Work on workflow builder:

- Made number of approvers dynamic with many select inputs as needed
- Tracking approval select input changes with step.assignees
- Fetching teachers and corporates from backend
- Responsive styling when rendering several select inputs
This commit is contained in:
Joao Correia
2025-01-23 02:48:25 +00:00
parent 4e81c08adb
commit aa76c2b54b
7 changed files with 165 additions and 102 deletions

View File

@@ -1,40 +1,47 @@
import { WorkflowStep } from "@/interfaces/approval.workflow"; import { WorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import { useState } from "react"; import { useEffect, useState } from "react";
import { BsTrash } from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import { LuGripHorizontal } from "react-icons/lu"; import { LuGripHorizontal } from "react-icons/lu";
import WorkflowStepNumber from "./WorkflowStepNumber"; import WorkflowStepNumber from "./WorkflowStepNumber";
import WorkflowStepSelects from "./WorkflowStepSelects"; import WorkflowStepSelects from "./WorkflowStepSelects";
import { CorporateUser, TeacherUser } from "@/interfaces/user";
import { AiOutlineUserAdd } from "react-icons/ai";
const teacherOptions: Option[] = [ interface Props extends WorkflowStep {
// fetch from database? entityTeachers: TeacherUser[];
] entityCorporates: CorporateUser[];
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
const directorOptions: Option[] = [ }
// fetch from database?
]
export default function WorkflowEditableStepComponent({ export default function WorkflowEditableStepComponent({
stepType, stepType,
stepNumber, stepNumber,
completed = false,
finalStep, finalStep,
onDelete, onDelete,
}: WorkflowStep) { onSelectChange,
entityTeachers,
entityCorporates,
}: Props) {
const [leftValue, setLeftValue] = useState<Option | null>(null); const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
const [rightValue, setRightValue] = useState<Option | null>(null);
let showSelects = false; const showSelects = stepType === "approval-by";
let leftPlaceholder = "";
let rightPlaceholder = "";
if (stepType === "approval-by") { const handleAddSelectComponent = () => {
// Show the selects only if it's an 'approval-by' step and in edit mode setSelects((prev) => {
showSelects = true; const updated = [...prev, null];
leftPlaceholder = "Approval by"; onSelectChange(updated.length, updated.length - 1, null);
rightPlaceholder = finalStep ? "2nd Director" : "2nd Teacher"; return updated;
} });
};
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
const updated = [...selects];
updated[index] = option;
setSelects(updated);
onSelectChange(numberOfSelects, index, option);
};
return ( return (
<div className="flex w-full"> <div className="flex w-full">
@@ -43,26 +50,22 @@ export default function WorkflowEditableStepComponent({
{/* Vertical Bar connecting steps */} {/* Vertical Bar connecting steps */}
{!finalStep && ( {!finalStep && (
<div className="w-1 h-10 bg-mti-purple-dark"></div> <div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
)} )}
</div> </div>
{stepNumber !== 1 && !finalStep {stepNumber !== 1 && !finalStep
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing" size={25} /> ? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div> : <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
} }
{showSelects && ( {showSelects && (
<div className="ml-10"> <div className="ml-10 mb-12">
<WorkflowStepSelects <WorkflowStepSelects
leftOptions={teacherOptions} teachers={entityTeachers}
rightOptions={teacherOptions} corporates={entityCorporates}
leftValue={leftValue} selects={selects}
rightValue={rightValue} onSelectChange={handleSelectChangeAt}
onLeftChange={setLeftValue}
onRightChange={setRightValue}
leftPlaceholder={leftPlaceholder}
rightPlaceholder={rightPlaceholder}
/> />
</div> </div>
)} )}
@@ -81,18 +84,27 @@ export default function WorkflowEditableStepComponent({
</div> </div>
)} )}
{stepNumber !== 1 && !finalStep && ( {stepNumber !== 1 && (
<div className="ml-4 mt-2"> <div className="flex flex-row items-start mt-1.5 ml-3">
<button <button
data-tip="Delete" type="button"
className="cursor-pointer tooltip" onClick={handleAddSelectComponent}
className="cursor-pointer"
>
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{!finalStep && (
<button
className="cursor-pointer"
onClick={onDelete} onClick={onDelete}
type="button" type="button"
> >
<BsTrash className="size-6 hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
); );

View File

@@ -6,14 +6,7 @@ import { IoIosAddCircleOutline } from "react-icons/io";
import Button from "../Low/Button"; import Button from "../Low/Button";
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent"; import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
import { ApprovalWorkflow, WorkflowStep } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow, WorkflowStep } from "@/interfaces/approval.workflow";
import { CorporateUser, TeacherUser } from "@/interfaces/user";
const teacherOptions: Option[] = [
// fetch from database?
]
const directorOptions: Option[] = [
// fetch from database?
]
// Variants for animating steps when they are added/removed // Variants for animating steps when they are added/removed
const itemVariants = { const itemVariants = {
@@ -25,9 +18,11 @@ const itemVariants = {
interface Props { interface Props {
workflow: ApprovalWorkflow; workflow: ApprovalWorkflow;
onWorkflowChange: (workflow: ApprovalWorkflow) => void; onWorkflowChange: (workflow: ApprovalWorkflow) => void;
entityTeachers: TeacherUser[];
entityCorporates: CorporateUser[];
} }
export default function WorkflowForm({ workflow, onWorkflowChange }: Props) { export default function WorkflowForm({ workflow, onWorkflowChange, entityTeachers, entityCorporates }: Props) {
const [steps, setSteps] = useState<WorkflowStep[]>(workflow.steps); const [steps, setSteps] = useState<WorkflowStep[]>(workflow.steps);
const [stepCounter, setStepCounter] = useState<number>(3); // to guarantee unique keys used for animations const [stepCounter, setStepCounter] = useState<number>(3); // to guarantee unique keys used for animations
const lastStep = steps[steps.length - 1]; const lastStep = steps[steps.length - 1];
@@ -55,6 +50,7 @@ export default function WorkflowForm({ workflow, onWorkflowChange }: Props) {
stepType: "approval-by", stepType: "approval-by",
stepNumber: steps.length, stepNumber: steps.length,
completed: false, completed: false,
assignees: [null],
}; };
setStepCounter((count) => count + 1); setStepCounter((count) => count + 1);
@@ -72,6 +68,27 @@ export default function WorkflowForm({ workflow, onWorkflowChange }: Props) {
} }
}; };
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
if (key === undefined) return;
setSteps((prevSteps) =>
prevSteps.map((step) => {
if (step.key !== key) return step;
const assignees = step.assignees ?? [];
let newAssignees = [...assignees];
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
newAssignees[index] = selectedOption?.value;
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
newAssignees.push(selectedOption?.value || null);
}
return { ...step, assignees: newAssignees };
})
);
};
const handleReorder = (newOrder: WorkflowStep[]) => { const handleReorder = (newOrder: WorkflowStep[]) => {
const firstIndex = newOrder.findIndex((s) => s.firstStep); const firstIndex = newOrder.findIndex((s) => s.firstStep);
if (firstIndex !== -1 && firstIndex !== 0) { if (firstIndex !== -1 && firstIndex !== 0) {
@@ -126,9 +143,11 @@ export default function WorkflowForm({ workflow, onWorkflowChange }: Props) {
editView editView
stepNumber={index + 1} stepNumber={index + 1}
stepType={step.stepType} stepType={step.stepType}
requestedBy="Prof. Foo"
finalStep={step.finalStep} finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)} onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) => handleSelectChange(step.key, numberOfSelects, idx, option)}
entityTeachers={entityTeachers}
entityCorporates={entityCorporates}
/> />
{step.finalStep && {step.finalStep &&
<Button <Button

View File

@@ -6,7 +6,7 @@ export default function WorkflowStepNumber({ stepNumber, selected = false, compl
return ( return (
<div <div
className={clsx( className={clsx(
'flex items-center justify-center w-11 h-11 rounded-full', 'flex items-center justify-center min-w-11 min-h-11 rounded-full',
{ {
'bg-mti-purple-dark text-mti-purple-ultralight': selected, 'bg-mti-purple-dark text-mti-purple-ultralight': selected,
'bg-mti-purple-ultralight text-gray-500': !selected, 'bg-mti-purple-ultralight text-gray-500': !selected,

View File

@@ -1,53 +1,60 @@
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { CorporateUser, TeacherUser } from "@/interfaces/user";
interface Props { interface Props {
leftOptions: Option[]; teachers: TeacherUser[];
rightOptions: Option[]; corporates: CorporateUser[];
leftValue?: Option | null; selects: (Option | null | undefined)[];
rightValue?: Option | null; onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
onLeftChange: (value: Option | null) => void;
onRightChange: (value: Option | null) => void;
leftPlaceholder?: string;
rightPlaceholder?: string;
} }
export default function WorkflowStepSelects({ export default function WorkflowStepSelects({
leftOptions, teachers,
rightOptions, corporates,
leftValue, selects,
rightValue, onSelectChange,
onLeftChange,
onRightChange,
leftPlaceholder = "Select",
rightPlaceholder = "Select",
}: Props) { }: Props) {
const teacherOptions: Option[] = teachers.map((teacher) => ({
value: teacher.id,
label: teacher.name,
icon: () => <img src={teacher.profilePicture} alt={teacher.name} />
}));
const corporateOptions: Option[] = corporates.map((corporate) => ({
value: corporate.id,
label: corporate.name,
icon: () => <img src={corporate.profilePicture} alt={corporate.name} />
}));
return ( return (
<div <div
className={"flex flex-row gap-0"} className={"flex flex-wrap gap-0"}
> >
{/* Left Select */} {selects.map((option, index) => {
<div className="flex-1 w-[275px]"> let classes = "px-2 rounded-none";
if (index === 0 && selects.length === 1) {
classes += " rounded-l-2xl rounded-r-2xl";
} else if (index === 0) {
classes += " rounded-l-2xl";
} else if (index === selects.length - 1) {
classes += " rounded-r-2xl";
}
return (
<div key={index} className="min-w-fit">
<Select <Select
options={leftOptions} options={[...teacherOptions, ...corporateOptions]}
value={leftValue} value={option}
onChange={onLeftChange} onChange={(option) => onSelectChange(selects.length, index, option)}
placeholder={leftPlaceholder} placeholder={"Approval By:"}
flat flat
className={"px-2 rounded-none rounded-l-2xl"} isClearable
/> className={classes}
</div>
{/* Right Select */}
<div className="flex-1 w-[275px]">
<Select
options={rightOptions}
value={rightValue}
onChange={onRightChange}
placeholder={rightPlaceholder}
flat
className="px-2 rounded-none rounded-r-2xl"
/> />
</div> </div>
);
})}
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import {Module} from "."; import {Module} from ".";
import Option from "./option";
import { CorporateUser, MasterCorporateUser, TeacherUser, userTypeLabels } from "./user"; import { CorporateUser, MasterCorporateUser, TeacherUser, userTypeLabels } from "./user";
export interface ApprovalWorkflow { export interface ApprovalWorkflow {
@@ -24,7 +25,7 @@ export interface WorkflowStep {
stepNumber: number, stepNumber: number,
completed?: boolean, completed?: boolean,
completedBy?: string, completedBy?: string,
assignees?: string[], assignees?: (string | null | undefined)[]; // bit of an hack, but allowing null or undefined values allows us to match one to one the select input components with the assignees array. And since select inputs allow undefined or null values, it is allowed here too, but must validate required input before form submission
assigneesType?: AssigneesType, assigneesType?: AssigneesType,
editView?: boolean, editView?: boolean,
firstStep?: boolean, firstStep?: boolean,

View File

@@ -1,7 +1,7 @@
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -15,11 +15,11 @@ import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { useState } from "react"; import { useEffect, useState } from "react";
import { MdFormatListBulletedAdd } from "react-icons/md"; import { MdFormatListBulletedAdd } from "react-icons/md";
import { User } from "@/interfaces/user"; import { CorporateUser, TeacherUser, User } from "@/interfaces/user";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import { getUserWithEntity } from "@/utils/users.be"; import { getEntitiesUsers, getUsers, getUserWithEntity } from "@/utils/users.be";
import { getEntities } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
@@ -30,10 +30,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/") return redirect("/")
const userEntitiesWithLabel = await getEntities(user.entities.map(entity => entity.id));
return { return {
props: serialize({ props: serialize({
user, user,
userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)), userEntitiesWithLabel,
userEntitiesTeachers: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: "teacher" }) as TeacherUser[],
userEntitiesCorporates: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: "corporate" }) as CorporateUser[],
}), }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -41,12 +45,34 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props { interface Props {
user: User, user: User,
userEntitiesWithLabel: Entity[], userEntitiesWithLabel: Entity[],
userEntitiesTeachers: TeacherUser[],
userEntitiesCorporates: CorporateUser[],
} }
export default function Home({ user, userEntitiesWithLabel }: Props) { export default function Home({ user, userEntitiesWithLabel, userEntitiesTeachers, userEntitiesCorporates }: Props) {
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]); const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | undefined>(undefined); const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | undefined>(undefined);
const [entityId, setEntityId] = useState<string | undefined>(undefined); const [entityId, setEntityId] = useState<string | undefined>(undefined);
const [entityTeachers, setEntityTeachers] = useState<TeacherUser[]>([]);
const [entityCorporates, setEntityCorporates] = useState<CorporateUser[]>([]);
useEffect(() => {
if (entityId) {
setEntityTeachers(
userEntitiesTeachers.filter(teacher =>
teacher.entities.some(entity => entity.id === entityId)
)
);
setEntityCorporates(
userEntitiesCorporates.filter(corporate =>
corporate.entities.some(entity => entity.id === entityId)
)
);
} else {
setEntityTeachers([]);
setEntityCorporates([]);
}
}, [entityId, userEntitiesTeachers, userEntitiesCorporates]);
const currentWorkflow = workflows.find(wf => wf.id === selectedWorkflowId); const currentWorkflow = workflows.find(wf => wf.id === selectedWorkflowId);
@@ -58,7 +84,6 @@ export default function Home({ user, userEntitiesWithLabel }: Props) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
// Handle form submission logic
console.log("Form submitted! Values:", workflows); console.log("Form submitted! Values:", workflows);
}; };
@@ -80,6 +105,7 @@ export default function Home({ user, userEntitiesWithLabel }: Props) {
}; };
const onWorkflowChange = (updatedWorkflow: ApprovalWorkflow) => { const onWorkflowChange = (updatedWorkflow: ApprovalWorkflow) => {
console.log(updatedWorkflow);
setWorkflows(prev => setWorkflows(prev =>
prev.map(wf => (wf.id === updatedWorkflow.id ? updatedWorkflow : wf)) prev.map(wf => (wf.id === updatedWorkflow.id ? updatedWorkflow : wf))
); );
@@ -177,6 +203,7 @@ export default function Home({ user, userEntitiesWithLabel }: Props) {
options={ENTITY_OPTIONS} options={ENTITY_OPTIONS}
onChange={(selectedEntity) => { onChange={(selectedEntity) => {
if (selectedEntity?.value) { if (selectedEntity?.value) {
setEntityId(selectedEntity.value);
const updatedWorkflow = { const updatedWorkflow = {
...currentWorkflow, ...currentWorkflow,
entityId: selectedEntity.value, entityId: selectedEntity.value,
@@ -202,6 +229,8 @@ export default function Home({ user, userEntitiesWithLabel }: Props) {
<WorkflowForm <WorkflowForm
workflow={currentWorkflow} workflow={currentWorkflow}
onWorkflowChange={onWorkflowChange} onWorkflowChange={onWorkflowChange}
entityTeachers={entityTeachers}
entityCorporates={entityCorporates}
/> />
</> </>
)} )}

View File

@@ -40,7 +40,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return { return {
props: serialize({ props: serialize({
user, user,
teachers: await getUsers({ type: "teacher" }) as TeacherUser[],
userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)), userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)),
}), }),
}; };
@@ -85,10 +84,6 @@ interface Props {
} }
export default function ApprovalWorkflows({ user, teachers, userEntitiesWithLabel }: Props) { export default function ApprovalWorkflows({ user, teachers, userEntitiesWithLabel }: Props) {
console.log(user);
console.log(teachers);
console.log(userEntitiesWithLabel);
const ENTITY_OPTIONS = [ const ENTITY_OPTIONS = [
{ {
@@ -208,7 +203,7 @@ export default function ApprovalWorkflows({ user, teachers, userEntitiesWithLabe
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{approvers.map((approver: string, index: number) => ( {approvers.map((approver: string | null | undefined, index: number) => (
<span <span
key={index} key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-gray-100 border border-gray-300 text-gray-900" className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-gray-100 border border-gray-300 text-gray-900"