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:
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user