major change on how workflow builder works. It now fetches in edit mode all the currently configured workflows

This commit is contained in:
Joao Correia
2025-02-01 22:36:42 +00:00
parent a0229cd971
commit ac539332e6
12 changed files with 159 additions and 56 deletions

View File

@@ -33,6 +33,7 @@ export default function WorkflowForm({ workflow, onWorkflowChange, entityApprove
key: stepCounter,
stepType: "approval-by",
stepNumber: workflow.steps.length,
completed: false,
assignees: [null],
firstStep: false,
finalStep: false,

View File

@@ -43,6 +43,10 @@ export interface EditableWorkflowStep {
key: number,
stepType: StepType,
stepNumber: number,
completed: boolean,
rejected?: boolean,
completedBy?: User["id"],
completedDate?: number,
assignees: (User["id"] | 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
firstStep: boolean,
finalStep?: boolean,

View File

@@ -2,7 +2,7 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { createApprovalWorkflow } from "@/utils/approval.workflows.be";
import { createConfiguredWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -23,5 +23,5 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const approvalWorkflow: ApprovalWorkflow = req.body;
if (approvalWorkflow)
return res.status(201).json(await createApprovalWorkflow(approvalWorkflow));
return res.status(201).json(await createConfiguredWorkflow(approvalWorkflow));
}

View File

@@ -2,7 +2,7 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { updateConfiguredWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -24,7 +24,7 @@ async function put(req: NextApiRequest, res: NextApiResponse) {
const approvalWorkflow: ApprovalWorkflow = req.body;
if (id && approvalWorkflow) {
await updateApprovalWorkflow(id, approvalWorkflow);
await updateConfiguredWorkflow(approvalWorkflow);
return res.status(204).end();
}
}

View File

@@ -2,7 +2,7 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { deleteApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { deleteConfiguredWorkflow, updateConfiguredWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -23,7 +23,7 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id?: string };
if (id) return res.status(200).json(await deleteApprovalWorkflow(id));
if (id) return res.status(200).json(await deleteConfiguredWorkflow(id));
}
async function put(req: NextApiRequest, res: NextApiResponse) {
@@ -38,7 +38,7 @@ async function put(req: NextApiRequest, res: NextApiResponse) {
const workflow = req.body;
if (id && workflow) {
await updateApprovalWorkflow(id, workflow);
await updateConfiguredWorkflow(workflow);
return res.status(204).end();
}
}

View File

@@ -1,13 +1,19 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { Entity } from "@/interfaces/entity";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { createApprovalWorkflows } from "@/utils/approval.workflows.be";
import { replaceConfiguredWorkflowsByEntities } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
interface ReplaceApprovalWorkflowsRequest {
filteredWorkflows: ApprovalWorkflow[];
userEntitiesWithLabel: Entity[];
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return await post(req, res);
}
@@ -20,9 +26,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return res.status(403).json({ ok: false });
}
const approvalWorkflows: ApprovalWorkflow[] = req.body;
const { filteredWorkflows, userEntitiesWithLabel } = req.body as ReplaceApprovalWorkflowsRequest;
await createApprovalWorkflows(approvalWorkflows);
const configuredWorkflows: ApprovalWorkflow[] = filteredWorkflows;
const entitiesIds: string[] = userEntitiesWithLabel.map((e) => e.id);
return res.status(201).json(approvalWorkflows);
await replaceConfiguredWorkflowsByEntities(configuredWorkflows, entitiesIds);
return res.status(201).json({ ok: true });
}

View File

@@ -22,9 +22,9 @@ import { useEffect, useState } from "react";
import { BsChevronLeft } from "react-icons/bs";
import { GrClearOption } from "react-icons/gr";
import { toast, ToastContainer } from "react-toastify";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { useRouter } from "next/router";
import axios from "axios";
import { getConfiguredWorkflow } from "@/utils/approval.workflows.be";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res);
@@ -35,7 +35,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const { id } = params as { id: string };
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow(id);
const workflow: ApprovalWorkflow | null = await getConfiguredWorkflow(id);
if (!workflow)
return redirect("/approval-workflows")
@@ -91,6 +91,7 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti
key: step.stepNumber + 999, // just making sure they are unique because new steps that users add will have key=3 key=4 etc
stepType: step.stepType,
stepNumber: step.stepNumber,
completed: false,
assignees: step.assignees.map(id => id),
firstStep: step.firstStep || false,
finalStep: step.finalStep || false,
@@ -179,8 +180,8 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti
startDate: Date.now(),
status: "pending",
steps: [
{ key: 9998, stepType: "form-intake", stepNumber: 1, firstStep: true, finalStep: false, assignees: [null] },
{ key: 9999, stepType: "approval-by", stepNumber: 2, firstStep: false, finalStep: true, assignees: [null] },
{ key: 9998, stepType: "form-intake", stepNumber: 1, completed: false, firstStep: true, finalStep: false, assignees: [null] },
{ key: 9999, stepType: "approval-by", stepNumber: 2, completed: false, firstStep: false, finalStep: true, assignees: [null] },
],
};
setCloneWorkflow(newWorkflow);

View File

@@ -8,18 +8,18 @@ import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User }
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getConfiguredWorkflow } from "@/utils/approval.workflows.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getEntityUsers } from "@/utils/users.be";
import axios from "axios";
import { motion } from "framer-motion";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { BsChevronLeft } from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
import axios from "axios";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { useRouter } from "next/router";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res);
@@ -30,7 +30,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const { id } = params as { id: string };
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow(id);
const workflow: ApprovalWorkflow | null = await getConfiguredWorkflow(id);
if (!workflow)
return redirect("/approval-workflows")
@@ -62,6 +62,9 @@ export default function Home({ user, workflow, workflowEntityApprovers }: Props)
key: step.stepNumber + 999, // just making sure they are unique because new steps that users add will have key=3 key=4 etc
stepType: step.stepType,
stepNumber: step.stepNumber,
completed: step.completed,
completedBy: step.completedBy || undefined,
completedDate: step.completedDate || undefined,
assignees: step.assignees.map(id => id),
firstStep: step.firstStep || false,
finalStep: step.finalStep || false,

