From c7bb3ca7b3b4a0d5802129a564f9291120f8a5b1 Mon Sep 17 00:00:00 2001 From: James Dinh Date: Fri, 20 Sep 2024 08:05:03 -0700 Subject: [PATCH 1/9] feat(Cs8): Add cs8 to our webforms list (#776) * working through logic * finish layout * Update v202401.ts * Update v202401.ts * Update v202401.ts * Update v202401.ts * add feedback additional rule --- lib/libs/webforms/CS8/index.ts | 1 + lib/libs/webforms/CS8/v202401.ts | 699 ++++++++++++++++++++++++++++++ lib/libs/webforms/index.ts | 4 + test/e2e/tests/a11y/index.spec.ts | 1 + 4 files changed, 705 insertions(+) create mode 100644 lib/libs/webforms/CS8/index.ts create mode 100644 lib/libs/webforms/CS8/v202401.ts diff --git a/lib/libs/webforms/CS8/index.ts b/lib/libs/webforms/CS8/index.ts new file mode 100644 index 000000000..e5cf77f4f --- /dev/null +++ b/lib/libs/webforms/CS8/index.ts @@ -0,0 +1 @@ +export * from "./v202401"; diff --git a/lib/libs/webforms/CS8/v202401.ts b/lib/libs/webforms/CS8/v202401.ts new file mode 100644 index 000000000..30f5e905e --- /dev/null +++ b/lib/libs/webforms/CS8/v202401.ts @@ -0,0 +1,699 @@ +import { FormSchema } from "shared-types"; +import { noLeadingTrailingWhitespace } from "shared-utils/regex"; + +export const v202401: FormSchema = { + header: "CS 8: Separate CHIP eligibility—Targeted low-income pregnant women", + subheader: "Section 2112 of the Social Security Act (SSA)", + formId: "cs8", + sections: [ + { + title: "Overview", + sectionId: "overview", + form: [ + { + slots: [ + { + rhf: "Checkbox", + name: "chip-cover-group-follow-provisions", + styledLabel: [ + { + text: "Targeted low-income pregnant women", + type: "bold", + }, + { + text: " are uninsured pregnant or postpartum women who do not have access to public employee coverage and whose household income is within standards established by the state.", + type: "default", + }, + ], + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: + "The CHIP agency operates this covered group in accordance with the following provisions.", + value: "true", + }, + ], + }, + }, + ], + }, + ], + }, + { + title: "Age", + sectionId: "age", + subsection: true, + form: [ + { + description: + "The state provides coverage to pregnant women in the following age ranges:", + descriptionClassName: "text-base", + slots: [ + { + rhf: "WrappedGroup", + name: "wrapped", + formItemClassName: "mt-0", + fields: [ + { + rhf: "Radio", + name: "age-range", + label: "Age range", + labelClassName: "font-bold", + descriptionAbove: true, + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "From age 19, up to specific age", + value: "from-age-19", + slots: [ + { + rhf: "Input", + name: "end-age-range", + rules: {}, + label: "End of age range", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + }, + }, + ], + }, + { + label: "No age restriction", + value: "no-age-restriction", + slots: [ + { + rhf: "Textarea", + name: "no-age-describe-applicant-child-preg-woman", + rules: { + required: "* Required", + pattern: { + value: noLeadingTrailingWhitespace, + message: + "Must not have leading or trailing whitespace.", + }, + }, + label: + "Describe how it’s determined whether the applicant will be provided coverage as a child or as a pregnant woman.", + labelClassName: "font-bold", + props: { + className: "w-[696px]", + }, + }, + ], + }, + { + label: "Another age range", + value: "another-age-range", + slots: [ + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: "flex-row flex gap-5", + }, + fields: [ + { + rhf: "Input", + name: "start-age-range", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Start of age range", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + }, + }, + { + rhf: "Input", + name: "end-age-range", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: "Must be a positive percentage", + }, + required: "* Required", + }, + addtnlRules: [ + { + type: "greaterThanField", + strictGreater: true, + fieldName: "cs8_age_start-age-range", + message: + "Must be greater than start of age range", + }, + ], + label: "End of age range", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + }, + }, + ], + }, + { + rhf: "Select", + name: "does-preg-woman-range-overlap-with-child", + rules: { + required: "* Required", + }, + label: + "Does the age range for targeted low-income pregnant women overlap with the age range for targeted low-income children?", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + options: [ + { label: "Yes", value: "yes" }, + { label: "No", value: "no" }, + ], + }, + }, + ], + }, + ], + }, + }, + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: + "ml-[0.6rem] px-4 border-l-4 border-l-primary mb-4", + }, + fields: [ + { + rhf: "Textarea", + name: "describe-applicant-child-preg-woman", + rules: { + required: "* Required", + pattern: { + value: noLeadingTrailingWhitespace, + message: + "Must not have leading or trailing whitespace.", + }, + }, + label: + "Describe how it’s determined whether the applicant will be provided coverage as a child or as a pregnant woman.", + labelClassName: "font-bold", + formItemClassName: + "ml-[0.6rem] px-4 border-l-4 border-l-primary my-4", + props: { + className: "w-[658px]", + }, + dependency: { + conditions: [ + { + type: "expectedValue", + name: "cs8_age_does-preg-woman-range-overlap-with-child", + expectedValue: "yes", + }, + ], + effect: { type: "show" }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + title: "Statewide income standards", + sectionId: "statewide-income-standards", + subsection: true, + form: [ + { + description: + "Coverage for pregnant women may only be provided if the children's qualifying income standard under the plan is at least 200% of the federal poverty level (FPL) for all age ranges.", + descriptionClassName: "text-base", + slots: [ + { + rhf: "Select", + name: "standards-applied-state", + label: "Are income standards applied statewide?", + labelClassName: "font-bold", + rules: { + required: "* Required", + }, + props: { + className: "w-[125px]", + options: [ + { label: "Yes", value: "yes" }, + { label: "No", value: "no" }, + ], + }, + }, + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: "flex-row flex gap-5", + }, + fields: [ + { + rhf: "Input", + name: "above", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Above", + labelClassName: "font-bold", + props: { + className: "w-[159px]", + icon: "% FPL", + iconRight: true, + }, + }, + { + rhf: "Input", + name: "up-to-and-including", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Up to and including", + labelClassName: "font-bold", + props: { + icon: "% FPL", + iconRight: true, + className: "w-[159px]", + }, + }, + ], + styledLabel: [ + { + text: "Statewide income standards", + type: "bold", + classname: "block pb-2", + }, + { + text: "CHIP coverage for pregnant women may only be provided if the qualifying income standard under Medicaid for pregnant women is at least 185% FPL.", + type: "default", + classname: "block pb-2", + }, + { + text: "The highest income level for pregnant women cannot be more than the highest income level for children.", + }, + ], + }, + ], + }, + ], + }, + { + title: "Income standard exceptions", + sectionId: "income-standard-exceptions", + subsection: true, + form: [ + { + slots: [ + { + rhf: "Select", + name: "any-except-such-as-pop", + rules: { + required: "* Required", + }, + label: + "Are there any exceptions, such as populations in a county that may qualify under either a statewide income standard or a county income standard?", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + options: [ + { label: "Yes", value: "yes" }, + { label: "No", value: "no" }, + ], + }, + }, + { + rhf: "Textarea", + name: "explain-overlap-diff-income-standards", + rules: { + required: "* Required", + pattern: { + value: noLeadingTrailingWhitespace, + message: "Must not have leading or trailing whitespace.", + }, + }, + label: + "Explain, including a description of the overlapping geographic area and the reason for having different income standards.", + labelClassName: "font-bold", + props: { + className: "w-[696px]", + }, + formItemClassName: + "ml-[0.6rem] px-4 border-l-4 border-l-primary mb-4", + dependency: { + conditions: [ + { + type: "expectedValue", + name: "cs8_income-standard-exceptions_any-except-such-as-pop", + expectedValue: "yes", + }, + ], + effect: { type: "show" }, + }, + }, + { + rhf: "Checkbox", + name: "geo-variation", + label: "Method of geographic variation", + labelClassName: "font-bold", + rules: { + required: "* Required", + }, + props: { + options: [ + { + label: "By county", + value: "county", + slots: [ + { + rhf: "WrappedGroup", + name: "wrapped", + styledLabel: [ + { + text: "Enter one county if the county has a unique income standard. If multiple counties share the same income standard, enter all the counties, then enter the income standard that applies to those counties.", + type: "default", + classname: "block pb-2", + }, + { + text: "CHIP coverage for pregnant women may only be provided if the qualifying income standard under Medicaid for pregnant women is at least 185% FPL.", + type: "default", + classname: "block pb-2", + }, + { + text: "The highest income level for pregnant women cannot be more than the highest income level for children.", + type: "default", + classname: "block pb-2", + }, + ], + fields: [ + { + rhf: "FieldArray", + name: "county-info", + props: { appendText: "Add County" }, + fields: [ + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: "flex-row flex gap-5", + }, + fields: [ + { + rhf: "Input", + name: "county-geo-var", + rules: { required: "* Required" }, + label: "County", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + }, + }, + { + rhf: "Input", + name: "above-county-geo-var", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: + "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Above", + labelClassName: "font-bold", + props: { + className: "w-[159px]", + icon: "% FPL", + iconRight: true, + }, + }, + { + rhf: "Input", + name: "up-to-and-including-county-geo-var", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: + "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Up to and including", + labelClassName: "font-bold", + props: { + icon: "% FPL", + iconRight: true, + className: "w-[159px]", + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + label: "By city", + value: "city", + slots: [ + { + rhf: "WrappedGroup", + name: "wrapped", + styledLabel: [ + { + text: "Enter one city if the city has a unique income standard. If multiple cities share the same income standard, enter all the cities, then enter the income standard that applies to those cities.", + type: "default", + classname: "block pb-2", + }, + { + text: "CHIP coverage for pregnant women may only be provided if the qualifying income standard under Medicaid for pregnant women is at least 185% FPL.", + type: "default", + classname: "block pb-2", + }, + { + text: "The highest income level for pregnant women cannot be more than the highest income level for children.", + type: "default", + classname: "block pb-2", + }, + ], + fields: [ + { + rhf: "FieldArray", + name: "city-info", + props: { appendText: "Add City" }, + fields: [ + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: "flex-row flex gap-5", + }, + fields: [ + { + rhf: "Input", + name: "city-geo-var", + rules: { required: "* Required" }, + label: "City", + labelClassName: "font-bold", + props: { + className: "w-[125px]", + }, + }, + { + rhf: "Input", + name: "above-city-geo-var", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: + "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Above", + labelClassName: "font-bold", + props: { + className: "w-[159px]", + icon: "% FPL", + iconRight: true, + }, + }, + { + rhf: "Input", + name: "up-to-and-including-city-geo-var", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: + "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Up to and including", + labelClassName: "font-bold", + props: { + icon: "% FPL", + iconRight: true, + className: "w-[159px]", + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + label: "Other geographic area", + value: "other-geo-area", + slots: [ + { + rhf: "WrappedGroup", + name: "wrapped", + styledLabel: [ + { + text: "Enter each geographic area with a unique income standard.", + type: "default", + classname: "block pb-2", + }, + { + text: "CHIP coverage for pregnant women may only be provided if the qualifying income standard under Medicaid for pregnant women is at least 185% FPL.", + type: "default", + classname: "block pb-2", + }, + { + text: "The highest income level for pregnant women cannot be more than the highest income level for children.", + type: "default", + classname: "block pb-2", + }, + ], + fields: [ + { + rhf: "FieldArray", + name: "other-geo-area", + props: { appendText: "Add geographic area" }, + fields: [ + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: "flex flex-col gap-7", + }, + fields: [ + { + rhf: "Input", + name: "other-geo-var", + rules: { required: "* Required" }, + label: "Geographic Area", + labelClassName: "font-bold", + props: { + className: "w-[527px]", + }, + }, + { + rhf: "Textarea", + name: "other-geo-area-describe", + rules: { + required: "* Required", + pattern: { + value: noLeadingTrailingWhitespace, + message: + "Must not have leading or trailing whitespace.", + }, + }, + label: "Describe", + labelClassName: "font-bold", + props: { + className: "w-[527px]", + }, + }, + { + rhf: "WrappedGroup", + name: "wrapped", + props: { + wrapperClassName: "flex-row flex gap-5", + }, + fields: [ + { + rhf: "Input", + name: "other-above-geo-var", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: + "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Above", + labelClassName: "font-bold", + props: { + className: "w-[159px]", + icon: "% FPL", + iconRight: true, + }, + }, + { + rhf: "Input", + name: "up-to-and-including-other-geo-var", + rules: { + pattern: { + value: /^\d*\.?\d+$/, + message: + "Must be a positive percentage", + }, + required: "* Required", + }, + label: "Up to and including", + labelClassName: "font-bold", + props: { + icon: "% FPL", + iconRight: true, + className: "w-[159px]", + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + ], +}; diff --git a/lib/libs/webforms/index.ts b/lib/libs/webforms/index.ts index c18679280..bdcb9708d 100644 --- a/lib/libs/webforms/index.ts +++ b/lib/libs/webforms/index.ts @@ -14,6 +14,7 @@ import * as ABP10 from "./ABP10"; import * as ABP11 from "./ABP11"; import * as CS3 from "./CS3"; import * as CS7 from "./CS7"; +import * as CS8 from "./CS8"; import * as G2A from "./G2A"; import * as G1 from "./G1"; import * as G2B from "./G2B"; @@ -71,6 +72,9 @@ export const webformVersions: Record> = { CS7: { v202401: CS7.v202401, }, + CS8: { + v202401: CS8.v202401, + }, G1: { v202401: G1.v202401, }, diff --git a/test/e2e/tests/a11y/index.spec.ts b/test/e2e/tests/a11y/index.spec.ts index 5a81c1545..bbdaacfd7 100644 --- a/test/e2e/tests/a11y/index.spec.ts +++ b/test/e2e/tests/a11y/index.spec.ts @@ -52,6 +52,7 @@ const webformRoutes = [ "/webform/g2b/202401", "/webform/g2a/202401", "/webform/g1/202401", + "/webform/cs8/202401", "/webform/cs3/202401", "/webform/abp11/202401", "/webform/abp10/202401", From 01b0a9b1ddfd1e0090b2bf60dbb549c142976776 Mon Sep 17 00:00:00 2001 From: asharonbaltazar <58940073+asharonbaltazar@users.noreply.github.com> Date: Fri, 20 Sep 2024 14:37:42 -0400 Subject: [PATCH 2/9] chore(ui): remove complete-intake (#783) * chore: remove complete-intake * chore: remove export from index * chore: remove more complete intake stuff * chore: remove more complete intake stuff --- lib/lambda/action.ts | 4 - .../complete-intake/complete-intake.test.ts | 8 -- .../complete-intake/complete-intake.ts | 46 ---------- lib/lambda/package-actions/index.ts | 1 - .../services/mako-write-service.ts | 24 ------ .../services/package-action-write-service.ts | 45 ---------- .../services/seatool-write-service.ts | 68 ++------------- .../action-types/complete-intake.ts | 18 ---- .../shared-types/action-types/index.ts | 1 - lib/packages/shared-types/actions.ts | 1 - .../shared-utils/package-actions/rules.ts | 11 +-- react-app/src/api/submissionService.ts | 9 +- .../src/components/Form/fields/CPOCSelect.tsx | 45 ---------- .../Form/fields/DescriptionInput.tsx | 33 -------- .../components/Form/fields/SubTypeSelect.tsx | 83 ------------------- .../components/Form/fields/SubjectInput.tsx | 28 ------- .../src/components/Form/fields/TypeSelect.tsx | 60 -------------- .../package-actions/lib/contentSwitch.ts | 9 -- .../package-actions/lib/fieldsSwitch.ts | 9 -- .../lib/modules/complete-intake/index.tsx | 52 ------------ .../package-actions/lib/modules/index.ts | 1 - .../package-actions/lib/schemaSwitch.ts | 9 -- .../package-actions/lib/successCheckSwitch.ts | 2 - .../features/package/admin-changes/index.tsx | 16 +--- react-app/src/features/submission/index.ts | 1 - .../submission/shared-components/index.ts | 5 -- react-app/src/utils/labelMappers.ts | 2 - react-app/src/utils/useSeaToolAuthorityId.ts | 13 --- 28 files changed, 12 insertions(+), 592 deletions(-) delete mode 100644 lib/lambda/package-actions/complete-intake/complete-intake.test.ts delete mode 100644 lib/lambda/package-actions/complete-intake/complete-intake.ts delete mode 100644 lib/packages/shared-types/action-types/complete-intake.ts delete mode 100644 react-app/src/components/Form/fields/CPOCSelect.tsx delete mode 100644 react-app/src/components/Form/fields/DescriptionInput.tsx delete mode 100644 react-app/src/components/Form/fields/SubTypeSelect.tsx delete mode 100644 react-app/src/components/Form/fields/SubjectInput.tsx delete mode 100644 react-app/src/components/Form/fields/TypeSelect.tsx delete mode 100644 react-app/src/features/package-actions/lib/modules/complete-intake/index.tsx delete mode 100644 react-app/src/features/submission/shared-components/index.ts delete mode 100644 react-app/src/utils/useSeaToolAuthorityId.ts diff --git a/lib/lambda/action.ts b/lib/lambda/action.ts index 5f374d8cd..bb86990bc 100644 --- a/lib/lambda/action.ts +++ b/lib/lambda/action.ts @@ -15,7 +15,6 @@ import { updateId, withdrawPackage, withdrawRai, - completeIntake, removeAppkChild, } from "./package-actions"; @@ -92,9 +91,6 @@ export const handler = async (event: APIGatewayEvent) => { case Action.UPDATE_ID: await updateId(body); break; - case Action.COMPLETE_INTAKE: - await completeIntake(body); - break; case Action.REMOVE_APPK_CHILD: await removeAppkChild(body); break; diff --git a/lib/lambda/package-actions/complete-intake/complete-intake.test.ts b/lib/lambda/package-actions/complete-intake/complete-intake.test.ts deleted file mode 100644 index eb05fb1d6..000000000 --- a/lib/lambda/package-actions/complete-intake/complete-intake.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it, expect } from "vitest"; - -// this is a placeholder. i will rewrite these shortly -describe("example test", () => { - it("should equal 1", () => { - expect(1).toEqual(1); - }); -}); \ No newline at end of file diff --git a/lib/lambda/package-actions/complete-intake/complete-intake.ts b/lib/lambda/package-actions/complete-intake/complete-intake.ts deleted file mode 100644 index cf27f28a2..000000000 --- a/lib/lambda/package-actions/complete-intake/complete-intake.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { completeIntakeSchema, Action } from "shared-types"; -import { response } from "../../../libs/handler-lib"; -import { TOPIC_NAME } from "../consts"; -import { completeIntakeAction } from "../services/package-action-write-service"; - -export async function completeIntake(body: any) { - console.log("CMS performing intake for a record."); - - const result = completeIntakeSchema.safeParse(body); - if (!result.success) { - console.error( - "validation error: The following record failed to parse: ", - JSON.stringify(body), - "Because of the following Reason(s):", - result.error.message, - ); - return response({ - statusCode: 400, - body: { - message: "Event validation error", - }, - }); - } - - const now = new Date().getTime(); - - await completeIntakeAction({ - timestamp: now, - action: Action.COMPLETE_INTAKE, - cpoc: result.data.cpoc, - description: result.data.description, - id: result.data.id, - subject: result.data.subject, - submitterName: result.data.submitterName, - subTypeIds: result.data.subTypeIds, - topicName: TOPIC_NAME, - typeIds: result.data.typeIds, - }); - - return response({ - statusCode: 200, - body: { - message: "record successfully submitted", - }, - }); -} diff --git a/lib/lambda/package-actions/index.ts b/lib/lambda/package-actions/index.ts index a780bba00..2a850dcfd 100644 --- a/lib/lambda/package-actions/index.ts +++ b/lib/lambda/package-actions/index.ts @@ -1,4 +1,3 @@ -export * from "./complete-intake/complete-intake"; export * from "./consts"; export * from "./get-id-to-update"; export * from "./issue-rai/issue-rai"; diff --git a/lib/lambda/package-actions/services/mako-write-service.ts b/lib/lambda/package-actions/services/mako-write-service.ts index 07c697488..e202fc586 100644 --- a/lib/lambda/package-actions/services/mako-write-service.ts +++ b/lib/lambda/package-actions/services/mako-write-service.ts @@ -8,13 +8,6 @@ export type MessageProducer = ( value: string, ) => Promise; -export type CompleteIntakeDto = { - topicName: string; - id: string; - action: Action; - timestamp: number; -} & Record; - export type IssueRaiDto = { topicName: string; id: string; @@ -59,23 +52,6 @@ export type UpdateIdDto = { action: Action; } & Record; -export const completeIntakeMako = async ({ - action, - id, - timestamp, - topicName, - ...data -}: CompleteIntakeDto) => - produceMessage( - topicName, - id, - JSON.stringify({ - actionType: action, - timestamp, - ...data, - }), - ); - export const issueRaiMako = async ({ action, id, diff --git a/lib/lambda/package-actions/services/package-action-write-service.ts b/lib/lambda/package-actions/services/package-action-write-service.ts index fe5ceb569..c2f35167e 100644 --- a/lib/lambda/package-actions/services/package-action-write-service.ts +++ b/lib/lambda/package-actions/services/package-action-write-service.ts @@ -1,14 +1,12 @@ import { getIdsToUpdate } from "../get-id-to-update"; import { IssueRaiDto as MakoIssueRaiDto, - CompleteIntakeDto as MakoCompleteIntake, RespondToRaiDto as MakoRespondToRai, WithdrawRaiDto as MakoWithdrawRai, ToggleRaiResponseDto, RemoveAppkChildDto as MakoRemoveAppkChild, WithdrawPackageDto as MakoWithdrawPackage, UpdateIdDto as MakoUpdateId, - completeIntakeMako, issueRaiMako, respondToRaiMako, withdrawPackageMako, @@ -18,14 +16,12 @@ import { } from "./mako-write-service"; import { IssueRaiDto as SeaIssueRaiDto, - CompleteIntakeDto as SeaCompleteIntake, RespondToRaiDto as SeaRespondToRai, WithdrawRaiDto as SeaWithdrawRai, RemoveAppkChildDto as SeaRemoveAppkChild, WithdrawPackageDto as SeaWithdrawPackage, UpdateIdDto as SeaUpdateId, getTrx, - completeIntakeSeatool, issueRaiSeatool, respondToRaiSeatool, withdrawRaiSeatool, @@ -34,7 +30,6 @@ import { updateIdSeatool, } from "./seatool-write-service"; -export type CompleteIntakeDto = MakoCompleteIntake & SeaCompleteIntake; export type IssueRaiDto = SeaIssueRaiDto & MakoIssueRaiDto; export type RespondToRaiDto = SeaRespondToRai & MakoRespondToRai; export type WithdrawRaiDto = SeaWithdrawRai & MakoWithdrawRai; @@ -42,46 +37,6 @@ export type RemoveAppkChildDto = SeaRemoveAppkChild & MakoRemoveAppkChild; export type WithdrawPackageDto = SeaWithdrawPackage & MakoWithdrawPackage; export type UpdateIdDto = SeaUpdateId & MakoUpdateId; -export const completeIntakeAction = async ({ - action, - cpoc, - description, - id, - subTypeIds, - subject, - submitterName, - timestamp, - topicName, - typeIds, - ...data -}: CompleteIntakeDto) => { - const { trx } = await getTrx(); - - try { - await trx.begin(); - await completeIntakeSeatool({ - typeIds, - subTypeIds, - id, - cpoc, - description, - subject, - submitterName, - }); - await completeIntakeMako({ - action, - id, - timestamp, - topicName, - ...data, - }); - await trx.commit(); - } catch (err: unknown) { - console.log("AN ERROR OCCURED: ", err); - trx.rollback(); - } -}; - export const issueRaiAction = async ({ action, id, diff --git a/lib/lambda/package-actions/services/seatool-write-service.ts b/lib/lambda/package-actions/services/seatool-write-service.ts index e41e682de..7fde66e2e 100644 --- a/lib/lambda/package-actions/services/seatool-write-service.ts +++ b/lib/lambda/package-actions/services/seatool-write-service.ts @@ -3,16 +3,6 @@ import { buildStatusMemoQuery } from "../../../libs/api/statusMemo"; import { formatSeatoolDate, getSecret } from "shared-utils"; import * as sql from "mssql"; -export type CompleteIntakeDto = { - id: string; - typeIds: number[]; - subTypeIds: number[]; - subject: string; - description: string; - cpoc: number; - submitterName: string; -}; - export type IssueRaiDto = { id: string; today: number; @@ -92,56 +82,6 @@ export const getTrx = async () => { return { trx: transaction }; }; -export const completeIntakeSeatool = async ({ - typeIds, - subTypeIds, - id, - cpoc, - description, - subject, - submitterName, -}: CompleteIntakeDto) => { - const { trx } = await getTrx(); - - // Generate INSERT statements for typeIds - const typeIdsValues = typeIds - .map((typeId: number) => `('${id}', '${typeId}')`) - .join(",\n"); - const typeIdsInsert = typeIdsValues - ? `INSERT INTO SEA.dbo.State_Plan_Service_Types (ID_Number, Service_Type_ID) VALUES ${typeIdsValues};` - : ""; - - // Generate INSERT statements for subTypeIds - const subTypeIdsValues = subTypeIds - .map((subTypeId: number) => `('${id}', '${subTypeId}')`) - .join(",\n"); - - const subTypeIdsInsert = subTypeIdsValues - ? `INSERT INTO SEA.dbo.State_Plan_Service_SubTypes (ID_Number, Service_SubType_ID) VALUES ${subTypeIdsValues};` - : ""; - - await trx.request().query(` - UPDATE SEA.dbo.State_Plan - SET - Title_Name = ${subject ? `'${subject.replace("'", "''")}'` : "NULL"}, - Summary_Memo = ${ - description ? `'${description.replace("'", "''")}'` : "NULL" - }, - Lead_Analyst_ID = ${cpoc ? cpoc : "NULL"}, - Status_Memo = ${buildStatusMemoQuery( - id, - `Intake Completed: Intake was completed by ${submitterName}`, - )} - WHERE ID_Number = '${id}' - - -- Insert all types into State_Plan_Service_Types - ${typeIdsInsert} - - -- Insert all types into State_Plan_Service_SubTypes - ${subTypeIdsInsert} - `); -}; - export const issueRaiSeatool = async ({ id, spwStatus, @@ -183,12 +123,16 @@ export const respondToRaiSeatool = async ({ if (raiReceivedDate && raiWithdrawnDate) { statusMemoUpdate = buildStatusMemoQuery( id, - `RAI Response Received. This overwrites the previous response received on ${formatSeatoolDate(raiReceivedDate)} and withdrawn on ${formatSeatoolDate(raiWithdrawnDate)}`, + `RAI Response Received. This overwrites the previous response received on ${formatSeatoolDate( + raiReceivedDate, + )} and withdrawn on ${formatSeatoolDate(raiWithdrawnDate)}`, ); } else if (raiWithdrawnDate) { statusMemoUpdate = buildStatusMemoQuery( id, - `RAI Response Received. This overwrites a previous response withdrawn on ${formatSeatoolDate(raiWithdrawnDate)}`, + `RAI Response Received. This overwrites a previous response withdrawn on ${formatSeatoolDate( + raiWithdrawnDate, + )}`, ); } else { statusMemoUpdate = buildStatusMemoQuery(id, "RAI Response Received"); diff --git a/lib/packages/shared-types/action-types/complete-intake.ts b/lib/packages/shared-types/action-types/complete-intake.ts deleted file mode 100644 index 737a8ae89..000000000 --- a/lib/packages/shared-types/action-types/complete-intake.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; - -// This is the event schema for intake performed from our system -export const completeIntakeSchema = z.object({ - id: z.string(), - authority: z.string(), - origin: z.string(), - submitterName: z.string(), - submitterEmail: z.string(), - subject: z.string(), - description: z.string(), - typeIds: z.array(z.number()), - subTypeIds: z.array(z.number()), - cpoc: z.number(), - timestamp: z.number().optional(), -}); - -export type CompleteIntake = z.infer; diff --git a/lib/packages/shared-types/action-types/index.ts b/lib/packages/shared-types/action-types/index.ts index aa930cde6..53a2857b4 100644 --- a/lib/packages/shared-types/action-types/index.ts +++ b/lib/packages/shared-types/action-types/index.ts @@ -10,4 +10,3 @@ export * from "./legacy-admin-change"; export * from "./seatool"; export * from "./remove-appk-child"; export * from "./update-id"; -export * from "./complete-intake"; diff --git a/lib/packages/shared-types/actions.ts b/lib/packages/shared-types/actions.ts index 493ba06b6..f3117effa 100644 --- a/lib/packages/shared-types/actions.ts +++ b/lib/packages/shared-types/actions.ts @@ -11,7 +11,6 @@ export enum Action { REMOVE_APPK_CHILD = "remove-appk-child", TEMP_EXTENSION = "temporary-extension", UPDATE_ID = "update-id", - COMPLETE_INTAKE = "complete-intake", LEGACY_ADMIN_CHANGE = "legacy-admin-change", LEGACY_WITHDRAW_RAI_REQUEST = "legacy-withdraw-rai-request", } diff --git a/lib/packages/shared-utils/package-actions/rules.ts b/lib/packages/shared-utils/package-actions/rules.ts index c2b6502a6..f3b741139 100644 --- a/lib/packages/shared-utils/package-actions/rules.ts +++ b/lib/packages/shared-utils/package-actions/rules.ts @@ -94,17 +94,11 @@ const arUpdateId: ActionRule = { check: (checker, user) => isCmsSuperUser(user) && !checker.hasStatus(finalDispositionStatuses), }; -const arCompleteIntake: ActionRule = { - action: Action.COMPLETE_INTAKE, - check: (checker, user) => isCmsWriteUser(user) && checker.needsIntake, -}; const arRemoveAppkChild: ActionRule = { action: Action.REMOVE_APPK_CHILD, - check: (checker, user) => - isStateUser(user) && !!checker.isAppkChild -} - + check: (checker, user) => isStateUser(user) && !!checker.isAppkChild, +}; export default [ arIssueRai, @@ -115,6 +109,5 @@ export default [ arWithdrawPackage, arTempExtension, arUpdateId, - arCompleteIntake, arRemoveAppkChild, ]; diff --git a/react-app/src/api/submissionService.ts b/react-app/src/api/submissionService.ts index 92185a358..fd787fea0 100644 --- a/react-app/src/api/submissionService.ts +++ b/react-app/src/api/submissionService.ts @@ -47,7 +47,7 @@ export const buildAttachmentObject = (recipes?: UploadRecipe[]) => { title: r.title, bucket: r.bucket, uploadDate: Date.now(), - }) as Attachment, + } as Attachment), ) .flat(); }; @@ -103,13 +103,6 @@ export const buildSubmissionPayload = >( attachments: attachments ? buildAttachmentObject(attachments) : null, state: (data.id as string).split("-")[0], }; - case buildActionUrl(Action.COMPLETE_INTAKE): - return { - ...data, - ...baseProperties, - ...userDetails, - state: (data.id as string).split("-")[0], - }; case buildActionUrl(Action.ISSUE_RAI): case buildActionUrl(Action.RESPOND_TO_RAI): case buildActionUrl(Action.ENABLE_RAI_WITHDRAW): diff --git a/react-app/src/components/Form/fields/CPOCSelect.tsx b/react-app/src/components/Form/fields/CPOCSelect.tsx deleted file mode 100644 index 4e3e3cc39..000000000 --- a/react-app/src/components/Form/fields/CPOCSelect.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useGetCPOCs } from "@/api"; -import * as Inputs from "@/components/Inputs"; -import { useFormContext } from "react-hook-form"; -import Select from "react-select"; -import { cpocs } from "shared-types/opensearch"; - -export function CPOCSelect() { - const { data } = useGetCPOCs(); - const form = useFormContext(); - - const options = data - ? data?.map((item) => ({ - value: item.id, - label: `${item.lastName}, ${item.firstName}`, - })) - : []; - - return ( - { - return ( - - - CPOC - - - - subTypeIds?.includes(option.value), - )} - onChange={(val) => { - if (val.length === 0) { - setValue("subTypeIds", [], { shouldValidate: true }); - return; - } - const v = val.map((v: SelectOption) => v.value); - setValue("subTypeIds", v, { shouldValidate: true }); - }} - options={options} - closeMenuOnSelect={false} - className="border border-black shadow-sm rounded-sm" - placeholder="Select a type to see options" - /> - - - )} - /> - ); -} diff --git a/react-app/src/components/Form/fields/SubjectInput.tsx b/react-app/src/components/Form/fields/SubjectInput.tsx deleted file mode 100644 index 845f30798..000000000 --- a/react-app/src/components/Form/fields/SubjectInput.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as Inputs from "@/components/Inputs"; -import { useFormContext } from "react-hook-form"; -import { useParams } from "react-router-dom"; - -export function SubjectInput() { - const form = useFormContext(); - const { authority } = useParams(); - return ( - ( - - - Subject - -

- The title or purpose of the {authority} -

- - - - -
- )} - /> - ); -} diff --git a/react-app/src/components/Form/fields/TypeSelect.tsx b/react-app/src/components/Form/fields/TypeSelect.tsx deleted file mode 100644 index ce6736937..000000000 --- a/react-app/src/components/Form/fields/TypeSelect.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useGetTypes } from "@/api"; -import * as Inputs from "@/components/Inputs"; -import { useFormContext } from "react-hook-form"; -import Select from "react-select"; -import { useSeaToolAuthorityId } from "@/utils/useSeaToolAuthorityId"; -import { LoadingSpinner } from "@/components"; - -type SelectOption = { - value: number; - label: string; -}; - -export function TypeSelect() { - const authorityId = useSeaToolAuthorityId(); - const { data, isLoading } = useGetTypes(authorityId); - const form = useFormContext(); - - const options = data?.map((item) => ({ - value: item.id, - label: item.name, - })); - - if (isLoading) return ; - return ( - { - return ( - - - Type - -

- You may select more than one -

- } + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + defaultValues={{ id: "default value for id" }} + tab={"waivers"} + />, + ); + + expect(queryByDisplayValue("default value for id")).toBeInTheDocument(); + }); + + test("renders `attachments.specialInstructions`", () => { + const { queryByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ + faqLink: "", + specialInstructions: "hello world special instructions.", + }} + tab={"waivers"} + />, + ); + + expect( + queryByText(/hello world special instructions./), + ).toBeInTheDocument(); + }); + + test("renders custom `promptOnLeavingForm` when clicking Cancel", async () => { + const { container } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ + faqLink: "", + }} + promptOnLeavingForm={{ + header: "Hello World Header", + body: "Hello World Body", + }} + tab={"waivers"} + />, + ); + + const onAcceptMock = vi.fn(); + const userPromptSpy = vi + .spyOn(userPrompt, "userPrompt") + .mockImplementation((args) => (args.onAccept = onAcceptMock)); + + const cancelBtn = container.querySelector('button[type="reset"]'); + await userEvent.click(cancelBtn); + + expect(userPromptSpy).toBeCalledWith({ + header: "Hello World Header", + body: "Hello World Body", + onAccept: onAcceptMock, + }); + }); + + test("renders custom `promptPreSubmission` when clicking Submit", async () => { + const { container } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ + faqLink: "", + }} + defaultValues={{ id: "hello world" }} + promptPreSubmission={{ + header: "Hello World Header", + body: "Hello World Body", + }} + tab={"waivers"} + />, + ); + + const onAcceptMock = vi.fn(); + const userPromptSpy = vi + .spyOn(userPrompt, "userPrompt") + .mockImplementation((args) => (args.onAccept = onAcceptMock)); + + const submitBtn = container.querySelector('button[type="button"]'); + await userEvent.click(submitBtn); + + expect(userPromptSpy).toBeCalledWith({ + header: "Hello World Header", + body: "Hello World Body", + onAccept: onAcceptMock, + }); + }); + + test("calls `documentPoller` with `documentPollerArgs`", async () => { + const documentCheckerFunc = vi.fn(); + + const { container } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: documentCheckerFunc, + }} + attachments={{ + faqLink: "", + }} + tab={"waivers"} + />, + ); + + const documentPollerSpy = vi + .spyOn(documentPoller, "documentPoller") + .mockImplementationOnce(vi.fn()); + + const submitBtn = container.querySelector('button[type="submit"]'); + await userEvent.click(submitBtn); + + waitFor(() => + expect(documentPollerSpy).toBeCalledWith("id", documentCheckerFunc), + ); + }); + + test("calls `banner` with `bannerPostSubmission`", async () => { + const documentCheckerFunc = vi.fn(); + + const { container } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: documentCheckerFunc, + }} + bannerPostSubmission={{ + header: "Hello World Header", + body: "Hello World Body", + }} + attachments={{ + faqLink: "", + }} + tab={"waivers"} + />, + ); + + const bannerPollerSpy = vi.spyOn(banner, "banner"); + + const submitBtn = container.querySelector('button[type="submit"]'); + await userEvent.click(submitBtn); + + waitFor(() => + expect(bannerPollerSpy).toBeCalledWith({ + header: "Hello World Header", + body: "Hello World Body", + pathnameToDisplayOn: "/dashboard", + }), + ); + }); + + test("renders all attachment properties within `attachments`", async () => { + const { queryAllByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ + faqLink: "", + }} + tab={"waivers"} + />, + ); + + const otherAttachmentLabels = queryAllByText("Other"); + + expect(otherAttachmentLabels.length).toBe(3); + }); + + test("renders Additional Information if `additionalInformation` is defined in schema", () => { + const { queryByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText("Additional Information")).toBeInTheDocument(); + }); + + test("doesn't render Additional Information if `additionalInformation` is undefined in schema", () => { + const { queryByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText("Additional Information")).not.toBeInTheDocument(); + }); + + test("renders Attachments if `attachments` is defined in schema", () => { + const { queryByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText("Attachments")).toBeInTheDocument(); + expect(queryByText("Other")).toBeInTheDocument(); + }); + + test("doesn't render Attachments if `attachments` is undefined in schema", () => { + const { queryByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText("Attachments")).not.toBeInTheDocument(); + }); + + test("renders ProgressReminder if schema has `attachments` property", () => { + const { queryAllByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryAllByText(PROGRESS_REMINDER).length).toBe(2); + }); + + test("renders ProgressReminder if `fields` property is defined", () => { + const { queryAllByText } = renderWithMemoryRouter( + ( + <> +
+
+ + )} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryAllByText(PROGRESS_REMINDER).length).toBe(2); + }); + + test("doesn't render ProgressReminder `fields` is undefined and `attachments` isn't defined in schema", () => { + const { queryByText } = renderWithMemoryRouter( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText(PROGRESS_REMINDER)).not.toBeInTheDocument(); + }); + + test("renders default wrapper if `fieldsLayout` is undefined", () => { + const { queryAllByText } = renderWithMemoryRouter( + ( + <> +
+
+ + )} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect( + queryAllByText( + /Once you submit this form, a confirmation email is sent to you and to CMS./, + ).length, + ).toBe(2); + }); + + test("renders `fieldsLayout`", () => { + const { queryByText } = renderWithMemoryRouter( + ( + <> + hello world! + {children} + + )} + fields={() =>

hello world within fields Layout

} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText("hello world!")).toBeInTheDocument(); + expect(queryByText("hello world within fields Layout")).toBeInTheDocument(); + }); + + test("renders `fieldsLayout` with correct `title`", () => { + const { queryByText } = renderWithMemoryRouter( + ( + <> + {title} + {children} + + )} + fields={() =>

hello world within fields Layout

} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "" }} + tab={"waivers"} + />, + ); + + expect(queryByText("Action Form Title")).toBeInTheDocument(); + expect(queryByText("hello world within fields Layout")).toBeInTheDocument(); + }); +}); diff --git a/react-app/src/components/ActionForm/ActionFormAttachments.tsx b/react-app/src/components/ActionForm/ActionFormAttachments.tsx new file mode 100644 index 000000000..17a257971 --- /dev/null +++ b/react-app/src/components/ActionForm/ActionFormAttachments.tsx @@ -0,0 +1,106 @@ +import { useFormContext } from "react-hook-form"; +import { z } from "zod"; +import { + SectionCard, + FormField, + FormItem, + FormLabel, + FormMessage, + RequiredIndicator, + Upload, +} from "@/components"; +import { Link } from "react-router-dom"; +import { FAQ_TAB } from "../Routing"; + +const DEFAULT_ATTACHMENTS_INSTRUCTIONS = + "Maximum file size of 80 MB per attachment. You can add multiple files per attachment type."; + +const AttachmentInstructions = ({ fileValidation }) => { + const { maxLength, minLength } = fileValidation; + + if (maxLength?.value === 1 && minLength?.value === 1) { + return

One attachment is required

; + } + + if (minLength?.value) { + return

At least one attachment is required

; + } + + return null; +}; + +type ActionFormAttachmentsProps = { + attachmentsFromSchema: [string, z.ZodObject][]; + specialInstructions?: string; + faqLink: string; +}; + +export const ActionFormAttachments = ({ + attachmentsFromSchema, + specialInstructions = DEFAULT_ATTACHMENTS_INSTRUCTIONS, + faqLink, +}: ActionFormAttachmentsProps) => { + const form = useFormContext(); + + return ( + +
+

+ {specialInstructions} Read the description for each of the attachment + types on the{" "} + + FAQ Page + + . +

+
+

+ We accept the following file formats:{" "} + .doc, .docx, .pdf, .jpg, .xlsx, and more. {" "} + + See the full list + + . +

+
+
+ {attachmentsFromSchema.map(([key, value]) => ( + ( + + + {value.shape.label._def.defaultValue()}{" "} + {value.shape.files instanceof z.ZodOptional ? null : ( + + )} + + + + + + )} + /> + ))} +
+
+ ); +}; diff --git a/react-app/src/components/ActionForm/actionForm.utilities.ts b/react-app/src/components/ActionForm/actionForm.utilities.ts new file mode 100644 index 000000000..3aae4c0e9 --- /dev/null +++ b/react-app/src/components/ActionForm/actionForm.utilities.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const getAdditionalInformation = < + Schema extends z.ZodObject | z.ZodEffects, +>( + schema: Schema, +): z.ZodDefault> | undefined => { + if (schema instanceof z.ZodEffects) { + const innerSchema = schema._def.schema; + + if (innerSchema instanceof z.ZodObject) { + if (innerSchema.shape.additionalInformation instanceof z.ZodDefault) { + return innerSchema.shape.additionalInformation; + } + } + } + + if (schema instanceof z.ZodObject) { + if (schema.shape.additionalInformation instanceof z.ZodDefault) { + return schema.shape.additionalInformation; + } + } + + return undefined; +}; + +export const getAttachments = ( + schema: Schema, +): [string, z.ZodObject][] => { + if (schema instanceof z.ZodEffects) { + const innerSchema = schema._def.schema; + + if ( + innerSchema instanceof z.ZodObject && + innerSchema.shape.attachments instanceof z.ZodObject + ) { + return Object.entries(innerSchema.shape?.attachments?.shape ?? {}); + } + } + + if (schema instanceof z.ZodObject) { + if (schema.shape.attachments instanceof z.ZodObject) { + return Object.entries(schema.shape?.attachments?.shape ?? {}); + } + } + + return []; +}; diff --git a/react-app/src/components/ActionForm/index.tsx b/react-app/src/components/ActionForm/index.tsx new file mode 100644 index 000000000..82950ae2e --- /dev/null +++ b/react-app/src/components/ActionForm/index.tsx @@ -0,0 +1,249 @@ +import { ReactNode, useMemo } from "react"; +import { + Banner, + Button, + UserPrompt, + SimplePageContainer, + BreadCrumbs, + Form, + LoadingSpinner, + SectionCard, + FormIntroText, + FormField, + banner, + userPrompt, + formCrumbsFromPath, + FAQFooter, + PreSubmissionMessage, +} from "@/components"; +import { + DefaultValues, + FieldPath, + useForm, + UseFormReturn, +} from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { SlotAdditionalInfo } from "@/features"; +import { getFormOrigin } from "@/utils"; +import { + CheckDocumentFunction, + documentPoller, +} from "@/utils/Poller/documentPoller"; +import { API } from "aws-amplify"; +import { Authority } from "shared-types"; +import { ActionFormAttachments } from "./ActionFormAttachments"; +import { + getAttachments, + getAdditionalInformation, +} from "./actionForm.utilities"; + +type EnforceSchemaProps = z.ZodObject< + Shape & { + attachments?: z.ZodObject<{ + [Key in keyof Shape]: z.ZodObject<{ + label: z.ZodDefault; + files: z.ZodTypeAny; + }>; + }>; + additionalInformation?: z.ZodDefault>; + }, + "strip", + z.ZodTypeAny +>; + +export type SchemaWithEnforcableProps< + Shape extends z.ZodRawShape = z.ZodRawShape, +> = z.ZodEffects> | EnforceSchemaProps; + +// Utility type to handle Zod schema with or without a transform +type InferUntransformedSchema = T extends z.ZodEffects ? U : T; + +type ActionFormProps = { + schema: Schema; + defaultValues?: DefaultValues>>; // Adjusted to infer the base schema type + title: string; + fieldsLayout?: (props: { children: ReactNode; title: string }) => ReactNode; + fields: ( + form: UseFormReturn>>, + ) => ReactNode; // Adjusted to use the untransformed schema type + bannerPostSubmission?: Omit; + promptPreSubmission?: Omit; + promptOnLeavingForm?: Omit; + attachments: { + faqLink: string; + specialInstructions?: string; + }; + documentPollerArgs: { + property: + | (keyof z.TypeOf & string) + | ((values: z.TypeOf) => string); + documentChecker: CheckDocumentFunction; + }; + tab: "spas" | "waivers"; +}; + +export const ActionForm = ({ + schema, + defaultValues, + title, + fields: Fields, + fieldsLayout: FieldsLayout, + bannerPostSubmission = { + header: "Package submitted", + body: "Your submission has been received.", + variant: "success", + }, + promptOnLeavingForm = { + header: "Stop form submission?", + body: "All information you've entered on this form will be lost if you leave this page.", + acceptButtonText: "Yes, leave form", + cancelButtonText: "Return to form", + areButtonsReversed: true, + }, + promptPreSubmission, + documentPollerArgs, + attachments, + tab, +}: ActionFormProps) => { + const { id, authority } = useParams<{ id: string; authority: Authority }>(); + const location = useLocation(); + const navigate = useNavigate(); + + const form = useForm>({ + resolver: zodResolver(schema), + mode: "onChange", + defaultValues: { + ...defaultValues, + }, + }); + + const onSubmit = form.handleSubmit(async (formData) => { + try { + await API.post("os", "/submit", { + body: formData, + }); + + const { documentChecker, property } = documentPollerArgs; + + const documentPollerId = + typeof property === "function" + ? property(formData) + : formData[property]; + + const poller = documentPoller(documentPollerId, documentChecker); + await poller.startPollingData(); + + const formOrigins = getFormOrigin({ authority, id }); + banner({ + ...bannerPostSubmission, + pathnameToDisplayOn: formOrigins.pathname, + }); + + navigate(`/dashboard?tab=${tab}`); + } catch (error) { + console.error(error); + banner({ + header: "An unexpected error has occurred:", + body: error instanceof Error ? error.message : String(error), + variant: "destructive", + pathnameToDisplayOn: window.location.pathname, + }); + window.scrollTo(0, 0); + } + }); + + const attachmentsFromSchema = useMemo(() => getAttachments(schema), [schema]); + const additionalInformationFromSchema = useMemo( + () => getAdditionalInformation(schema), + [schema], + ); + const hasProgressLossReminder = useMemo( + () => Fields({ ...form }) !== null || attachmentsFromSchema.length > 0, + [attachmentsFromSchema, Fields, form], + ); + + return ( + + + {form.formState.isSubmitting && } +
+ + {FieldsLayout ? ( + + + + ) : ( + + + + + )} + {attachmentsFromSchema.length > 0 && ( + + )} + {additionalInformationFromSchema && ( + + >} + render={SlotAdditionalInfo({ + withoutHeading: true, + label: ( +

Add anything else you would like to share with CMS.

+ ), + })} + /> +
+ )} + +
+ + +
+ + + +
+ ); +}; diff --git a/react-app/src/components/Alert/index.tsx b/react-app/src/components/Alert/index.tsx index 5c1c6732f..591446542 100644 --- a/react-app/src/components/Alert/index.tsx +++ b/react-app/src/components/Alert/index.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils"; +import { cn } from "@/utils/utils"; const alertVariants = cva( "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", diff --git a/react-app/src/components/Container/banner.test.tsx b/react-app/src/components/Banner/banner.test.tsx similarity index 98% rename from react-app/src/components/Container/banner.test.tsx rename to react-app/src/components/Banner/banner.test.tsx index 62a03ff32..95a95458b 100644 --- a/react-app/src/components/Container/banner.test.tsx +++ b/react-app/src/components/Banner/banner.test.tsx @@ -3,7 +3,7 @@ import { Link, MemoryRouter, Route, Routes } from "react-router-dom"; import { describe, expect, test } from "vitest"; import { act, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { banner, Banner } from "../Banner"; +import { banner, Banner } from "."; const wrapper = ({ children }: { children: ReactNode }) => ( diff --git a/react-app/src/components/Cards/SectionCard.tsx b/react-app/src/components/Cards/SectionCard.tsx index c46d54521..2426a48a4 100644 --- a/react-app/src/components/Cards/SectionCard.tsx +++ b/react-app/src/components/Cards/SectionCard.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from "react"; +import { ReactNode } from "react"; import { cn } from "@/utils"; interface SectionCardProps { @@ -7,26 +7,24 @@ interface SectionCardProps { title?: ReactNode; id?: string; } -export const SectionCard: FC = ({ +export const SectionCard = ({ title, children, className, id, }: SectionCardProps) => { return ( -
-
- {title && ( - <> -

{title}

-
- - )} -
{children}
-
-
+ {title && ( + <> +

{title}

+
+ + )} +
{children}
+ ); }; diff --git a/react-app/src/components/Form/old-content.tsx b/react-app/src/components/Form/old-content.tsx index e58997c3d..335b95bad 100644 --- a/react-app/src/components/Form/old-content.tsx +++ b/react-app/src/components/Form/old-content.tsx @@ -8,10 +8,16 @@ import { ProgressLossReminder, } from "@/components"; -export const FormIntroText = () => ( +type FormIntroTextProps = { + hasProgressLossReminder?: boolean; +}; + +export const FormIntroText = ({ + hasProgressLossReminder = true, +}: FormIntroTextProps) => (
- + Once you submit this form, a confirmation email is sent to you and to CMS. CMS will use this content to review your package, and you will not be able to edit this form. If CMS needs any additional information, they will @@ -97,7 +103,7 @@ type PreSubmissionMessageProps = { export const PreSubmissionMessage = ({ hasProgressLossReminder = true, }: PreSubmissionMessageProps) => ( - +

Once you submit this form, a confirmation email is sent to you and to CMS. diff --git a/react-app/src/components/Inputs/date-picker.tsx b/react-app/src/components/Inputs/date-picker.tsx index f60bde8dc..4d9a5ed45 100644 --- a/react-app/src/components/Inputs/date-picker.tsx +++ b/react-app/src/components/Inputs/date-picker.tsx @@ -13,7 +13,7 @@ import { PopoverTrigger, } from "@/components"; -export const DatePicker = ({ date, onChange }: DatePickerProps) => { +export const DatePicker = ({ date, onChange, dataTestId }: DatePickerProps) => { const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [selected, setSelected] = React.useState(); return ( @@ -25,6 +25,7 @@ export const DatePicker = ({ date, onChange }: DatePickerProps) => { "w-[280px] justify-start text-left font-normal", !date && "text-muted-foreground", )} + data-testid={`${dataTestId}-datepicker`} > {date ? format(date, "MM/dd/yyyy") : Pick a date} diff --git a/react-app/src/components/Inputs/upload.tsx b/react-app/src/components/Inputs/upload.tsx index 96c0da528..641a3c22a 100644 --- a/react-app/src/components/Inputs/upload.tsx +++ b/react-app/src/components/Inputs/upload.tsx @@ -5,29 +5,77 @@ import * as I from "@/components/Inputs"; import { X } from "lucide-react"; import { FILE_TYPES } from "shared-types/uploads"; import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; // Import your LoadingSpinner component +import { attachmentSchema } from "shared-types"; +import { + extractBucketAndKeyFromUrl, + getPresignedUrl, + uploadToS3, +} from "./upload.utilities"; + +type Attachment = z.infer; type UploadProps = { maxFiles?: number; - files: File[]; - setFiles: (files: File[]) => void; + files: Attachment[]; + setFiles: (files: Attachment[]) => void; + dataTestId?: string; }; -export const Upload = ({ maxFiles, files, setFiles }: UploadProps) => { +export const Upload = ({ + maxFiles, + files, + setFiles, + dataTestId, +}: UploadProps) => { + const [isUploading, setIsUploading] = useState(false); // New state for tracking upload status const [errorMessage, setErrorMessage] = useState(null); const uniqueId = uuidv4(); const onDrop = useCallback( - (acceptedFiles: File[], fileRejections: FileRejection[]) => { + async (acceptedFiles: File[], fileRejections: FileRejection[]) => { if (fileRejections.length > 0) { setErrorMessage( "Selected file(s) is too large or of a disallowed file type.", ); } else { setErrorMessage(null); - setFiles([...files, ...acceptedFiles]); + setIsUploading(true); // Set uploading to true + + const processedFiles = await Promise.all( + acceptedFiles.map(async (file) => { + try { + const url = await getPresignedUrl(file.name); + const { bucket, key } = extractBucketAndKeyFromUrl(url); + await uploadToS3(file, url); + + const attachment: Attachment = { + filename: file.name, + title: file.name.split(".").slice(0, -1).join("."), + bucket, + key, + uploadDate: Date.now(), + }; + + return attachment; + } catch (error) { + setErrorMessage("Failed to upload one or more files."); + console.error("Upload error:", error); + return null; + } + }), + ); + + const validAttachments = processedFiles.filter( + (attachment) => attachment !== null, + ) as Attachment[]; + + setFiles([...files, ...validAttachments]); + setIsUploading(false); // Set uploading to false when done } }, - [files], + [files, setFiles], ); const accept: Accept = {}; @@ -42,6 +90,7 @@ export const Upload = ({ maxFiles, files, setFiles }: UploadProps) => { accept, maxFiles, maxSize: 80 * 1024 * 1024, // 80MB, + disabled: isUploading, // Disable dropzone while uploading }); return ( @@ -51,13 +100,13 @@ export const Upload = ({ maxFiles, files, setFiles }: UploadProps) => { {files.map((file) => (

- {file.name} + {file.filename} { e.preventDefault(); - setFiles(files.filter((a) => a.name !== file.name)); + setFiles(files.filter((a) => a.filename !== file.filename)); }} variant="ghost" className="p-0 h-0" @@ -69,26 +118,32 @@ export const Upload = ({ maxFiles, files, setFiles }: UploadProps) => {
)} {errorMessage && {errorMessage}} -
-

- Drag file here or{" "} - - choose from folder - -

- - - - {/* {isDragActive &&

Drag is Active

} */} -
+ {isUploading ? ( + // Render the loading spinner when uploading + ) : ( +
+

+ Drag file here or{" "} + + choose from folder + +

+ + +
+ )} ); }; diff --git a/react-app/src/components/Inputs/upload.utilities.ts b/react-app/src/components/Inputs/upload.utilities.ts new file mode 100644 index 000000000..8a99d945a --- /dev/null +++ b/react-app/src/components/Inputs/upload.utilities.ts @@ -0,0 +1,46 @@ +import { API } from "aws-amplify"; + +export const getPresignedUrl = async (fileName: string): Promise => { + const response = await API.post("os", "/getUploadUrl", { + body: { fileName }, + }); + return response.url; +}; + +export const uploadToS3 = async (file: File, url: string): Promise => { + await fetch(url, { + body: file, + method: "PUT", + }); +}; + +export const extractBucketAndKeyFromUrl = ( + url: string, +): { + bucket: string | null; + key: string | null; +} => { + try { + const parsedUrl = new URL(url); + + const hostnameParts = parsedUrl.hostname.split("."); + let bucket: string | null = null; + let key: string | null = null; + + if ( + hostnameParts.length > 3 && + hostnameParts[1] === "s3" && + hostnameParts[2] === "us-east-1" + ) { + bucket = hostnameParts[0]; // The bucket name is the first part of the hostname + } + + // Extract key from the pathname + key = parsedUrl.pathname.slice(1); // Remove the leading slash + + return { bucket, key }; + } catch (error) { + console.error("Invalid URL format:", error); + return { bucket: null, key: null }; + } +}; diff --git a/react-app/src/components/Opensearch/main/useOpensearch.ts b/react-app/src/components/Opensearch/main/useOpensearch.ts index 382ea9fe2..c6eb10e90 100644 --- a/react-app/src/components/Opensearch/main/useOpensearch.ts +++ b/react-app/src/components/Opensearch/main/useOpensearch.ts @@ -11,9 +11,9 @@ export const DEFAULT_FILTERS: Record> = { spas: { filters: [ { - field: "flavor.keyword", + field: "authority.keyword", type: "terms", - value: ["CHIP", "MEDICAID"], + value: ["Medicaid SPA", "CHIP SPA"], prefix: "must", }, ], @@ -21,17 +21,17 @@ export const DEFAULT_FILTERS: Record> = { waivers: { filters: [ { - field: "flavor.keyword", + field: "authority.keyword", type: "terms", - value: ["WAIVER"], + value: ["1915(b)", "1915(c)"], prefix: "must", }, - { - field: "appkParentId", - type: "exists", - value: true, - prefix: "must_not", - }, + // { + // field: "appkParentId", + // type: "exists", + // value: true, + // prefix: "must_not", + // }, ], }, }; diff --git a/react-app/src/components/RHF/utils/additionalRules.ts b/react-app/src/components/RHF/utils/additionalRules.ts index 2efcd209f..dfed4761d 100644 --- a/react-app/src/components/RHF/utils/additionalRules.ts +++ b/react-app/src/components/RHF/utils/additionalRules.ts @@ -1,5 +1,5 @@ import { RegisterOptions } from "react-hook-form"; -import { RuleGenerator, SortFuncs, AdditionalRule } from "shared-types"; +import { RuleGenerator, SortFuncs, AdditionalRule } from "shared-types/forms"; export const sortFunctions: { [x in SortFuncs]: (a: string, b: string) => number; diff --git a/react-app/src/components/RHF/utils/initializer.ts b/react-app/src/components/RHF/utils/initializer.ts index 3bed34386..6b879958b 100644 --- a/react-app/src/components/RHF/utils/initializer.ts +++ b/react-app/src/components/RHF/utils/initializer.ts @@ -1,25 +1,30 @@ -import * as T from "shared-types"; +import { + RHFOption, + RHFSlotProps, + FormGroup, + FormSchema, +} from "shared-types/forms"; type GL = Record; export const formGroupInitializer = - (parentId?: string) => (ACC: GL, FORM: T.FormGroup) => { + (parentId?: string) => (ACC: GL, FORM: FormGroup) => { FORM.slots.reduce(slotInitializer(parentId), ACC); return ACC; }; export const slotInitializer = (parentId?: string) => - (ACC: GL, SLOT: T.RHFSlotProps): GL => { + (ACC: GL, SLOT: RHFSlotProps): GL => { const adjustedName = `${parentId ?? ""}${SLOT.name}`; - const optionReducer = (OPT: T.RHFOption) => { + const optionReducer = (OPT: RHFOption) => { if (OPT.form) OPT.form.reduce(formGroupInitializer(parentId), ACC); if (OPT.slots) OPT.slots.reduce(slotInitializer(parentId), ACC); return ACC; }; - const fieldInitializer = (ACC1: GL, SLOTC: T.RHFSlotProps): GL => { + const fieldInitializer = (ACC1: GL, SLOTC: RHFSlotProps): GL => { if (SLOTC.rhf === "FieldArray") { return { ...ACC1, @@ -70,7 +75,7 @@ export const slotInitializer = return ACC; }; -export const documentInitializer = (document: T.FormSchema) => { +export const documentInitializer = (document: FormSchema) => { return document.sections.reduce((ACC, SEC) => { SEC.form.reduce( formGroupInitializer(`${document.formId}_${SEC.sectionId}_`), diff --git a/react-app/src/components/RHF/utils/validator.ts b/react-app/src/components/RHF/utils/validator.ts index fdbc2f3dc..5e9fb7ef2 100644 --- a/react-app/src/components/RHF/utils/validator.ts +++ b/react-app/src/components/RHF/utils/validator.ts @@ -4,7 +4,13 @@ * - creating/saving form data * - retrieving form data */ -import * as T from "shared-types"; +import { + DependencyRule, + FormGroup, + FormSchema, + RHFOption, + RHFSlotProps, +} from "shared-types/forms"; import { RegisterOptions } from "react-hook-form"; import { @@ -156,7 +162,7 @@ export const validateOption = (optionValue: string, options: any[]) => { }; // return true: run validation - false: skip validation -export const dependencyCheck = (dep: T.DependencyRule, data: any) => { +export const dependencyCheck = (dep: DependencyRule, data: any) => { const conditionMatched = dep.conditions.every((DC) => { if (DC.type === "valueNotExist") return !data[DC.name]; if (DC.type === "expectedValue") { @@ -171,7 +177,7 @@ export const dependencyCheck = (dep: T.DependencyRule, data: any) => { }; export const formGroupValidator = - (data: any) => (ACC: ERROR, FORM: T.FormGroup) => { + (data: any) => (ACC: ERROR, FORM: FormGroup) => { if (FORM.dependency) { const depMatch = dependencyCheck(FORM.dependency, data); if (!depMatch) return ACC; @@ -182,8 +188,8 @@ export const formGroupValidator = export const slotValidator = (data: any) => - (ACC: ERROR, SLOT: T.RHFSlotProps): ERROR => { - const optionValidator = (OPT: T.RHFOption) => { + (ACC: ERROR, SLOT: RHFSlotProps): ERROR => { + const optionValidator = (OPT: RHFOption) => { if (OPT.form) OPT.form.reduce(formGroupValidator(data), ACC); if (OPT.slots) { OPT.slots.reduce(slotValidator(data), ACC); @@ -191,7 +197,7 @@ export const slotValidator = return ACC; }; - const fieldValidator = (FLD: any) => (SLOT1: T.RHFSlotProps) => { + const fieldValidator = (FLD: any) => (SLOT1: RHFSlotProps) => { if (SLOT1.rhf === "FieldArray") { FLD[SLOT1.name].forEach((DAT: any) => { SLOT1.fields?.forEach(fieldValidator(DAT)); @@ -264,7 +270,7 @@ export const slotValidator = return ACC; }; -export const documentValidator = (document: T.FormSchema) => (data: any) => { +export const documentValidator = (document: FormSchema) => (data: any) => { return document.sections.reduce((ACC, SEC) => { if (SEC.dependency) { const depMatch = dependencyCheck(SEC.dependency, data); diff --git a/react-app/src/components/index.tsx b/react-app/src/components/index.tsx index 80cf90625..00f4e87fc 100644 --- a/react-app/src/components/index.tsx +++ b/react-app/src/components/index.tsx @@ -30,4 +30,5 @@ export * from "./ConfirmationDialog"; export * from "./SearchForm"; export * from "./TimeoutModal"; export * from "./Banner"; +export * from "./ActionForm"; export * from "./Tooltip"; diff --git a/react-app/src/features/forms/index.ts b/react-app/src/features/forms/index.ts new file mode 100644 index 000000000..d8c355cc5 --- /dev/null +++ b/react-app/src/features/forms/index.ts @@ -0,0 +1,5 @@ +export * from "./new-submission"; +export * as CapitatedWaivers from "./waiver/capitated"; +export * as ContractingWaivers from "./waiver/contracting"; +export * from "./waiver/app-k"; +export * from "./waiver/temporary-extension"; diff --git a/react-app/src/features/forms/new-submission/Chip.tsx b/react-app/src/features/forms/new-submission/Chip.tsx new file mode 100644 index 000000000..4c5d87aed --- /dev/null +++ b/react-app/src/features/forms/new-submission/Chip.tsx @@ -0,0 +1,94 @@ +import { + ActionForm, + FAQ_TAB, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + RequiredIndicator, + SpaIdFormattingDesc, + Input, + DatePicker, +} from "@/components"; +import { Link } from "react-router-dom"; +import { formSchemas } from "@/formSchemas"; + +export const ChipForm = () => ( + ( + <> + ( + +
+ + SPA ID + + + What is my SPA ID? + +
+ + + + field.onChange(e.currentTarget.value.toUpperCase()) + } + /> + + +
+ )} + /> + ( + + + Proposed Effective Date of CHIP SPA + + + field.onChange(date.getTime())} + date={field.value ? new Date(field.value) : undefined} + /> + + + + )} + /> + + )} + defaultValues={{ id: "" }} + attachments={{ + faqLink: "/faq/chip-spa-attachments", + }} + documentPollerArgs={{ + property: "id", + documentChecker: (check) => check.recordExists, + }} + promptOnLeavingForm={{ + header: "Stop form submission?", + body: "All information you've entered on this form will be lost if you leave this page.", + acceptButtonText: "Yes, leave form", + cancelButtonText: "Return to form", + areButtonsReversed: true, + }} + tab={"spas"} + /> +); diff --git a/react-app/src/features/forms/new-submission/Medicaid.tsx b/react-app/src/features/forms/new-submission/Medicaid.tsx new file mode 100644 index 000000000..77ca2f617 --- /dev/null +++ b/react-app/src/features/forms/new-submission/Medicaid.tsx @@ -0,0 +1,92 @@ +import { Link } from "react-router-dom"; +import { + FormControl, + FormField, + FormLabel, + FormItem, + RequiredIndicator, + DatePicker, + FormMessage, + Input, + FAQ_TAB, + SpaIdFormattingDesc, +} from "@/components"; +import { ActionForm } from "@/components/ActionForm"; +import { formSchemas } from "@/formSchemas"; + +export const MedicaidForm = () => ( + ( + <> + ( + +
+ + SPA ID + + + What is my SPA ID? + +
+ + + + field.onChange(e.currentTarget.value.toUpperCase()) + } + /> + + +
+ )} + /> + ( + + + Proposed Effective Date of Medicaid SPA + + + field.onChange(date.getTime())} + date={field.value ? new Date(field.value) : undefined} + dataTestId="proposedEffectiveDate" + /> + + + + )} + /> + + )} + defaultValues={{ id: "" }} + attachments={{ + faqLink: "/faq/medicaid-spa-attachments", + specialInstructions: + "Maximum file size of 80 MB per attachment. You can add multiple files per attachment type except for the CMS Form 179.", + }} + documentPollerArgs={{ + property: "id", + documentChecker: (check) => check.recordExists, + }} + tab={"spas"} + /> +); diff --git a/react-app/src/features/forms/new-submission/chip.test.tsx b/react-app/src/features/forms/new-submission/chip.test.tsx new file mode 100644 index 000000000..fc602b7ee --- /dev/null +++ b/react-app/src/features/forms/new-submission/chip.test.tsx @@ -0,0 +1,82 @@ +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import { describe, test, expect, beforeAll } from "vitest"; +import { ChipForm } from "./Chip"; +import { formSchemas } from "@/formSchemas"; +import { uploadFiles } from "@/utils/test-helpers/uploadFiles"; +import { renderForm } from "@/utils/test-helpers/renderForm"; +import { skipCleanup } from "@/utils/test-helpers/skipCleanup"; + +const upload = uploadFiles<(typeof formSchemas)["new-chip-submission"]>(); + +let container: HTMLElement; + +describe("CHIP SPA", () => { + beforeAll(() => { + skipCleanup(); + + const { container: renderedContainer } = renderForm(); + + container = renderedContainer; + }); + + test("SPA ID", async () => { + const spaIdInput = screen.getByLabelText(/SPA ID/); + const spaIdLabel = screen.getByTestId("spaid-label"); + + // test id validations + // fails if item exists + await userEvent.type(spaIdInput, "MD-00-0000"); + const recordExistsErrorText = screen.getByText( + /According to our records, this SPA ID already exists. Please check the SPA ID and try entering it again./, + ); + expect(recordExistsErrorText).toBeInTheDocument(); + + await userEvent.clear(spaIdInput); + + // fails if state entered is not a valid state + await userEvent.type(spaIdInput, "AK-00-0000"); + const invalidStateErrorText = screen.getByText( + /You can only submit for a state you have access to. If you need to add another state, visit your IDM user profile to request access./, + ); + expect(invalidStateErrorText).toBeInTheDocument(); + + await userEvent.clear(spaIdInput); + + // end of test id validations + await userEvent.type(spaIdInput, "MD-00-0001"); + + expect(spaIdLabel).not.toHaveClass("text-destructive"); + }); + + test("PROPOSED EFFECTIVE DATE OF CHIP SPA", async () => { + await userEvent.click( + screen.getByTestId("proposedEffectiveDate-datepicker"), + ); + await userEvent.keyboard("{Enter}"); + const proposedEffectiveDateLabel = container.querySelector( + '[for="proposedEffectiveDate"]', + ); + + expect(proposedEffectiveDateLabel).not.toHaveClass("text-destructive"); + }); + + test("CURRENT STATE PLAN", async () => { + const currentStatePlanLabel = await upload("currentStatePlan"); + expect(currentStatePlanLabel).not.toHaveClass("text-destructive"); + }); + + test("AMENDED STATE PLAN LANGUAGE", async () => { + const amendedLanguageLabel = await upload("amendedLanguage"); + expect(amendedLanguageLabel).not.toHaveClass("text-destructive"); + }); + + test("COVER LETTER", async () => { + const coverLetterLabel = await upload("coverLetter"); + expect(coverLetterLabel).not.toHaveClass("text-destructive"); + }); + + test("submit button is enabled", () => { + expect(screen.getByTestId("submit-action-form")).toBeEnabled(); + }); +}); diff --git a/react-app/src/features/forms/new-submission/index.ts b/react-app/src/features/forms/new-submission/index.ts new file mode 100644 index 000000000..af2d6be58 --- /dev/null +++ b/react-app/src/features/forms/new-submission/index.ts @@ -0,0 +1,2 @@ +export * from "./Medicaid"; +export * from "./Chip"; diff --git a/react-app/src/features/forms/new-submission/medicaid.test.tsx b/react-app/src/features/forms/new-submission/medicaid.test.tsx new file mode 100644 index 000000000..c83836ff1 --- /dev/null +++ b/react-app/src/features/forms/new-submission/medicaid.test.tsx @@ -0,0 +1,84 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, test, expect, beforeAll } from "vitest"; +import { MedicaidForm } from "./Medicaid"; +import { formSchemas } from "@/formSchemas"; +import { uploadFiles } from "@/utils/test-helpers/uploadFiles"; +import { + skipCleanup, + mockApiRefinements, +} from "@/utils/test-helpers/skipCleanup"; +import { renderForm } from "@/utils/test-helpers/renderForm"; + +const upload = uploadFiles<(typeof formSchemas)["new-medicaid-submission"]>(); + +// use container globally for tests to use same render and let each test fill out inputs +// and at the end validate button is enabled for submit +let container: HTMLElement; + +describe("Medicaid SPA", () => { + beforeAll(() => { + skipCleanup(); + mockApiRefinements(); + + const { container: renderedContainer } = renderForm(); + + container = renderedContainer; + }); + + test("SPA ID", async () => { + const spaIdInput = screen.getByLabelText(/SPA ID/); + const spaIdLabel = screen.getByTestId("spaid-label"); + + // test id validations + // fails if item exists + await userEvent.type(spaIdInput, "MD-00-0000"); + const recordExistsErrorText = screen.getByText( + /According to our records, this SPA ID already exists. Please check the SPA ID and try entering it again./, + ); + expect(recordExistsErrorText).toBeInTheDocument(); + + await userEvent.clear(spaIdInput); + + // fails if state entered is not a valid state + await userEvent.type(spaIdInput, "AK-00-0000"); + const invalidStateErrorText = screen.getByText( + /You can only submit for a state you have access to. If you need to add another state, visit your IDM user profile to request access./, + ); + expect(invalidStateErrorText).toBeInTheDocument(); + + await userEvent.clear(spaIdInput); + + // end of test id validations + await userEvent.type(spaIdInput, "MD-00-0001"); + + expect(spaIdLabel).not.toHaveClass("text-destructive"); + }); + + test("PROPOSED EFFECTIVE DATE OF MEDICAID SPA", async () => { + await userEvent.click( + screen.getByTestId("proposedEffectiveDate-datepicker"), + ); + await userEvent.keyboard("{Enter}"); + const proposedEffectiveDateLabel = container.querySelector( + '[for="proposedEffectiveDate"]', + ); + + expect(proposedEffectiveDateLabel).not.toHaveClass("text-destructive"); + }); + + test("CMS FORM 179", async () => { + const cmsForm179PlanLabel = await upload("cmsForm179"); + expect(cmsForm179PlanLabel).not.toHaveClass("text-destructive"); + }); + + test("SPA PAGES", async () => { + const spaPagesLabel = await upload("spaPages"); + + expect(spaPagesLabel).not.toHaveClass("text-destructive"); + }); + + test("submit button is enabled", async () => { + expect(screen.getByTestId("submit-action-form")).toBeEnabled(); + }); +}); diff --git a/react-app/src/features/submission/app-k/consts.ts b/react-app/src/features/forms/waiver/app-k/StateField.tsx similarity index 64% rename from react-app/src/features/submission/app-k/consts.ts rename to react-app/src/features/forms/waiver/app-k/StateField.tsx index c18851edd..96f0e9204 100644 --- a/react-app/src/features/submission/app-k/consts.ts +++ b/react-app/src/features/forms/waiver/app-k/StateField.tsx @@ -1,7 +1,17 @@ -import { z } from "zod"; -import { zAttachmentOptional, zAttachmentRequired } from "@/utils"; -import { zAppkWaiverNumberSchema } from "@/utils"; -export const OPTIONS_STATE = [ +import { useGetUser } from "@/api"; +import { + FormItem, + FormLabel, + FormMessage, + RequiredIndicator, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components"; + +const OPTIONS_STATE = [ { label: "Alabama", value: "AL" }, { label: "Alaska", value: "AK" }, { label: "American Samoa", value: "AS" }, @@ -58,20 +68,40 @@ export const OPTIONS_STATE = [ { label: "West Virginia", value: "WV" }, { label: "Wisconsin", value: "WI" }, { label: "Wyoming", value: "WY" }, - { label: "ZZ Test Data", value: "ZZ" }, ]; -export const FORM = z.object({ - state: z.string(), - waiverIds: z.array(zAppkWaiverNumberSchema).min(1), - additionalInformation: z.string().max(4000).optional(), - title: z.string().trim().min(1, { message: "Required" }), - attachments: z.object({ - appk: zAttachmentRequired({ min: 1 }), - other: zAttachmentOptional, - }), - proposedEffectiveDate: z.date(), - seaActionType: z.string().default("Amend"), -}); +type StateFieldProps = { + value: string; + onChange: (newValue: string) => void; +}; + +export const StateField = ({ value, onChange }: StateFieldProps) => { + const { data: user } = useGetUser(); + + if (user === undefined) { + return null; + } + + const stateAccess = user.user["custom:state"].split(","); -export type SchemaForm = z.infer; + return ( + + + State + + + + + ); +}; diff --git a/react-app/src/features/forms/waiver/app-k/WaiverIdField.tsx b/react-app/src/features/forms/waiver/app-k/WaiverIdField.tsx new file mode 100644 index 000000000..da77a7a1d --- /dev/null +++ b/react-app/src/features/forms/waiver/app-k/WaiverIdField.tsx @@ -0,0 +1,228 @@ +import { itemExists } from "@/api"; +import { + Button, + FAQ_TAB, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RequiredIndicator, +} from "@/components"; +import { useDebounce } from "@/hooks"; +import { cn, zAppkWaiverNumberSchema } from "@/utils"; +import { useCallback, useEffect, useState } from "react"; +import { + Control, + ControllerProps, + FieldPath, + FieldValues, + useFieldArray, + useFormContext, +} from "react-hook-form"; +import { motion } from "framer-motion"; +import { Loader, Plus, XIcon } from "lucide-react"; +import { Link } from "react-router-dom"; + +export const SlotWaiverId = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + state, + onRemove, +}: { + state: string; + onRemove?: () => void; + className?: string; +}): ControllerProps["render"] => + function Render({ field, fieldState }) { + const debouncedValue = useDebounce(field.value, 750); + const [loading, setLoading] = useState(false); + const context = useFormContext(); + + const onValidate = useCallback( + async (value: string) => { + if (value.length === 0) { + return; + } + + setLoading(true); + + const [, currentWaiverIndex] = field.name.split("."); + const childWaivers = context.getValues("waiverIds") || []; + + const existsInList = childWaivers + .filter( + (_: string, index: number) => index !== Number(currentWaiverIndex), + ) + .includes(value); + + if (existsInList) { + return context.setError(field.name, { + message: "Waiver ID is already included in this Appendix K", + }); + } + + const parsed = await zAppkWaiverNumberSchema.safeParseAsync(value); + + if (parsed.success === false) { + const [err] = parsed.error.errors; + return context.setError(field.name, err); + } + + const exists = await itemExists(`${state}-${value}`); + + if (exists) { + return context.setError(field.name, { + message: + "According to our records, this Waiver Amendment Number already exists. Please check the Waiver Amendment Number and try entering it again.", + }); + } + + context.clearErrors(field.name); + }, + [field.name, context], + ); + + useEffect(() => { + onValidate(debouncedValue).then(() => setLoading(false)); + }, [debouncedValue]); + + return ( + +
+
+

{state} -

+ { + field.onChange({ + target: { value: e.target.value.toUpperCase() }, + }); + }} + value={field.value} + /> + {loading && ( + + + + )} +
+ {onRemove ? ( + + ) : ( +
+ +
+ )} +
+ +
+ ); + }; + +type WaiverIdFieldProps = { + control: Control; + name: string; + state: string | undefined; +}; + +export const WaiverIdField = ({ control, name, state }: WaiverIdFieldProps) => { + const fieldArr = useFieldArray({ + control: control, + name: name, + shouldUnregister: true, + }); + + if (state === undefined) { + return null; + } + + return ( + <> +
+
+ + Waiver IDs + + + What is my Appendix K ID? + +
+
+ Format is 1111.R22. + 33 or 11111.R22. + 33 where: +
+
    +
  • + 1111 or 11111 is the four- or + five-digit waiver initial number +
  • +
  • + R22 is the renewal number (Use R00{" "} + for waivers without renewals.) +
  • +
  • + 33 is the Appendix K amendment number (The last two + digits relating to the number of amendments in the waiver cycle + start with “01” and ascend.) +
  • +
+
+ +
+ + + The first ID entered will be used to track the submission on the + OneMAC dashboard. + {" "} + You will be able to find other waiver IDs entered below by searching + for the first waiver ID. + {" "} +
+ {fieldArr.fields.map((field, index) => { + return ( +
+ fieldArr.remove(index) }), + state: state, + })} + /> +
+ ); + })} + +
+
+ + ); +}; diff --git a/react-app/src/features/forms/waiver/app-k/index.tsx b/react-app/src/features/forms/waiver/app-k/index.tsx new file mode 100644 index 000000000..843eaee20 --- /dev/null +++ b/react-app/src/features/forms/waiver/app-k/index.tsx @@ -0,0 +1,104 @@ +import { + ActionForm, + DatePicker, + FormControl, + FormField, + FormIntroText, + FormItem, + FormLabel, + FormMessage, + RequiredIndicator, + Textarea, +} from "@/components"; +import { Authority, appkSchema } from "shared-types"; +import { WaiverIdField } from "./WaiverIdField"; +import { StateField } from "./StateField"; + +export const AppKAmendmentForm = () => ( + ( + <> +
+ +

+ + If your Appendix K submission is for more than one waiver number, + please enter one of the applicable waiver numbers. You do not need + to create multiple submissions. + +

+
+ ( + + + Amendment Title + +