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

View File

@@ -6,14 +6,7 @@ import { IoIosAddCircleOutline } from "react-icons/io";
import Button from "../Low/Button";
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
import { ApprovalWorkflow, WorkflowStep } from "@/interfaces/approval.workflow";
const teacherOptions: Option[] = [
// fetch from database?
]
const directorOptions: Option[] = [
// fetch from database?
]
import { CorporateUser, TeacherUser } from "@/interfaces/user";
// Variants for animating steps when they are added/removed
const itemVariants = {
@@ -25,9 +18,11 @@ const itemVariants = {
interface Props {
workflow: ApprovalWorkflow;
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 [stepCounter, setStepCounter] = useState<number>(3); // to guarantee unique keys used for animations
const lastStep = steps[steps.length - 1];
@@ -55,6 +50,7 @@ export default function WorkflowForm({ workflow, onWorkflowChange }: Props) {
stepType: "approval-by",
stepNumber: steps.length,
completed: false,
assignees: [null],
};
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 firstIndex = newOrder.findIndex((s) => s.firstStep);
if (firstIndex !== -1 && firstIndex !== 0) {
@@ -126,9 +143,11 @@ export default function WorkflowForm({ workflow, onWorkflowChange }: Props) {
editView
stepNumber={index + 1}
stepType={step.stepType}
requestedBy="Prof. Foo"
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) => handleSelectChange(step.key, numberOfSelects, idx, option)}
entityTeachers={entityTeachers}
entityCorporates={entityCorporates}
/>
{step.finalStep &&
<Button

View File

@@ -6,7 +6,7 @@ export default function WorkflowStepNumber({ stepNumber, selected = false, compl
return (
<div
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-ultralight text-gray-500': !selected,

View File

@@ -1,53 +1,60 @@
import Option from "@/interfaces/option";
import Select from "../Low/Select";
import { CorporateUser, TeacherUser } from "@/interfaces/user";
interface Props {
leftOptions: Option[];
rightOptions: Option[];
leftValue?: Option | null;
rightValue?: Option | null;
onLeftChange: (value: Option | null) => void;
onRightChange: (value: Option | null) => void;
leftPlaceholder?: string;
rightPlaceholder?: string;
teachers: TeacherUser[];
corporates: CorporateUser[];
selects: (Option | null | undefined)[];
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
}
export default function WorkflowStepSelects({
leftOptions,
rightOptions,
leftValue,
rightValue,
onLeftChange,
onRightChange,
leftPlaceholder = "Select",
rightPlaceholder = "Select",
teachers,
corporates,
selects,
onSelectChange,
}: 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 (
<div
className={"flex flex-row gap-0"}
className={"flex flex-wrap gap-0"}
>
{/* Left Select */}
<div className="flex-1 w-[275px]">
{selects.map((option, index) => {
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
options={leftOptions}
value={leftValue}
onChange={onLeftChange}
placeholder={leftPlaceholder}
options={[...teacherOptions, ...corporateOptions]}
value={option}
onChange={(option) => onSelectChange(selects.length, index, option)}
placeholder={"Approval By:"}
flat
className={"px-2 rounded-none rounded-l-2xl"}
/>
</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"
isClearable
className={classes}
/>
</div>
);
})}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import {Module} from ".";
import Option from "./option";
import { CorporateUser, MasterCorporateUser, TeacherUser, userTypeLabels } from "./user";
export interface ApprovalWorkflow {
@@ -24,7 +25,7 @@ export interface WorkflowStep {
stepNumber: number,
completed?: boolean,
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,
editView?: boolean,
firstStep?: boolean,

View File

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

View File

@@ -40,7 +40,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return {
props: serialize({
user,
teachers: await getUsers({ type: "teacher" }) as TeacherUser[],
userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)),
}),
};
@@ -85,10 +84,6 @@ interface Props {
}
export default function ApprovalWorkflows({ user, teachers, userEntitiesWithLabel }: Props) {
console.log(user);
console.log(teachers);
console.log(userEntitiesWithLabel);
const ENTITY_OPTIONS = [
{
@@ -208,7 +203,7 @@ export default function ApprovalWorkflows({ user, teachers, userEntitiesWithLabe
return (
<div className="flex flex-wrap gap-2">
{approvers.map((approver: string, index: number) => (
{approvers.map((approver: string | null | undefined, index: number) => (
<span
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"