diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 71f695a1..d1325420 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -53,6 +53,7 @@ jobs: FORMIO_2023_FRF_PATH: ${{ secrets.FORMIO_2023_FRF_PATH }} FORMIO_2023_PRF_PATH: ${{ secrets.FORMIO_2023_PRF_PATH }} FORMIO_2023_CRF_PATH: ${{ secrets.FORMIO_2023_CRF_PATH }} + FORMIO_2023_CHANGE_PATH: ${{ secrets.FORMIO_2023_CHANGE_PATH }} FORMIO_BASE_URL: ${{ secrets.FORMIO_BASE_URL }} FORMIO_PROJECT_NAME: ${{ secrets.FORMIO_PROJECT_NAME }} FORMIO_API_KEY: ${{ secrets.FORMIO_API_KEY }} @@ -142,6 +143,7 @@ jobs: cf set-env $APP_NAME "FORMIO_2023_FRF_PATH" "$FORMIO_2023_FRF_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_2023_PRF_PATH" "$FORMIO_2023_PRF_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_2023_CRF_PATH" "$FORMIO_2023_CRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_CHANGE_PATH" "$FORMIO_2023_CHANGE_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_BASE_URL" "$FORMIO_BASE_URL" > /dev/null cf set-env $APP_NAME "FORMIO_PROJECT_NAME" "$FORMIO_PROJECT_NAME" > /dev/null cf set-env $APP_NAME "FORMIO_API_KEY" "$FORMIO_API_KEY" > /dev/null diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 69fe9edb..f8677b79 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -53,6 +53,7 @@ jobs: FORMIO_2023_FRF_PATH: ${{ secrets.FORMIO_2023_FRF_PATH }} FORMIO_2023_PRF_PATH: ${{ secrets.FORMIO_2023_PRF_PATH }} FORMIO_2023_CRF_PATH: ${{ secrets.FORMIO_2023_CRF_PATH }} + FORMIO_2023_CHANGE_PATH: ${{ secrets.FORMIO_2023_CHANGE_PATH }} FORMIO_BASE_URL: ${{ secrets.FORMIO_BASE_URL }} FORMIO_PROJECT_NAME: ${{ secrets.FORMIO_PROJECT_NAME }} FORMIO_API_KEY: ${{ secrets.FORMIO_API_KEY }} @@ -142,6 +143,7 @@ jobs: cf set-env $APP_NAME "FORMIO_2023_FRF_PATH" "$FORMIO_2023_FRF_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_2023_PRF_PATH" "$FORMIO_2023_PRF_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_2023_CRF_PATH" "$FORMIO_2023_CRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_CHANGE_PATH" "$FORMIO_2023_CHANGE_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_BASE_URL" "$FORMIO_BASE_URL" > /dev/null cf set-env $APP_NAME "FORMIO_PROJECT_NAME" "$FORMIO_PROJECT_NAME" > /dev/null cf set-env $APP_NAME "FORMIO_API_KEY" "$FORMIO_API_KEY" > /dev/null diff --git a/app/client/src/components/app.tsx b/app/client/src/components/app.tsx index 43639c79..59fd422a 100644 --- a/app/client/src/components/app.tsx +++ b/app/client/src/components/app.tsx @@ -42,6 +42,7 @@ import { CRF2022 } from "@/routes/crf2022"; import { FRF2023 } from "@/routes/frf2023"; import { PRF2023 } from "@/routes/prf2023"; // import { CRF2023 } from "@/routes/crf2023"; +import { Change2023 } from "@/routes/change2023"; import { useDialogState, useDialogActions } from "@/contexts/dialog"; /** Custom hook to display a site-wide alert banner */ @@ -254,6 +255,8 @@ export function App() { } /> {/* } /> */} + } /> + } /> , diff --git a/app/client/src/components/change2023New.tsx b/app/client/src/components/change2023New.tsx new file mode 100644 index 00000000..538b2fe9 --- /dev/null +++ b/app/client/src/components/change2023New.tsx @@ -0,0 +1,319 @@ +import { Fragment, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Dialog, Transition } from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { Form } from "@formio/react"; +import clsx from "clsx"; +import icons from "uswds/img/sprite.svg"; +// --- +import { serverUrl, messages } from "@/config"; +import { + type FormType, + type FormioChange2023Submission, + getData, + postData, + useContentData, + useChangeRequestsQuery, +} from "@/utilities"; +import { Loading } from "@/components/loading"; +import { Message } from "@/components/message"; +import { MarkdownContent } from "@/components/markdownContent"; +import { useNotificationsActions } from "@/contexts/notifications"; + +type ChangeRequestData = { + formType: FormType; + comboKey: string; + mongoId: string; + rebateId: string | null; + email: string; + title: string; + name: string; +}; + +type ServerResponse = { url: string; json: object }; + +/** Custom hook to fetch Formio schema */ +function useFormioSchemaQuery() { + const url = `${serverUrl}/api/formio/2023/change`; + + const query = useQuery({ + queryKey: ["formio/2023/change"], + queryFn: () => getData(url), + refetchOnWindowFocus: false, + }); + + return { query }; +} + +export function ChangeRequest2023Button(props: { + disabled: boolean; + data: ChangeRequestData; +}) { + const { disabled, data } = props; + + const [dialogShown, setDialogShown] = useState(false); + + function closeDialog() { + setDialogShown(false); + } + + return ( + <> + + + + + ); +} + +function ChangeRequest2023Dialog(props: { + dialogShown: boolean; + closeDialog: () => void; + data: ChangeRequestData; +}) { + const { dialogShown, closeDialog, data } = props; + + /* + * NOTE: For some reason select inputs from the Formio form won't receive + * click events if the Dialog.Panel component is used (strangely, they still + * receive keyboard events), so a div is used instead. The downside is we lose + * the triggering of the Dialog component's `onClose` event when a user clicks + * outside the panel. + */ + + return ( + + closeDialog()} + > + +
+ + +
+
+ + {/* +
+
+ +
+
+ +
+ +
+
+ {/* */} + +
+
+
+
+ ); +} + +function ChangeRequest2023Form(props: { + data: ChangeRequestData; + closeDialog: () => void; +}) { + const { data, closeDialog } = props; + const { formType, comboKey, rebateId, mongoId, email, title, name } = data; + + const content = useContentData(); + const { + displaySuccessNotification, + displayErrorNotification, + dismissNotification, + } = useNotificationsActions(); + + const changeRequestsQuery = useChangeRequestsQuery("2023"); + + const { query } = useFormioSchemaQuery(); + const formSchema = query.data; + + /** + * Stores when the form is being submitted, so it can be referenced in the + * Form component's `onSubmit` event prop to prevent double submits. + */ + const formIsBeingSubmitted = useRef(false); + + if (query.isInitialLoading) { + return ; + } + + if (query.isError || !formSchema) { + return ; + } + + return ( + <> + {content && } + +
+
{ + // account for when form is being submitted to prevent double submits + if (formIsBeingSubmitted.current) return; + formIsBeingSubmitted.current = true; + + dismissNotification({ id: 0 }); + + postData( + `${serverUrl}/api/formio/2023/change/`, + onSubmitSubmission, + ) + .then((res) => { + displaySuccessNotification({ + id: Date.now(), + body: ( +

+ Change Request {res._id} submitted successfully. +

+ ), + }); + + closeDialog(); + changeRequestsQuery.refetch(); + }) + .catch((_err) => { + displayErrorNotification({ + id: Date.now(), + body: ( + <> +

+ Error creating Change Request for{" "} + + {formType.toUpperCase()} {rebateId} + + . +

+

+ Please try again. +

+ + ), + }); + }) + .finally(() => { + formIsBeingSubmitted.current = false; + }); + }} + /> +
+ + ); +} diff --git a/app/client/src/components/loading.tsx b/app/client/src/components/loading.tsx index 952f9691..fa55f31e 100644 --- a/app/client/src/components/loading.tsx +++ b/app/client/src/components/loading.tsx @@ -1,3 +1,5 @@ +import clsx from "clsx"; +// --- // NOTE: React JSX doesn't support namespaces, so `uswds/img/loader.svg` copied // into app's `images/loader.svg` with namespace tags removed import loader from "@/images/loader.svg"; @@ -12,12 +14,16 @@ export function Loading() { ); } -export function LoadingButtonIcon() { +export function LoadingButtonIcon(props: { position: "start" | "end" }) { + const { position } = props; return ( Loading... ); } diff --git a/app/client/src/components/userDashboard.tsx b/app/client/src/components/userDashboard.tsx index ea6fc4aa..4cc6cafe 100644 --- a/app/client/src/components/userDashboard.tsx +++ b/app/client/src/components/userDashboard.tsx @@ -71,7 +71,8 @@ export function UserDashboard(props: { email: string }) { const onFormPage = pathname.startsWith("/frf") || pathname.startsWith("/prf") || - pathname.startsWith("/crf"); + pathname.startsWith("/crf") || + pathname.startsWith("/change"); const btnClassNames = "usa-button margin-0 padding-x-2 padding-y-1 width-full font-sans-2xs"; diff --git a/app/client/src/config.tsx b/app/client/src/config.tsx index 3437fa1f..eb040af3 100644 --- a/app/client/src/config.tsx +++ b/app/client/src/config.tsx @@ -45,6 +45,7 @@ export const messages = { formSubmissionError: "The requested submission does not exist, or you do not have access. Please contact support if you believe this is a mistake.", formSubmissionsError: "Error loading form submissions.", + formSchemaError: "Error loading form schema.", newApplication: "Please select the “New Application” button above to create your first rebate application.", helpdeskSubmissionSearchError: diff --git a/app/client/src/routes/change2023.tsx b/app/client/src/routes/change2023.tsx new file mode 100644 index 00000000..ec9eac6b --- /dev/null +++ b/app/client/src/routes/change2023.tsx @@ -0,0 +1,112 @@ +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { Form } from "@formio/react"; +import icons from "uswds/img/sprite.svg"; +// --- +import { serverUrl, messages } from "@/config"; +import { + type FormioChange2023Submission, + getData, + useContentData, +} from "@/utilities"; +import { Loading } from "@/components/loading"; +import { Message } from "@/components/message"; +import { MarkdownContent } from "@/components/markdownContent"; + +type ServerResponse = + | { + userAccess: false; + formSchema: null; + submission: null; + } + | { + userAccess: true; + formSchema: { url: string; json: object }; + submission: FormioChange2023Submission; + }; + +/** Custom hook to fetch Formio submission data */ +function useFormioSubmissionQuery(mongoId: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.resetQueries({ queryKey: ["formio/2023/change"] }); + }, [queryClient]); + + const url = `${serverUrl}/api/formio/2023/change/${mongoId}`; + + const query = useQuery({ + queryKey: ["formio/2023/change", { id: mongoId }], + queryFn: () => getData(url), + refetchOnWindowFocus: false, + }); + + return { query }; +} + +export function Change2023() { + const { id: mongoId } = useParams<"id">(); // MongoDB ObjectId string + + const content = useContentData(); + + const { query } = useFormioSubmissionQuery(mongoId); + const { userAccess, formSchema, submission } = query.data ?? {}; + + if (query.isInitialLoading) { + return ; + } + + if (query.isError || !userAccess || !formSchema || !submission) { + return ; + } + + return ( +
+ {content && ( + + )} + +
    +
  • +
    + +
    +
    + Change Request ID: {submission._id} +
    +
  • + +
  • +
    + +
    +
    + Rebate ID: {submission.data._bap_rebate_id} +
    +
  • +