View File

@@ -11,7 +11,7 @@ import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { getConfiguredWorkflow } from "@/utils/approval.workflows.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getSpecificUsers, getUser } from "@/utils/users.be";
import axios from "axios";
@@ -23,12 +23,14 @@ import { useRouter } from "next/router";
import { useState } from "react";
import { BsChevronLeft } from "react-icons/bs";
import { FaSpinner, FaWpforms } from "react-icons/fa6";
import { FiSave } from "react-icons/fi";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { IoDocumentTextOutline } from "react-icons/io5";
import { MdOutlineDoubleArrow } from "react-icons/md";
import { RiThumbUpLine } from "react-icons/ri";
import { toast, ToastContainer } from "react-toastify";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { FiSave } from "react-icons/fi";
import { RxCrossCircled } from "react-icons/rx";
import { TiEdit } from "react-icons/ti";
import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res);
@@ -39,7 +41,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const { id } = params as { id: string };
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow(id);
const workflow: ApprovalWorkflow | null = await getConfiguredWorkflow(id);
if (!workflow)
return redirect("/approval-workflows")
@@ -132,6 +134,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
const updatedWorkflow: ApprovalWorkflow = {
...workflow,
status: selectedStepIndex === workflow.steps.length - 1 ? "approved" : "pending",
steps: workflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
@@ -206,6 +209,14 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
})
};
const handleViewExam = () => {
}
const handleEditExam = () => {
}
return (
<>
<Head>
@@ -244,6 +255,29 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
status={workflow.status}
/>
</div>
<div className="flex flex-row gap-3">
<Button
color="purple"
variant="solid"
onClick={handleViewExam}
padding="px-6 py-2"
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
<IoDocumentTextOutline />
View Exam
</Button>
<Button
color="purple"
variant="solid"
onClick={handleEditExam}
padding="px-6 py-2"
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
<TiEdit size={20} />
Edit Exam
</Button>
</div>
{steps.find((step) => !step.completed) === undefined &&
<Tip text="All steps in this instance have been completed." />
}
@@ -271,7 +305,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
{/* Side panel */}
<AnimatePresence mode="wait">
<LayoutGroup key="sidePanel">
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-2/5' : 'w-0'}`}>
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
{isPanelOpen && selectedStep && (
<motion.div
className="p-6"
@@ -412,7 +446,7 @@ export default function Home({ user, workflow, workflowAssignees, workflowReques
) : (
<>
<RxCrossCircled size={20} />
Reject Step
Reject
</>
)}
</Button>

View File

@@ -10,6 +10,7 @@ import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User }
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getConfiguredWorkflowsByEntities } from "@/utils/approval.workflows.be";
import { getEntities } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getEntitiesUsers } from "@/utils/users.be";
@@ -33,10 +34,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return redirect("/")
const userEntitiesWithLabel = await getEntities(user.entities.map(entity => entity.id));
const allConfiguredWorkflows = await getConfiguredWorkflowsByEntities(userEntitiesWithLabel.map(entity => entity.id));
return {
props: serialize({
user,
allConfiguredWorkflows,
userEntitiesWithLabel,
userEntitiesApprovers: await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: { $in: ["teacher", "corporate", "mastercorporate", "developer"] } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}),
@@ -45,12 +49,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props {
user: User,
allConfiguredWorkflows: EditableApprovalWorkflow[],
userEntitiesWithLabel: Entity[],
userEntitiesApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
}
export default function Home({ user, userEntitiesWithLabel, userEntitiesApprovers }: Props) {
const [workflows, setWorkflows] = useState<EditableApprovalWorkflow[]>([]);
export default function Home({ user, allConfiguredWorkflows, userEntitiesWithLabel, userEntitiesApprovers }: Props) {
const [workflows, setWorkflows] = useState<EditableApprovalWorkflow[]>(allConfiguredWorkflows);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | undefined>(undefined);
const [entityId, setEntityId] = useState<string | null | undefined>(null);
const [entityApprovers, setEntityApprovers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
@@ -93,13 +98,14 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesApprover
...workflow,
steps: workflow.steps.map(step => ({
...step,
completed: false,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
}))
}));
const requestData = {filteredWorkflows, userEntitiesWithLabel};
axios
.post(`/api/approval-workflows/create`, filteredWorkflows)
.post(`/api/approval-workflows/create`, requestData)
.then(() => {
toast.success("Approval Workflows created successfully.");
setIsRedirecting(true);
@@ -135,8 +141,8 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesApprover
startDate: Date.now(),
status: "pending",
steps: [
{ key: 9998, stepType: "form-intake", stepNumber: 1, firstStep: true, finalStep: false, assignees: [null] },
{ key: 9999, stepType: "approval-by", stepNumber: 2, firstStep: false, finalStep: true, assignees: [null] },
{ key: 9998, stepType: "form-intake", stepNumber: 1, completed: false, firstStep: true, finalStep: false, assignees: [null] },
{ key: 9999, stepType: "approval-by", stepNumber: 2, completed: false, firstStep: false, finalStep: true, assignees: [null] },
],
};
setWorkflows((prev) => [...prev, newWorkflow]);

View File

@@ -1,5 +1,6 @@
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import { Module, ModuleTypeLabels } from "@/interfaces";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
@@ -8,6 +9,7 @@ import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getConfiguredWorkflows } from "@/utils/approval.workflows.be";
import { getEntities } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getSpecificUsers } from "@/utils/users.be";
@@ -17,18 +19,14 @@ import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { BsTrash } from "react-icons/bs";
import { FaRegEdit } from "react-icons/fa";
import { FaRegClone } from "react-icons/fa6";
import { IoIosAddCircleOutline } from "react-icons/io";
import { toast, ToastContainer } from "react-toastify";
import Input from "@/components/Low/Input";
import { FaRegClone } from "react-icons/fa6";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { useRouter } from "next/router";
const columnHelper = createColumnHelper<ApprovalWorkflow>();
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
@@ -38,7 +36,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/")
const workflows = await getApprovalWorkflows();
const workflows = await getConfiguredWorkflows();
const allAssigneeIds: string[] = [
...new Set(
@@ -331,7 +329,7 @@ export default function ApprovalWorkflows({ user, workflows, workflowsAssignees,
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
<IoIosAddCircleOutline className="size-6" />
Configure New Workflows
Configure Workflows
</Button>
</Link>
</div>

View File

@@ -1,36 +1,83 @@
import { ObjectId } from "mongodb";
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import client from "@/lib/mongodb";
import { ObjectId } from "mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getApprovalWorkflows = async (ids?: string[]) => {
export const getConfiguredWorkflows = async (ids?: string[]) => {
return await db
.collection<ApprovalWorkflow>("approval-workflows")
.collection<ApprovalWorkflow>("configured-workflows")
.find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {})
.toArray();
};
export const getApprovalWorkflow = async (id: string) => {
return await db.collection<ApprovalWorkflow>("approval-workflows").findOne({ _id: new ObjectId(id) });
export const getConfiguredWorkflow = async (id: string) => {
return await db.collection<ApprovalWorkflow>("configured-workflows").findOne({ _id: new ObjectId(id) });
};
export const createApprovalWorkflow = async (workflow: ApprovalWorkflow) => {
const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection("approval-workflows").insertOne(workflowWithoutId);
export const getConfiguredWorkflowsByEntities = async (ids: string[]) => {
return await db
.collection<ApprovalWorkflow>("configured-workflows")
.find({ entityId: { $in: ids } })
.toArray();
};
export const createApprovalWorkflows = async (workflows: ApprovalWorkflow[]) => {
export const createConfiguredWorkflow = async (workflow: ApprovalWorkflow) => {
const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection("configured-workflows").insertOne(workflowWithoutId);
};
export const createConfiguredWorkflows = async (workflows: ApprovalWorkflow[]) => {
if (workflows.length === 0) return;
const workflowsWithoutIds: ApprovalWorkflow[] = workflows.map(({_id, ...wfs}) => wfs)
return await db.collection("approval-workflows").insertMany(workflowsWithoutIds);
const workflowsWithoutIds: ApprovalWorkflow[] = workflows.map(({ _id, ...wfs }) => wfs);
return await db.collection("configured-workflows").insertMany(workflowsWithoutIds);
};
export const updateApprovalWorkflow = async (id: string, workflow: ApprovalWorkflow) => {
const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection("approval-workflows").replaceOne({ _id: new ObjectId(id) }, workflowWithoutId);
export const updateConfiguredWorkflow = async (workflow: ApprovalWorkflow) => {
const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection("configured-workflows").replaceOne({ _id: new ObjectId(_id) }, workflowWithoutId);
};
export const deleteApprovalWorkflow = async (id: string) => {
return await db.collection("approval-workflows").deleteOne({ _id: new ObjectId(id) });
export const updateConfiguredWorkflows = async (workflows: ApprovalWorkflow[]) => {
const bulkOperations = workflows.map((workflow) => {
const { _id, ...workflowWithoutId } = workflow;
return {
replaceOne: {
filter: { _id: new ObjectId(_id) },
replacement: workflowWithoutId,
},
};
});
return await db.collection("configured-workflows").bulkWrite(bulkOperations);
};
export const deleteConfiguredWorkflow = async (id: string) => {
return await db.collection("configured-workflows").deleteOne({ _id: new ObjectId(id) });
};
export const replaceConfiguredWorkflowsByEntities = async (workflows: ApprovalWorkflow[], entityIds: string[]) => {
// 1. Keep track of the _id values of all workflows we want to end up with
const finalIds = new Set<string>();
// 2. Process incoming workflows
for (const workflow of workflows) {
if (workflow._id) {
// Replace the existing ones
await updateConfiguredWorkflow(workflow);
finalIds.add(workflow._id.toString());
} else {
// Insert if no _id
const insertResult = await createConfiguredWorkflow(workflow);
finalIds.add(insertResult.insertedId.toString());
}
}
// 3. Delete any existing workflow (within these entityIds) that wasn't in the final list
await db.collection("configured-workflows").deleteMany({
_id: {
$nin: Array.from(finalIds).map((id) => new ObjectId(id)),
},
entityId: { $in: entityIds },
});
};