diff --git a/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx b/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx index ec53d821..263202a9 100644 --- a/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx +++ b/src/components/ApprovalWorkflows/WorkflowEditableStepComponent.tsx @@ -26,6 +26,7 @@ export default function WorkflowEditableStepComponent({ }: Props) { const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]); + const [isAdding, setIsAdding] = useState(false); const teacherOptions: Option[] = useMemo(() => entityTeachers @@ -85,13 +86,17 @@ export default function WorkflowEditableStepComponent({ ); const handleAddSelectComponent = () => { - setSelects((prev) => { - const updated = [...prev, null]; - onSelectChange(updated.length, updated.length - 1, null); - return updated; - }); + setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender. + setSelects(prev => [...prev, null]); }; + useEffect(() => { + if (isAdding) { + onSelectChange(selects.length, selects.length - 1, null); + setIsAdding(false); + } + }, [selects.length, isAdding, onSelectChange]); + const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => { const updated = [...selects]; updated[index] = option; diff --git a/src/components/ApprovalWorkflows/WorkflowForm.tsx b/src/components/ApprovalWorkflows/WorkflowForm.tsx index d730b16a..da1cdffa 100644 --- a/src/components/ApprovalWorkflows/WorkflowForm.tsx +++ b/src/components/ApprovalWorkflows/WorkflowForm.tsx @@ -3,7 +3,7 @@ import Option from "@/interfaces/option"; import { CorporateUser, TeacherUser } from "@/interfaces/user"; import { AnimatePresence, Reorder, motion } from "framer-motion"; import { useState } from "react"; -import { FaRegCheckCircle } from "react-icons/fa"; +import { FaRegCheckCircle, FaSpinner } from "react-icons/fa"; import { IoIosAddCircleOutline } from "react-icons/io"; import Button from "../Low/Button"; import Tip from "./Tip"; @@ -14,9 +14,11 @@ interface Props { onWorkflowChange: (workflow: EditableApprovalWorkflow) => void; entityTeachers: TeacherUser[]; entityCorporates: CorporateUser[]; + isLoading: boolean; + isRedirecting: boolean; } -export default function WorkflowForm({ workflow, onWorkflowChange, entityTeachers, entityCorporates }: Props) { +export default function WorkflowForm({ workflow, onWorkflowChange, entityTeachers, entityCorporates, isLoading, isRedirecting }: Props) { const [stepCounter, setStepCounter] = useState(3); // to guarantee unique keys used for animations const lastStep = workflow.steps[workflow.steps.length - 1]; @@ -145,10 +147,25 @@ export default function WorkflowForm({ workflow, onWorkflowChange, entityTeacher type="submit" color="purple" variant="solid" + disabled={isLoading} className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4" > - - Confirm Exam Workflow Pipeline + {isRedirecting ? ( + <> + + Redirecting... + + ) : isLoading ? ( + <> + + Loading... + + ) : ( + <> + + Confirm Exam Workflow Pipeline + + )} } diff --git a/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx b/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx index a6741148..90de7772 100644 --- a/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx +++ b/src/components/ApprovalWorkflows/WorkflowStepComponent.tsx @@ -64,7 +64,7 @@ export default function WorkflowStepComponent({ /> )} - {!completed && completedBy && ( + {!completed && currentStep && (
In Progress... Assignees:
diff --git a/src/pages/api/approval-workflows/[id]/clone.ts b/src/pages/api/approval-workflows/[id]/clone.ts new file mode 100644 index 00000000..6515d751 --- /dev/null +++ b/src/pages/api/approval-workflows/[id]/clone.ts @@ -0,0 +1,27 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import { sessionOptions } from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { createApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return await post(req, res); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const approvalWorkflow: ApprovalWorkflow = req.body; + + if (approvalWorkflow) + return res.status(201).json(await createApprovalWorkflow(approvalWorkflow)); +} diff --git a/src/pages/api/approval-workflows/[id]/edit.ts b/src/pages/api/approval-workflows/[id]/edit.ts new file mode 100644 index 00000000..2d517f0e --- /dev/null +++ b/src/pages/api/approval-workflows/[id]/edit.ts @@ -0,0 +1,28 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { ApprovalWorkflow } from "@/interfaces/approval.workflow"; +import { sessionOptions } from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { createApprovalWorkflows, updateApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "PUT") return await put(req, res); +} + +async function put(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { + return res.status(403).json({ ok: false }); + } + + const { id } = req.query as { id?: string }; + const approvalWorkflow: ApprovalWorkflow = req.body; + + if (id && approvalWorkflow) + return res.status(204).json(await updateApprovalWorkflow(id, approvalWorkflow)); +} diff --git a/src/pages/approval-workflows/[id]/clone.tsx b/src/pages/approval-workflows/[id]/clone.tsx index 0d12b2f4..8bb620f7 100644 --- a/src/pages/approval-workflows/[id]/clone.tsx +++ b/src/pages/approval-workflows/[id]/clone.tsx @@ -21,8 +21,11 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { BsChevronLeft } from "react-icons/bs"; import { GrClearOption } from "react-icons/gr"; -import { ToastContainer } from "react-toastify"; +import { toast, ToastContainer } from "react-toastify"; import approvalWorkflowsData from '../../../demo/approval_workflows.json'; // to test locally +import { getApprovalWorkflow } from "@/utils/approval.workflows.be"; +import { useRouter } from "next/router"; +import axios from "axios"; export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { const user = await requestUser(req, res); @@ -33,9 +36,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params } const { id } = params as { id: string }; - // replace later with await getApprovalWorkflow(id). - const approvalWorkflowsDataAsWorkflows = approvalWorkflowsData as ApprovalWorkflow[]; - const workflow: ApprovalWorkflow | undefined = approvalWorkflowsDataAsWorkflows.find(workflow => workflow.id === id); + const workflow: ApprovalWorkflow | null = await getApprovalWorkflow(id); if (!workflow) return redirect("/approval-workflows") @@ -66,6 +67,10 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti const [entityId, setEntityId] = useState(workflow.entityId); const [entityTeachers, setEntityTeachers] = useState([]); const [entityCorporates, setEntityCorporates] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isRedirecting, setIsRedirecting] = useState(false); + + const router = useRouter(); useEffect(() => { if (entityId) { @@ -118,18 +123,47 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - - if (!cloneWorkflow) return; - - const filteredWorkflow = { + setIsLoading(true); + + if (!cloneWorkflow) { + setIsLoading(false); + return; + } + + const filteredWorkflow: ApprovalWorkflow = { ...cloneWorkflow, steps: cloneWorkflow.steps.map(step => ({ ...step, - assignees: step.assignees.filter(assignee => assignee !== null && assignee !== undefined) + currentStep: step.stepNumber === 1 ? true : false, + completed: false, + assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined) })) }; - - console.log("Form submitted! Filtered Workflow:", filteredWorkflow); + + axios + .post(`/api/approval-workflows/${workflow.id}/clone`, filteredWorkflow) + .then(() => { + toast.success("Approval Workflow cloned successfully."); + setIsRedirecting(true); + setTimeout(() => { + router.push("/approval-workflows"); + }, 2000); + }) + .catch((reason) => { + if (reason.response.status === 401) { + toast.error("Not logged in!"); + return router.push("/login"); + } + if (reason.response.status === 403) { + toast.error("You do not have permission to clone Approval Workflows!"); + return router.push("/approval-workflows"); + } + toast.error("Something went wrong, please try again later."); + setIsLoading(false); + return; + }) + + console.log("Form submitted! Filtered Values:", filteredWorkflow); }; const onWorkflowChange = (wf: EditableApprovalWorkflow) => { @@ -257,6 +291,8 @@ export default function Home({ user, workflow, userEntitiesWithLabel, userEntiti onWorkflowChange={onWorkflowChange} entityTeachers={entityTeachers} entityCorporates={entityCorporates} + isLoading={isLoading} + isRedirecting={isRedirecting} /> diff --git a/src/pages/approval-workflows/[id]/edit.tsx b/src/pages/approval-workflows/[id]/edit.tsx index bbe35b89..6d8b477d 100644 --- a/src/pages/approval-workflows/[id]/edit.tsx +++ b/src/pages/approval-workflows/[id]/edit.tsx @@ -16,8 +16,10 @@ import Head from "next/head"; import Link from "next/link"; import { useEffect, useState } from "react"; import { BsChevronLeft } from "react-icons/bs"; -import { ToastContainer } from "react-toastify"; -import approvalWorkflowsData from '../../../demo/approval_workflows.json'; // to test locally +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); @@ -28,9 +30,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params } const { id } = params as { id: string }; - // replace later with await getApprovalWorkflow(id). - const approvalWorkflowsDataAsWorkflows = approvalWorkflowsData as ApprovalWorkflow[]; - const workflow: ApprovalWorkflow | undefined = approvalWorkflowsDataAsWorkflows.find(workflow => workflow.id === id); + const workflow: ApprovalWorkflow | null = await getApprovalWorkflow(id); if (!workflow) return redirect("/approval-workflows") @@ -54,6 +54,10 @@ interface Props { export default function Home({ user, workflow, workflowEntityTeachers, workflowEntityCorporates }: Props) { const [updatedWorkflow, setUpdatedWorkflow] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isRedirecting, setIsRedirecting] = useState(false); + + const router = useRouter(); useEffect(() => { const editableSteps: EditableWorkflowStep[] = workflow.steps.map(step => ({ @@ -82,23 +86,52 @@ export default function Home({ user, workflow, workflowEntityTeachers, workflowE const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - - if (!updatedWorkflow) return; - - const filteredWorkflow = { + setIsLoading(true); + + if (!updatedWorkflow){ + setIsLoading(false); + return; + } + + const filteredWorkflow: ApprovalWorkflow = { ...updatedWorkflow, steps: updatedWorkflow.steps.map(step => ({ ...step, - assignees: step.assignees.filter(assignee => assignee !== null && assignee !== undefined) + currentStep: step.stepNumber === 1 ? true : false, + completed: false, + assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined) })) }; - - console.log("Form submitted! Filtered Workflow:", filteredWorkflow); + + axios + .put(`/api/approval-workflows/${workflow.id}/edit`, filteredWorkflow) + .then(() => { + toast.success("Approval Workflow edited successfully."); + setIsRedirecting(true); + setTimeout(() => { + router.push("/approval-workflows"); + }, 2000); + }) + .catch((reason) => { + if (reason.response.status === 401) { + toast.error("Not logged in!"); + return router.push("/login"); + } + if (reason.response.status === 403) { + toast.error("You do not have permission to edit Approval Workflows!"); + return router.push("/approval-workflows"); + } + toast.error("Something went wrong, please try again later."); + setIsLoading(false); + return; + }) + + console.log("Form submitted! Filtered Values:", filteredWorkflow); }; - const onWorkflowChange = (wf: EditableApprovalWorkflow) => { - setUpdatedWorkflow(wf); - } + const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => { + setUpdatedWorkflow(updatedWorkflow); + }; return ( <> @@ -153,6 +186,8 @@ export default function Home({ user, workflow, workflowEntityTeachers, workflowE onWorkflowChange={onWorkflowChange} entityTeachers={workflowEntityTeachers} entityCorporates={workflowEntityCorporates} + isLoading={isLoading} + isRedirecting={isRedirecting} /> } diff --git a/src/pages/approval-workflows/[id]/index.tsx b/src/pages/approval-workflows/[id]/index.tsx index 32e3a2b1..0ffe56e2 100644 --- a/src/pages/approval-workflows/[id]/index.tsx +++ b/src/pages/approval-workflows/[id]/index.tsx @@ -1,6 +1,7 @@ import RequestedBy from "@/components/ApprovalWorkflows/RequestedBy"; import StartedOn from "@/components/ApprovalWorkflows/StartedOn"; import Status from "@/components/ApprovalWorkflows/Status"; +import Tip from "@/components/ApprovalWorkflows/Tip"; import UserWithProfilePic from "@/components/ApprovalWorkflows/UserWithProfilePic"; import WorkflowStepComponent from "@/components/ApprovalWorkflows/WorkflowStepComponent"; import Layout from "@/components/High/Layout"; @@ -9,8 +10,10 @@ 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 { shouldRedirectHome } from "@/utils/navigation.disabled"; import { getSpecificUsers, getUser } from "@/utils/users.be"; +import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { withIronSessionSsr } from "iron-session/next"; import Head from "next/head"; import Link from "next/link"; @@ -21,10 +24,6 @@ import { MdOutlineDoubleArrow } from "react-icons/md"; import { RiThumbUpLine } from "react-icons/ri"; import { ToastContainer } from "react-toastify"; -import approvalWorkflowsData from '../../../demo/approval_workflows.json'; // to test locally -import Tip from "@/components/ApprovalWorkflows/Tip"; -import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; - export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { const user = await requestUser(req, res); if (!user) return redirect("/login") @@ -34,9 +33,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params } const { id } = params as { id: string }; - // replace later with await getApprovalWorkflow(id). - const approvalWorkflowsDataAsWorkflows = approvalWorkflowsData as ApprovalWorkflow[]; - const workflow: ApprovalWorkflow | undefined = approvalWorkflowsDataAsWorkflows.find(workflow => workflow.id === id); + const workflow: ApprovalWorkflow | null = await getApprovalWorkflow(id); if (!workflow) return redirect("/approval-workflows") diff --git a/src/pages/approval-workflows/create.tsx b/src/pages/approval-workflows/create.tsx index 7dbdae77..8e34dca1 100644 --- a/src/pages/approval-workflows/create.tsx +++ b/src/pages/approval-workflows/create.tsx @@ -18,6 +18,7 @@ import { AnimatePresence, LayoutGroup, 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, BsTrash } from "react-icons/bs"; import { MdFormatListBulletedAdd } from "react-icons/md"; @@ -56,7 +57,11 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesTeachers const [entityId, setEntityId] = useState(null); const [entityTeachers, setEntityTeachers] = useState([]); const [entityCorporates, setEntityCorporates] = useState([]); - const [isAdding, setIsAdding] = useState(false); // used to temporary timeout new workflow button. With animations, clicking too fast might cause state inconsistencies between renders. + const [isAdding, setIsAdding] = useState(false); // used to temporary timeout new workflow button. With animations, clicking too fast might cause state inconsistencies between renders. + const [isLoading, setIsLoading] = useState(false); + const [isRedirecting, setIsRedirecting] = useState(false); + + const router = useRouter(); useEffect(() => { if (entityId) { @@ -86,6 +91,11 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesTeachers const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + setIsLoading(true); + + if (workflows.length === 0) { + setIsLoading(false); + } const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({ ...workflow, @@ -99,24 +109,28 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesTeachers axios .post(`/api/approval-workflows/create`, filteredWorkflows) - .then(() => toast.success(`Approval Workflows created successfully.`)) + .then(() => { + toast.success("Approval Workflows created successfully."); + setIsRedirecting(true); + setTimeout(() => { + router.push("/approval-workflows"); + }, 2000); + }) .catch((reason) => { if (reason.response.status === 401) { toast.error("Not logged in!"); - return redirect("/login"); + return router.push("/login"); } if (reason.response.status === 403) { toast.error("You do not have permission to create Approval Workflows!"); - return; + return router.push("/approval-workflows"); } - toast.error("Something went wrong, please try again later."); + setIsLoading(false); return; }) - + console.log("Form submitted! Filtered Values:", filteredWorkflows); - - return redirect("/approval-workflows"); }; const handleAddNewWorkflow = () => { @@ -309,6 +323,8 @@ export default function Home({ user, userEntitiesWithLabel, userEntitiesTeachers onWorkflowChange={onWorkflowChange} entityTeachers={entityTeachers} entityCorporates={entityCorporates} + isLoading={isLoading} + isRedirecting={isRedirecting} /> diff --git a/src/utils/approval.workflows.be.ts b/src/utils/approval.workflows.be.ts index 5c7549d3..b163e9d0 100644 --- a/src/utils/approval.workflows.be.ts +++ b/src/utils/approval.workflows.be.ts @@ -16,9 +16,13 @@ export const getApprovalWorkflow = async (id: string) => { export const createApprovalWorkflow = async (workflow: ApprovalWorkflow) => { await db.collection("approval-workflows").insertOne(workflow); -} +}; export const createApprovalWorkflows = async (workflows: ApprovalWorkflow[]) => { if (workflows.length === 0) return; await db.collection("approval-workflows").insertMany(workflows); -}; \ No newline at end of file +}; + +export const updateApprovalWorkflow = async (id: string, workflow: ApprovalWorkflow) => { + await db.collection("approval-workflows").replaceOne({ id }, workflow); +};