+ +
+ +
+
+ ); +} diff --git a/app/client/src/routes/frfNew.tsx b/app/client/src/routes/frfNew.tsx index a76e1e3e..e918e843 100644 --- a/app/client/src/routes/frfNew.tsx +++ b/app/client/src/routes/frfNew.tsx @@ -347,7 +347,7 @@ export function FRFNew() { New Application {postingDataId === comboKey && ( - + )} diff --git a/app/client/src/routes/helpdesk.tsx b/app/client/src/routes/helpdesk.tsx index 42bee296..2ce12296 100644 --- a/app/client/src/routes/helpdesk.tsx +++ b/app/client/src/routes/helpdesk.tsx @@ -15,6 +15,7 @@ import { bapCRFStatusMap, } from "@/config"; import { + type FormType, type FormioFRF2022Submission, type FormioPRF2022Submission, type FormioCRF2022Submission, @@ -36,8 +37,6 @@ import { useRebateYearActions, } from "@/contexts/rebateYear"; -type FormType = "frf" | "prf" | "crf"; - type ServerResponse = | { formSchema: null; diff --git a/app/client/src/routes/submissions.tsx b/app/client/src/routes/submissions.tsx index 5be128f2..8625b2eb 100644 --- a/app/client/src/routes/submissions.tsx +++ b/app/client/src/routes/submissions.tsx @@ -5,6 +5,7 @@ import { useNavigate, useOutletContext, } from "react-router-dom"; +import { ChevronUpIcon } from "@heroicons/react/20/solid"; import clsx from "clsx"; import icons from "uswds/img/sprite.svg"; // --- @@ -21,6 +22,8 @@ import { useContentData, useConfigData, useBapSamData, + useChangeRequestsQuery, + useChangeRequestsData, useSubmissionsQueries, useSubmissions, submissionNeedsEdits, @@ -30,6 +33,7 @@ import { Loading, LoadingButtonIcon } from "@/components/loading"; import { Message } from "@/components/message"; import { MarkdownContent } from "@/components/markdownContent"; import { TextWithTooltip } from "@/components/tooltip"; +import { ChangeRequest2023Button } from "@/components/change2023New"; import { useNotificationsActions } from "@/contexts/notifications"; import { type RebateYear, @@ -40,16 +44,17 @@ import { const defaultTableRowClassNames = "bg-gray-5"; const highlightedTableRowClassNames = "bg-primary-lighter"; -function FormButtonLink(props: { type: "edit" | "view"; to: LinkProps["to"] }) { - const icon = props.type === "edit" ? "edit" : "visibility"; - const text = props.type === "edit" ? "Edit" : "View"; +function FormLink(props: { type: "edit" | "view"; to: LinkProps["to"] }) { + const { type, to } = props; + const icon = type === "edit" ? "edit" : "visibility"; + const text = type === "edit" ? "Edit" : "View"; return ( @@ -79,7 +84,9 @@ function NewApplicationIconText() { ); } -function SubmissionsTableHeader() { +function SubmissionsTableHeader(props: { rebateYear: RebateYear }) { + const { rebateYear } = props; + return ( @@ -138,6 +145,15 @@ function SubmissionsTableHeader() { tooltip="Last date this form was updated" /> + + {rebateYear === "2023" && ( + + + + )} ); @@ -245,11 +261,11 @@ function FRF2022Submission(props: { rebate: Rebate }) { > {frfNeedsEdits ? ( - + ) : frf.formio.state === "submitted" || !frfSubmissionPeriodOpen ? ( - + ) : frf.formio.state === "draft" ? ( - + ) : null} @@ -490,7 +506,7 @@ function PRF2022Submission(props: { rebate: Rebate }) { New Payment Request - {dataIsPosting && } + {dataIsPosting && } @@ -579,13 +595,13 @@ function PRF2022Submission(props: { rebate: Rebate }) { > {frfNeedsEdits ? ( - + ) : prfNeedsEdits ? ( - + ) : prf.formio.state === "submitted" || !prfSubmissionPeriodOpen ? ( - + ) : prf.formio.state === "draft" ? ( - + ) : null} @@ -734,7 +750,7 @@ function CRF2022Submission(props: { rebate: Rebate }) { New Close Out - {dataIsPosting && } + {dataIsPosting && } @@ -809,11 +825,11 @@ function CRF2022Submission(props: { rebate: Rebate }) { > {crfNeedsEdits ? ( - + ) : crf.formio.state === "submitted" || !crfSubmissionPeriodOpen ? ( - + ) : crf.formio.state === "draft" ? ( - + ) : null} @@ -868,18 +884,34 @@ function FRF2023Submission(props: { rebate: Rebate }) { const { rebate } = props; const { frf, prf, crf } = rebate; + const { email } = useOutletContext<{ email: string }>(); + const configData = useConfigData(); + const bapSamData = useBapSamData(); - if (!configData) return null; + if (!configData || !bapSamData) return null; + + /** matched SAM.gov entity for the FRF submission */ + const entity = bapSamData.entities.find((entity) => { + const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; + const comboKey = (frf.formio as FormioFRF2023Submission).data + ._bap_entity_combo_key; + return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; + }); + + if (!entity) return null; + + const { title, name } = getUserInfo(email, entity); const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2023"].frf; const { + _user_email, + _bap_entity_combo_key, appInfo_uei, appInfo_efti, appInfo_orgName, _formio_schoolDistrictName, - _user_email, } = (frf.formio as FormioFRF2023Submission).data; const date = new Date(frf.formio.modified).toLocaleDateString(); @@ -965,11 +997,11 @@ function FRF2023Submission(props: { rebate: Rebate }) { > {frfNeedsEdits ? ( - + ) : frf.formio.state === "submitted" || !frfSubmissionPeriodOpen ? ( - + ) : frf.formio.state === "draft" ? ( - + ) : null} @@ -1061,6 +1093,21 @@ function FRF2023Submission(props: { rebate: Rebate }) {
{date} + + + + ); } @@ -1085,12 +1132,6 @@ function PRF2023Submission(props: { rebate: Rebate }) { if (!configData || !bapSamData) return null; - const prfSubmissionPeriodOpen = configData.submissionPeriodOpen["2023"].prf; - - const frfSelected = frf.bap?.status === "Accepted"; - - const frfSelectedButNoPRF = frfSelected && !Boolean(prf.formio); - /** matched SAM.gov entity for the FRF submission */ const entity = bapSamData.entities.find((entity) => { const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; @@ -1099,6 +1140,16 @@ function PRF2023Submission(props: { rebate: Rebate }) { return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; }); + if (!entity) return null; + + const { title, name } = getUserInfo(email, entity); + + const prfSubmissionPeriodOpen = configData.submissionPeriodOpen["2023"].prf; + + const frfSelected = frf.bap?.status === "Accepted"; + + const frfSelectedButNoPRF = frfSelected && !Boolean(prf.formio); + if (frfSelectedButNoPRF) { return ( @@ -1108,14 +1159,12 @@ function PRF2023Submission(props: { rebate: Rebate }) { disabled={!prfSubmissionPeriodOpen} onClick={(_ev) => { if (!prfSubmissionPeriodOpen) return; - if (!frf.bap || !entity) return; + if (!frf.bap) return; // account for when data is posting to prevent double submits if (dataIsPosting) return; setDataIsPosting(true); - const { title, name } = getUserInfo(email, entity); - // create a new draft PRF submission postData(`${serverUrl}/api/formio/2023/prf-submission/`, { email, @@ -1169,7 +1218,7 @@ function PRF2023Submission(props: { rebate: Rebate }) { New Payment Request - {dataIsPosting && } + {dataIsPosting && } @@ -1182,6 +1231,7 @@ function PRF2023Submission(props: { rebate: Rebate }) { const { _user_email, + _bap_entity_combo_key, _bap_rebate_id, // } = (prf.formio as FormioPRF2023Submission).data; @@ -1258,13 +1308,13 @@ function PRF2023Submission(props: { rebate: Rebate }) { > {frfNeedsEdits ? ( - + ) : prfNeedsEdits ? ( - + ) : prf.formio.state === "submitted" || !prfSubmissionPeriodOpen ? ( - + ) : prf.formio.state === "draft" ? ( - + ) : null} @@ -1305,6 +1355,21 @@ function PRF2023Submission(props: { rebate: Rebate }) {
{date} + + + + ); } @@ -1348,8 +1413,8 @@ function Submissions2022() { aria-label="Your 2022 Rebate Forms" className="usa-table usa-table--stacked usa-table--borderless width-full" > - - + + {submissions.map((rebate, index) => { return rebate.rebateYear === "2022" ? ( @@ -1358,8 +1423,12 @@ function Submissions2022() { {/* blank row after all submissions but the last one */} {index !== submissions.length - 1 && ( - - + +   @@ -1376,14 +1445,21 @@ function Submissions2022() { function Submissions2023() { const content = useContentData(); + const changeRequestsQuery = useChangeRequestsQuery("2023"); const submissionsQueries = useSubmissionsQueries("2023"); const submissions = useSubmissions("2023"); - if (submissionsQueries.some((query) => query.isFetching)) { + if ( + changeRequestsQuery.isInitialLoading || + submissionsQueries.some((query) => query.isFetching) + ) { return ; } - if (submissionsQueries.some((query) => query.isError)) { + if ( + changeRequestsQuery.isError || + submissionsQueries.some((query) => query.isError) + ) { return ; } @@ -1397,6 +1473,8 @@ function Submissions2023() { return ( <> + + {content && ( - - + + {submissions.map((rebate, index) => { return rebate.rebateYear === "2023" ? ( @@ -1419,8 +1497,12 @@ function Submissions2023() { {/* */} {/* blank row after all submissions but the last one */} {index !== submissions.length - 1 && ( - - + +   @@ -1435,6 +1517,149 @@ function Submissions2023() { ); } +function ChangeRequests2023() { + const changeRequestsQuery = useChangeRequestsQuery("2023"); + const changeRequests = useChangeRequestsData("2023"); + + if (changeRequestsQuery.isFetching) { + return ; + } + + if (!changeRequests || changeRequests.length === 0) return null; + + return ( +
+ + + Your Change Requests + + + +
+ + + + + + + + + + + + + + + + {changeRequests.map((request, index) => { + const { _id, modified, data } = request; + const { + _request_form, + _bap_rebate_id, + _user_email, + request_type, + } = data; + + const date = new Date(modified).toLocaleDateString(); + const time = new Date(modified).toLocaleTimeString(); + + const formType = + _request_form === "frf" + ? "Application" + : _request_form === "prf" + ? "Payment Request" + : _request_form === "crf" + ? "Close Out" + : ""; + + return ( + + + + + + + + + + + + + + ); + })} + +
+ + + + + + + + + +
+ {_bap_rebate_id} + + {formType} + + {request_type?.label} + {_user_email} + {date} +
+
+
+ ); +} + export function Submissions() { const content = useContentData(); const configData = useConfigData(); diff --git a/app/client/src/utilities.ts b/app/client/src/utilities.ts index 4dd16f0f..b6e5f536 100644 --- a/app/client/src/utilities.ts +++ b/app/client/src/utilities.ts @@ -18,6 +18,8 @@ type Content = { submittedPRFIntro: string; draftCRFIntro: string; submittedCRFIntro: string; + newChangeIntro: string; + submittedChangeIntro: string; }; type UserData = { @@ -111,6 +113,8 @@ type BapFormSubmissions = { }; }; +export type FormType = "frf" | "prf" | "crf"; + type FormioSubmission = { [field: string]: unknown; _id: string; // MongoDB ObjectId string @@ -124,6 +128,20 @@ type FormioSubmission = { }; }; +type FormioChange2023Data = { + [field: string]: unknown; + // fields injected upon a new draft Change Request form submission creation: + _request_form: FormType; + _bap_entity_combo_key: string; + _bap_rebate_id: string; + _mongo_id: string; + _user_email: string; + _user_title: string; + _user_name: string; + // fields set by the form definition (among others): + request_type: { label: string; value: string }; +}; + type FormioFRF2022Data = { [field: string]: unknown; // fields injected upon a new draft FRF submission creation: @@ -210,6 +228,7 @@ type FormioPRF2023Data = { _user_name: string; _bap_entity_combo_key: string; _bap_rebate_id: string; + // TODO: add more here if helpful }; type FormioCRF2023Data = { @@ -222,6 +241,10 @@ type FormioCRF2023Data = { _bap_rebate_id: string; }; +export type FormioChange2023Submission = FormioSubmission & { + data: FormioChange2023Data; +}; + export type FormioFRF2022Submission = FormioSubmission & { data: FormioFRF2022Data; }; @@ -286,7 +309,7 @@ async function fetchData(url: string, options: RequestInit) { /** * Fetches data and returns a promise containing JSON fetched from a provided - * web service URL or handles any other OK response returned from the server + * web service URL or handles any other OK response returned from the server. */ export function getData(url: string) { return fetchData(url, { @@ -297,7 +320,7 @@ export function getData(url: string) { /** * Posts JSON data and returns a promise containing JSON fetched from a provided - * web service URL or handles any other OK response returned from the server + * web service URL or handles any other OK response returned from the server. */ export function postData(url: string, data: object) { return fetchData(url, { @@ -308,7 +331,7 @@ export function postData(url: string, data: object) { }); } -/** Custom hook to fetch content data */ +/** Custom hook to fetch content data. */ export function useContentQuery() { return useQuery({ queryKey: ["content"], @@ -317,13 +340,13 @@ export function useContentQuery() { }); } -/** Custom hook that returns cached fetched content data */ +/** Custom hook that returns cached fetched content data. */ export function useContentData() { const queryClient = useQueryClient(); return queryClient.getQueryData(["content"]); } -/** Custom hook to fetch user data */ +/** Custom hook to fetch user data. */ export function useUserQuery() { return useQuery({ queryKey: ["user"], @@ -333,13 +356,13 @@ export function useUserQuery() { }); } -/** Custom hook that returns cached fetched user data */ +/** Custom hook that returns cached fetched user data. */ export function useUserData() { const queryClient = useQueryClient(); return queryClient.getQueryData(["user"]); } -/** Custom hook to check if user should have access to the helpdesk page */ +/** Custom hook to check if user should have access to the helpdesk page. */ export function useHelpdeskAccess() { const user = useUserData(); const userRoles = user?.memberof.split(",") || []; @@ -351,7 +374,7 @@ export function useHelpdeskAccess() { : "failure"; } -/** Custom hook to fetch CSB config */ +/** Custom hook to fetch CSB config. */ export function useConfigQuery() { return useQuery({ queryKey: ["config"], @@ -360,13 +383,13 @@ export function useConfigQuery() { }); } -/** Custom hook that returns cached fetched CSB config */ +/** Custom hook that returns cached fetched CSB config. */ export function useConfigData() { const queryClient = useQueryClient(); return queryClient.getQueryData(["config"]); } -/** Custom hook to fetch BAP SAM.gov data */ +/** Custom hook to fetch BAP SAM.gov data. */ export function useBapSamQuery() { return useQuery({ queryKey: ["bap/sam"], @@ -383,13 +406,64 @@ export function useBapSamQuery() { }); } -/** Custom hook that returns cached fetched BAP SAM.gov data */ +/** Custom hook that returns cached fetched BAP SAM.gov data. */ export function useBapSamData() { const queryClient = useQueryClient(); return queryClient.getQueryData(["bap/sam"]); } -/** Custom hook to fetch submissions from the BAP and Formio */ +/** Custom hook to fetch Change Request form submissions from Formio. */ +export function useChangeRequestsQuery(rebateYear: RebateYear) { + /* + * NOTE: Change Request form was added in the 2023 rebate year, so there's no + * change request data to fetch for 2022. + */ + const changeRequest2022Query = { + queryKey: ["formio/2022/changes"], + queryFn: () => Promise.resolve([]), + refetchOnWindowFocus: false, + }; + + const changeRequest2023Query = { + queryKey: ["formio/2023/changes"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2023/changes`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + + /* NOTE: Fallback (not used, as rebate year will match a query above) */ + const changeRequestQuery = { + queryKey: ["formio/changes"], + queryFn: () => Promise.resolve([]), + refetchOnWindowFocus: false, + }; + + const query = + rebateYear === "2022" + ? changeRequest2022Query + : rebateYear === "2023" + ? changeRequest2023Query + : changeRequestQuery; + + return useQuery(query); +} + +/** + * Custom hook that returns cached fetched Change Request form submissions from + * Formio. + */ +export function useChangeRequestsData(rebateYear: RebateYear) { + const queryClient = useQueryClient(); + return rebateYear === "2022" + ? queryClient.getQueryData<[]>(["formio/2022/changes"]) + : rebateYear === "2023" + ? queryClient.getQueryData(["formio/2023/changes"]) // prettier-ignore + : undefined; +} + +/** Custom hook to fetch submissions from the BAP and Formio. */ export function useSubmissionsQueries(rebateYear: RebateYear) { const bapQuery = { queryKey: ["bap/submissions"], diff --git a/app/server/.env.example b/app/server/.env.example index 334f7a6d..ead1c86e 100644 --- a/app/server/.env.example +++ b/app/server/.env.example @@ -22,6 +22,7 @@ FORMIO_2022_CRF_PATH= FORMIO_2023_FRF_PATH= FORMIO_2023_PRF_PATH= FORMIO_2023_CRF_PATH= +FORMIO_2023_CHANGE_PATH= FORMIO_BASE_URL= FORMIO_PROJECT_NAME= FORMIO_API_KEY= diff --git a/app/server/app/config/formio.js b/app/server/app/config/formio.js index 68f90b47..2d89b5e8 100644 --- a/app/server/app/config/formio.js +++ b/app/server/app/config/formio.js @@ -21,6 +21,7 @@ const { FORMIO_2023_FRF_PATH, FORMIO_2023_PRF_PATH, FORMIO_2023_CRF_PATH, + FORMIO_2023_CHANGE_PATH, } = process.env; const formioProjectUrl = `${FORMIO_BASE_URL}/${FORMIO_PROJECT_NAME}`; @@ -33,11 +34,13 @@ const formUrl = { frf: `${formioProjectUrl}/${FORMIO_2022_FRF_PATH}`, prf: `${formioProjectUrl}/${FORMIO_2022_PRF_PATH}`, crf: `${formioProjectUrl}/${FORMIO_2022_CRF_PATH}`, + change: "", // NOTE: Change Request form was added in the 2023 rebate year }, 2023: { frf: `${formioProjectUrl}/${FORMIO_2023_FRF_PATH}`, prf: `${formioProjectUrl}/${FORMIO_2023_PRF_PATH}`, crf: `${formioProjectUrl}/${FORMIO_2023_CRF_PATH}`, + change: `${formioProjectUrl}/${FORMIO_2023_CHANGE_PATH}`, }, }; @@ -97,7 +100,7 @@ function axiosFormio(req) { log({ level: "warn", message: logMessage, req: config }); return new Promise((resolve) => - setTimeout(() => resolve(instance.request(config)), 1000) + setTimeout(() => resolve(instance.request(config)), 1000), ); } @@ -107,7 +110,7 @@ function axiosFormio(req) { log({ level: "error", message: logMessage, req: config }); return Promise.reject(error); - } + }, ); return instance; diff --git a/app/server/app/content/new-change-intro.md b/app/server/app/content/new-change-intro.md new file mode 100644 index 00000000..44188e6b --- /dev/null +++ b/app/server/app/content/new-change-intro.md @@ -0,0 +1 @@ +## Submit Your Change Request diff --git a/app/server/app/content/submitted-change-intro.md b/app/server/app/content/submitted-change-intro.md new file mode 100644 index 00000000..d1a39d0e --- /dev/null +++ b/app/server/app/content/submitted-change-intro.md @@ -0,0 +1 @@ +## View Your Submitted Change Request diff --git a/app/server/app/index.js b/app/server/app/index.js index 8e0176b5..971388d5 100644 --- a/app/server/app/index.js +++ b/app/server/app/index.js @@ -44,6 +44,7 @@ const requiredEnvironmentVariables = [ "FORMIO_2023_FRF_PATH", "FORMIO_2023_PRF_PATH", "FORMIO_2023_CRF_PATH", + "FORMIO_2023_CHANGE_PATH", "FORMIO_BASE_URL", "FORMIO_PROJECT_NAME", "FORMIO_API_KEY", diff --git a/app/server/app/middleware.js b/app/server/app/middleware.js index 6d3773b1..7be03972 100644 --- a/app/server/app/middleware.js +++ b/app/server/app/middleware.js @@ -171,6 +171,7 @@ function checkClientRouteExists(req, res, next) { if ( !clientRoutes.includes(req.path) && + !req.path.includes("/change/") && !req.path.includes("/frf/") && !req.path.includes("/prf/") && !req.path.includes("/crf/") diff --git a/app/server/app/routes/content.js b/app/server/app/routes/content.js index 162492f5..1e03d1eb 100644 --- a/app/server/app/routes/content.js +++ b/app/server/app/routes/content.js @@ -24,6 +24,8 @@ router.get("/", (req, res) => { "submitted-prf-intro.md", "draft-crf-intro.md", "submitted-crf-intro.md", + "new-change-intro.md", + "submitted-change-intro.md", ]; const s3BucketUrl = `https://${S3_PUBLIC_BUCKET}.s3-${S3_PUBLIC_REGION}.amazonaws.com`; @@ -37,7 +39,7 @@ router.get("/", (req, res) => { return NODE_ENV === "development" ? readFile(resolve(__dirname, "../content", filename), "utf8") : axios.get(`${s3BucketUrl}/content/${filename}`); - }) + }), ) .then((stringsOrResponses) => { /** @@ -61,6 +63,8 @@ router.get("/", (req, res) => { submittedPRFIntro: data[8], draftCRFIntro: data[9], submittedCRFIntro: data[10], + newChangeIntro: data[11], + submittedChangeIntro: data[12], }); }) .catch((error) => { diff --git a/app/server/app/routes/formio2023.js b/app/server/app/routes/formio2023.js index a8e46dca..2d84ae31 100644 --- a/app/server/app/routes/formio2023.js +++ b/app/server/app/routes/formio2023.js @@ -19,6 +19,11 @@ const { fetchPRFSubmission, updatePRFSubmission, deletePRFSubmission, + // + fetchChangeRequests, + fetchChangeRequestSchema, + createChangeRequest, + fetchChangeRequest, } = require("../utilities/formio"); const rebateYear = "2023"; @@ -99,16 +104,36 @@ router.post("/delete-prf-submission", storeBapComboKeys, (req, res) => { deletePRFSubmission({ rebateYear, req, res }); }); -// --- get user's 2022 CRF submissions from Formio +// --- get user's 2023 CRF submissions from Formio router.get("/crf-submissions", storeBapComboKeys, (req, res) => { // TODO res.json([]); }); -// --- post a new 2022 CRF submission to Formio +// --- post a new 2023 CRF submission to Formio + +// --- get an existing 2023 CRF's schema and submission data from Formio -// --- get an existing 2022 CRF's schema and submission data from Formio +// --- post an update to an existing draft 2023 CRF submission to Formio -// --- post an update to an existing draft 2022 CRF submission to Formio +// --- get user's 2023 Change Request form submissions from Formio +router.get("/changes", storeBapComboKeys, (req, res) => { + fetchChangeRequests({ rebateYear, req, res }); +}); + +// --- get the 2023 Change Request form's schema from Formio +router.get("/change", storeBapComboKeys, (req, res) => { + fetchChangeRequestSchema({ rebateYear, req, res }); +}); + +// --- post a new 2023 Change Request form submission to Formio +router.post("/change", storeBapComboKeys, (req, res) => { + createChangeRequest({ rebateYear, req, res }); +}); + +// --- get an existing 2023 Change Request form's schema and submission data from Formio +router.get("/change/:mongoId", storeBapComboKeys, async (req, res) => { + fetchChangeRequest({ rebateYear, req, res }); +}); module.exports = router; diff --git a/app/server/app/utilities/formio.js b/app/server/app/utilities/formio.js index fe589d6e..19720437 100644 --- a/app/server/app/utilities/formio.js +++ b/app/server/app/utilities/formio.js @@ -1063,6 +1063,175 @@ function fetchCRFSubmissions({ rebateYear, req, res }) { }); } +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchChangeRequests({ rebateYear, req, res }) { + const { bapComboKeys } = req; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const comboKeySearchParam = `&data.${comboKeyFieldName}=`; + + const formioFormUrl = formUrl[rebateYear].change; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} Change Request form.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const submissionsUrl = + `${formioFormUrl}/submission` + + `?sort=-modified` + + `&limit=1000000` + + comboKeySearchParam + + `${bapComboKeys.join(comboKeySearchParam)}`; + + axiosFormio(req) + .get(submissionsUrl) + .then((axiosRes) => axiosRes.data) + .then((submissions) => res.json(submissions)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio ${rebateYear} Change Request form submissions.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchChangeRequestSchema({ rebateYear, req, res }) { + const formioFormUrl = formUrl[rebateYear].change; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} Change Request form.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + axiosFormio(req) + .get(formioFormUrl) + .then((axiosRes) => axiosRes.data) + .then((schema) => res.json({ url: formioFormUrl, json: schema })) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio ${rebateYear} Change Request form schema.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function createChangeRequest({ rebateYear, req, res }) { + const { bapComboKeys, body } = req; + const { mail } = req.user; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const comboKey = body.data?.[comboKeyFieldName]; + + const formioFormUrl = formUrl[rebateYear].change; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} Change Request form.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to post a new ${rebateYear} ` + + `Change Request form submission without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** Add custom metadata to track formio submissions from wrapper. */ + body.metadata = { ...formioCSBMetadata }; + + axiosFormio(req) + .post(`${formioFormUrl}/submission`, body) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error posting Formio ${rebateYear} Change Request form submission.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchChangeRequest({ rebateYear, req, res }) { + const { bapComboKeys } = req; + const { mail } = req.user; + const { mongoId } = req.params; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + + const formioFormUrl = formUrl[rebateYear].change; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} Change Request form.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + Promise.all([ + axiosFormio(req).get(`${formioFormUrl}/submission/${mongoId}`), + axiosFormio(req).get(formioFormUrl), + ]) + .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) + .then(([submission, schema]) => { + const comboKey = submission.data?.[comboKeyFieldName]; + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to access ${rebateYear} ` + + `Change Request form submission '${mongoId}' that they do not have access to.`; + log({ level: "warn", message: logMessage, req }); + + return res.json({ + userAccess: false, + formSchema: null, + submission: null, + }); + } + + return res.json({ + userAccess: true, + formSchema: { url: formioFormUrl, json: schema }, + submission, + }); + }) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio ${rebateYear} Change Request form submission '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + module.exports = { uploadS3FileMetadata, downloadS3FileMetadata, @@ -1079,4 +1248,9 @@ module.exports = { deletePRFSubmission, // fetchCRFSubmissions, + // + fetchChangeRequests, + fetchChangeRequestSchema, + createChangeRequest, + fetchChangeRequest, };