diff --git a/lib/local-constructs/clamav-scanning/package-lock.json b/lib/local-constructs/clamav-scanning/package-lock.json index e63172bef4..8bb24ffc4a 100644 --- a/lib/local-constructs/clamav-scanning/package-lock.json +++ b/lib/local-constructs/clamav-scanning/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.679.0", "@types/mime-types": "^2.1.4", - "file-type": "^19.4.1", + "file-type": "^19.6.0", "mime-types": "^2.1.35", "pino": "^9.4.0" }, @@ -2167,13 +2167,13 @@ } }, "node_modules/file-type": { - "version": "19.4.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.4.1.tgz", - "integrity": "sha512-RuWzwF2L9tCHS76KR/Mdh+DwJZcFCzrhrPXpOw6MlEfl/o31fjpTikzcKlYuyeV7e7ftdCGVJTNOCzkYD/aLbw==", + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.6.0.tgz", + "integrity": "sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==", "license": "MIT", "dependencies": { "get-stream": "^9.0.1", - "strtok3": "^8.1.0", + "strtok3": "^9.0.1", "token-types": "^6.0.0", "uint8array-extras": "^1.3.0" }, @@ -2259,9 +2259,9 @@ } }, "node_modules/peek-readable": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.2.0.tgz", - "integrity": "sha512-U94a+eXHzct7vAd19GH3UQ2dH4Satbng0MyYTMaQatL0pvYYL5CTPR25HBhKtecl+4bfu1/i3vC6k0hydO5Vcw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.3.1.tgz", + "integrity": "sha512-GVlENSDW6KHaXcd9zkZltB7tCLosKB/4Hg0fqBJkAoBgYG2Tn1xtMgXtSUuMU9AK/gCm/tTdT8mgAeF4YNeeqw==", "license": "MIT", "engines": { "node": ">=14.16" @@ -2407,13 +2407,13 @@ "license": "MIT" }, "node_modules/strtok3": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-8.1.0.tgz", - "integrity": "sha512-ExzDvHYPj6F6QkSNe/JxSlBxTh3OrI6wrAIz53ulxo1c4hBJ1bT9C/JrAthEKHWG9riVH3Xzg7B03Oxty6S2Lw==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-9.0.1.tgz", + "integrity": "sha512-ERPW+XkvX9W2A+ov07iy+ZFJpVdik04GhDA4eVogiG9hpC97Kem2iucyzhFxbFRvQ5o2UckFtKZdp1hkGvnrEw==", "license": "MIT", "dependencies": { "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.1.4" + "peek-readable": "^5.3.1" }, "engines": { "node": ">=16" diff --git a/lib/local-constructs/clamav-scanning/package.json b/lib/local-constructs/clamav-scanning/package.json index 18311a8717..a0d6bb6841 100644 --- a/lib/local-constructs/clamav-scanning/package.json +++ b/lib/local-constructs/clamav-scanning/package.json @@ -21,7 +21,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.679.0", "@types/mime-types": "^2.1.4", - "file-type": "^19.4.1", + "file-type": "^19.6.0", "mime-types": "^2.1.35", "pino": "^9.4.0" } diff --git a/lib/stacks/email.ts b/lib/stacks/email.ts index 70deb019b2..a5ca78c1b7 100644 --- a/lib/stacks/email.ts +++ b/lib/stacks/email.ts @@ -222,7 +222,7 @@ export class Email extends cdk.NestedStack { alarm.addAlarmAction(new cdk.aws_cloudwatch_actions.SnsAction(alarmTopic)); - new CfnEventSourceMapping(this, "SinkEmailTrigger", { + new CfnEventSourceMapping(this, "SinkSESTrigger", { batchSize: 1, enabled: true, selfManagedEventSource: { diff --git a/react-app/src/components/Inputs/button.test.tsx b/react-app/src/components/Inputs/button.test.tsx new file mode 100644 index 0000000000..8dfb4d8db5 --- /dev/null +++ b/react-app/src/components/Inputs/button.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Button } from './button'; + +describe('Button Component', () => { + it('renders children correctly', () => { + render(Click Me); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('applies default variant and size classes', () => { + render(Click Me); + const button = screen.getByText('Click Me'); + expect(button).toHaveClass('bg-primary text-slate-50'); + }); + + it('applies the correct variant and size classes when props are set', () => { + render(Delete); + const button = screen.getByText('Delete'); + expect(button).toHaveClass('bg-destructive'); + expect(button).toHaveClass('h-11 px-8'); + }); + + it('shows a loading spinner when loading prop is true', () => { + const { container } = render(Loading); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('disables the button when the disabled prop is true', () => { + render(Disabled); + const button = screen.getByText('Disabled'); + expect(button).toBeDisabled(); + }); +}); + diff --git a/react-app/src/components/Inputs/checkbox.test.tsx b/react-app/src/components/Inputs/checkbox.test.tsx new file mode 100644 index 0000000000..b966c22f10 --- /dev/null +++ b/react-app/src/components/Inputs/checkbox.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Checkbox, CheckboxGroup } from './checkbox'; +import React from 'react'; + +describe('Checkbox Component', () => { + it('renders correctly with a label', () => { + render(); + expect(screen.getByLabelText('Accept Terms')).toBeInTheDocument(); + }); + + it('renders correctly with a styledLabel', () => { + render(Styled Label} />); + expect(screen.getByText('Styled Label')).toBeInTheDocument(); + }); + + it('displays description if provided', () => { + render(); + expect(screen.getByText('Please accept terms and conditions')).toBeInTheDocument(); + }); + + it('applies custom class names', () => { + render(); + const checkbox = screen.getByLabelText('Terms'); + expect(checkbox).toHaveClass('custom-class'); + }); + + it('toggles checked state when clicked', () => { + const handleChange = vi.fn(); + render(); + const checkbox = screen.getByLabelText('Terms'); + + // Simulate checking the checkbox + fireEvent.click(checkbox); + expect(handleChange).toHaveBeenCalledWith(true); + + // Simulate unchecking the checkbox + fireEvent.click(checkbox); + expect(handleChange).toHaveBeenCalledWith(false); + }); +}); + +describe('CheckboxGroup Component', () => { + it('renders all options correctly', () => { + const options = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { label: 'Option 3', value: 'opt3' }, + ]; + render( {}} options={options} />); + + options.forEach((opt) => { + expect(screen.getByLabelText(opt.label)).toBeInTheDocument(); + }); + }); + + it('checks the correct checkboxes based on initial value', () => { + const options = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + ]; + render( {}} options={options} />); + + expect(screen.getByLabelText('Option 1')).toBeChecked(); + expect(screen.getByLabelText('Option 2')).not.toBeChecked(); + }); + + it('calls onChange with the correct values when a checkbox is toggled', () => { + const options = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + ]; + const handleChange = vi.fn(); + + // State wrapper for CheckboxGroup to handle the "value" prop directly + const CheckboxGroupWrapper = () => { + const [value, setValue] = React.useState([]); + return ( + { + setValue(newValue); // Update internal state + handleChange(newValue); // Call the spy + }} + options={options} + /> + ); + }; + + // Render the wrapper component + render(); + + // 1. Check "Option 1" + fireEvent.click(screen.getByLabelText('Option 1')); + expect(handleChange).toHaveBeenNthCalledWith(1, ['opt1']); + + // 2. Log output and then Check "Option 2" + fireEvent.click(screen.getByLabelText('Option 2')); + expect(handleChange).toHaveBeenNthCalledWith(2, ['opt1', 'opt2']); // Expect both checked + + // 3. Uncheck "Option 1" + fireEvent.click(screen.getByLabelText('Option 1')); + expect(handleChange).toHaveBeenNthCalledWith(3, ['opt2']); + }); +}); diff --git a/react-app/src/components/Inputs/switch.test.tsx b/react-app/src/components/Inputs/switch.test.tsx new file mode 100644 index 0000000000..ae4c1a8bfc --- /dev/null +++ b/react-app/src/components/Inputs/switch.test.tsx @@ -0,0 +1,23 @@ +import { Switch } from "./switch"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +describe("Switch", () => { + it("should render", () => { + render(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("should call onChange and onCheckedChange when state changes", () => { + const onChange = vi.fn(); + const onCheckedChange = vi.fn(); + + render(); + + const switchElement = screen.getByRole("switch"); + fireEvent.click(switchElement); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onCheckedChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/react-app/src/components/Inputs/switch.tsx b/react-app/src/components/Inputs/switch.tsx index 98271445c5..4cdcabb759 100644 --- a/react-app/src/components/Inputs/switch.tsx +++ b/react-app/src/components/Inputs/switch.tsx @@ -1,4 +1,3 @@ - import * as React from "react"; import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "@/utils"; @@ -11,7 +10,7 @@ const Switch = React.forwardRef< { @@ -22,7 +21,7 @@ const Switch = React.forwardRef< > diff --git a/react-app/src/components/Inputs/upload.test.tsx b/react-app/src/components/Inputs/upload.test.tsx new file mode 100644 index 0000000000..3692468ce5 --- /dev/null +++ b/react-app/src/components/Inputs/upload.test.tsx @@ -0,0 +1,120 @@ +import { Upload } from "./upload"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock global fetch +global.fetch = vi.fn(); + +// Mock AWS Amplify API +vi.mock("aws-amplify", () => ({ + API: { + post: vi.fn(), + }, +})); + +const defaultProps = { + dataTestId: "upload-component", + files: [], + setFiles: vi.fn(), + setErrorMessage: vi.fn(), +}; + +describe("Upload", () => { + const testIdSuffix = "upload"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders correctly with initial props", () => { + render(); + expect(screen.getByTestId(`${defaultProps.dataTestId}-${testIdSuffix}`)).toBeInTheDocument(); + expect(screen.queryByText("Uploading...")).not.toBeInTheDocument(); + }); + + it("uploads files correctly", async () => { + render(); + + const dropzone = screen.getByRole("presentation"); + const file = new File(["file contents"], "file.pdf", { type: "application/pdf" }); + + Object.defineProperty(dropzone, "files", { + value: [file], + writable: false, + }); + + fireEvent.drop(dropzone); + + await waitFor(() => { + expect(defaultProps.setFiles).toHaveBeenCalledWith([ + { + bucket: "hello", + key: "world", + filename: "file.pdf", + title: "file", + uploadDate: expect.any(Number), // Since it's a timestamp + }, + ]); + }); + }); + + it("displays an error for unsupported file types", async () => { + render(); + + const dropzone = screen.getByRole("presentation"); + const file = new File(["file contents"], "file.exe", { type: "application/x-msdownload" }); + + Object.defineProperty(dropzone, "files", { + value: [file], + writable: false, + }); + + fireEvent.drop(dropzone); + + await waitFor(() => { + expect( + screen.getByText("Selected file(s) is too large or of a disallowed file type."), + ).toBeInTheDocument(); + }); + }); + + it("does not display the dropzone when uploading", async () => { + render(); + + const dropzone = screen.getByTestId("upload-component-upload"); + const file = new File(["file contents"], "file.pdf", { type: "application/pdf" }); + + Object.defineProperty(dropzone, "files", { + value: [file], + writable: false, + }); + + fireEvent.drop(dropzone); + + await waitFor(() => { + expect(screen.getByTestId("upload-component-upload")).not.toBeVisible(); + }); + }); + + it("handles file removal on event", () => { + const mockSetFiles = vi.fn(); + const files = [ + { filename: "file-1.txt" }, + { filename: "file-to-remove.txt" }, + { filename: "file-2.txt" }, + ]; + + // Render the component with necessary props + render(); + + // Simulate the event (e.g., a click on the remove button) + const removeButton = screen.getByTestId("upload-component-remove-file-file-to-remove.txt"); // Ensure your component uses this testId + fireEvent.click(removeButton); + + // Assert that setFiles was called with the updated files array + expect(mockSetFiles).toHaveBeenCalledWith([ + { filename: "file-1.txt" }, + { filename: "file-2.txt" }, + ]); + }); +}); diff --git a/react-app/src/components/Inputs/upload.tsx b/react-app/src/components/Inputs/upload.tsx index 641a3c22a5..b61eedc62e 100644 --- a/react-app/src/components/Inputs/upload.tsx +++ b/react-app/src/components/Inputs/upload.tsx @@ -8,11 +8,7 @@ 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"; +import { extractBucketAndKeyFromUrl, getPresignedUrl, uploadToS3 } from "./upload.utilities"; type Attachment = z.infer; @@ -23,12 +19,30 @@ type UploadProps = { dataTestId?: string; }; -export const Upload = ({ - maxFiles, - files, - setFiles, - dataTestId, -}: UploadProps) => { +/** + * Upload component for handling file uploads with drag-and-drop functionality. + * + * @param {Object} props - The properties object. + * @param {number} props.maxFiles - The maximum number of files that can be uploaded. + * @param {Attachment[]} props.files - The current list of uploaded files. + * @param {Function} props.setFiles - Function to update the list of uploaded files. + * @param {string} props.dataTestId - The data-testid attribute for testing purposes. + * + * @returns {JSX.Element} The rendered Upload component. + * + * @component + * @example + * const [files, setFiles] = useState([]); + * return ( + * + * ); + */ +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(); @@ -36,9 +50,7 @@ export const Upload = ({ const onDrop = useCallback( async (acceptedFiles: File[], fileRejections: FileRejection[]) => { if (fileRejections.length > 0) { - setErrorMessage( - "Selected file(s) is too large or of a disallowed file type.", - ); + setErrorMessage("Selected file(s) is too large or of a disallowed file type."); } else { setErrorMessage(null); setIsUploading(true); // Set uploading to true @@ -110,6 +122,7 @@ export const Upload = ({ }} variant="ghost" className="p-0 h-0" + data-testid={`${dataTestId}-remove-file-${file.filename}`} > @@ -130,9 +143,7 @@ export const Upload = ({ > Drag file here or{" "} - - choose from folder - + choose from folder Drag file here or choose from folder diff --git a/react-app/src/components/Inputs/upload.utilities.test.ts b/react-app/src/components/Inputs/upload.utilities.test.ts new file mode 100644 index 0000000000..f0bb8bedd9 --- /dev/null +++ b/react-app/src/components/Inputs/upload.utilities.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as utilities from "@/components/Inputs/upload.utilities"; + +describe("getPresignedUrl", () => { + beforeEach(() => { + vi.unmock("@/components/Inputs/upload.utilities"); + vi.mock("aws-amplify", () => ({ + API: { + post: vi.fn(async () => ({ url: "https://example.com/test-url" })), + }, + })); + }); + + it("gets a presigned URL from the API", async () => { + const fileName = "file.pdf"; + const url = await utilities.getPresignedUrl(fileName); + expect(url).toBe("https://example.com/test-url"); + }); +}); + +describe("uploadToS3", () => { + beforeEach(() => { + vi.unmock("@/components/Inputs/upload.utilities"); + }); + + it("uploads a file to S3", async () => { + const file = new File(["file contents"], "file.pdf", { type: "application/pdf" }); + const url = "https://s3.us-east-1.amazonaws.com/hello/world"; + + // Mock fetch + const mockFetch = vi.fn().mockResolvedValue({}); + global.fetch = mockFetch; + + // Call the function + await utilities.uploadToS3(file, url); + + // Assertion + expect(mockFetch).toHaveBeenCalledWith(url, { + body: file, + method: "PUT", + }); + }); + + describe("extractBucketAndKeyFromUrl", () => { + beforeEach(() => { + vi.unmock("@/components/Inputs/upload.utilities"); + }); + + it("extracts the bucket and key from a URL", () => { + const url = "https://hello.s3.us-east-1.amazonaws.com/world"; + const { bucket, key } = utilities.extractBucketAndKeyFromUrl(url); + + expect(bucket).toBe("hello"); + expect(key).toBe("world"); + }); + + it("logs an error for an invalid URL", () => { + const url = "invalid-url"; + const consoleSpy = vi.spyOn(console, "error"); + + const { bucket, key } = utilities.extractBucketAndKeyFromUrl(url); + + expect(bucket).toBeNull(); + expect(key).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith("Invalid URL format:", expect.any(Error)); + }); + }); +}); diff --git a/react-app/src/components/Layout/index.tsx b/react-app/src/components/Layout/index.tsx index 75df2d8d4e..713839c6fd 100644 --- a/react-app/src/components/Layout/index.tsx +++ b/react-app/src/components/Layout/index.tsx @@ -85,7 +85,7 @@ const UserDropdownMenu = () => { My Account @@ -302,7 +302,7 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { {isOpen && ( - + {links.map((link) => ( { > Sign In - + Register > diff --git a/react-app/src/features/forms/legacy-shared-components.tsx b/react-app/src/features/forms/legacy-shared-components.tsx deleted file mode 100644 index eab5562513..0000000000 --- a/react-app/src/features/forms/legacy-shared-components.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { banner } from "@/components"; -import { getFormOrigin } from "@/utils"; -import { useEffect } from "react"; -import { SubmitHandler, useFormContext } from "react-hook-form"; -import { - ActionFunctionArgs, - useActionData, - useLocation, - useNavigate, - useSubmit, -} from "react-router-dom"; -import { Authority } from "shared-types"; - -// ONLY used by temp extension legacy-page.tsx, will be refactored out - -export const useSubmitForm = () => { - const methods = useFormContext(); - const submit = useSubmit(); - const location = useLocation(); - - const validSubmission: SubmitHandler = (data) => { - const formData = new FormData(); - // Append all other data - for (const key in data) { - if (key !== "attachments" && data[key] !== undefined) { - formData.append(key, data[key]); - } - } - const attachments = - Object.keys(filterUndefinedValues(data.attachments)).length > 0 - ? data.attachments - : {}; - for (const key in attachments) { - attachments[key]?.forEach((file: any, index: number) => { - formData.append(`attachments.${key}.${index}`, file as any); - }); - } - - submit(formData, { - method: "post", - encType: "multipart/form-data", - state: location.state, - }); - }; - - return { - handleSubmit: methods.handleSubmit(validSubmission), - formMethods: methods, - }; -}; - -export const useIntakePackage = () => { - const methods = useFormContext(); - const submit = useSubmit(); - const location = useLocation(); - - const validSubmission: SubmitHandler = (data) => { - submit(data, { - method: "post", - encType: "application/json", - state: location.state, - }); - }; - - return { - handleSubmit: methods.handleSubmit(validSubmission), - formMethods: methods, - }; -}; - -export const useDisplaySubmissionAlert = (header: string, body: string) => { - const data = useActionData() as ActionFunctionReturnType & { isTe?: boolean }; - const navigate = useNavigate(); - const location = useLocation(); - - return useEffect(() => { - if (data?.submitted) { - if (location.pathname.endsWith("/update-id")) { - banner({ - header, - body, - variant: "success", - pathnameToDisplayOn: "/dashboard", - }); - return navigate("/dashboard"); - } - - const formOrigin = getFormOrigin({ authority: Authority["1915c"] }); - - banner({ - header, - body, - variant: "success", - pathnameToDisplayOn: formOrigin.pathname, - }); - - navigate(formOrigin); - } - - if (!data?.submitted && data?.error) { - window.scrollTo(0, 0); - return banner({ - header: "An unexpected error has occurred:", - body: - data.error instanceof Error ? data.error.message : String(data.error), - variant: "destructive", - pathnameToDisplayOn: window.location.pathname, - }); - } - }, [data, navigate, location, body, header]); -}; - -// Utility Functions -const filterUndefinedValues = (obj: Record) => { - if (obj) { - return Object.fromEntries( - Object.entries(obj).filter(([, value]) => value !== undefined), - ); - } - return {}; -}; - -// Types -export type ActionFunction = ( - args: ActionFunctionArgs, -) => Promise<{ submitted: boolean; error?: Error | unknown }>; -export type ActionFunctionReturnType = Awaited>; diff --git a/react-app/src/features/forms/post-submission/post-submission-forms.tsx b/react-app/src/features/forms/post-submission/post-submission-forms.tsx index 6a217fac6c..907076d872 100644 --- a/react-app/src/features/forms/post-submission/post-submission-forms.tsx +++ b/react-app/src/features/forms/post-submission/post-submission-forms.tsx @@ -1,4 +1,5 @@ -import { LoaderFunction, useParams } from "react-router-dom"; +import { LoaderFunction, Navigate, useParams } from "react-router-dom"; +import { Action, AuthorityUnion } from "shared-types"; import { WithdrawPackageAction, WithdrawPackageActionChip, @@ -9,39 +10,44 @@ import { queryClient } from "../../../router"; import { getItem } from "@/api"; import { WithdrawRaiForm } from "./withdraw-rai"; import { DisableWithdrawRaiForm, EnableWithdrawRaiForm } from "./toggle-withdraw-rai"; +import { TemporaryExtensionForm } from "../waiver/temporary-extension"; import { UploadSubsequentDocuments } from "./upload-subsequent-documents"; -// the keys will relate to this part of the route /actions/{key of postSubmissionForms}/authority/id -export const postSubmissionForms: Record React.ReactNode>> = { +export const postSubmissionForms: Partial< + Record React.ReactNode>>> +> = { "withdraw-package": { - ["1915(b)"]: WithdrawPackageActionWaiver, - ["1915(c)"]: WithdrawPackageActionWaiver, - ["Medicaid SPA"]: WithdrawPackageAction, - ["CHIP SPA"]: WithdrawPackageActionChip, + "1915(b)": WithdrawPackageActionWaiver, + "1915(c)": WithdrawPackageActionWaiver, + "Medicaid SPA": WithdrawPackageAction, + "CHIP SPA": WithdrawPackageActionChip, }, "respond-to-rai": { - ["1915(b)"]: RespondToRaiWaiver, - ["1915(c)"]: RespondToRaiWaiver, - ["Medicaid SPA"]: RespondToRaiMedicaid, - ["CHIP SPA"]: RespondToRaiChip, + "1915(b)": RespondToRaiWaiver, + "1915(c)": RespondToRaiWaiver, + "Medicaid SPA": RespondToRaiMedicaid, + "CHIP SPA": RespondToRaiChip, }, "withdraw-rai": { - ["1915(b)"]: WithdrawRaiForm, - ["1915(c)"]: WithdrawRaiForm, - ["Medicaid SPA"]: WithdrawRaiForm, - ["CHIP SPA"]: WithdrawRaiForm, + "1915(b)": WithdrawRaiForm, + "1915(c)": WithdrawRaiForm, + "Medicaid SPA": WithdrawRaiForm, + "CHIP SPA": WithdrawRaiForm, }, "enable-rai-withdraw": { - ["1915(b)"]: EnableWithdrawRaiForm, - ["1915(c)"]: EnableWithdrawRaiForm, - ["Medicaid SPA"]: EnableWithdrawRaiForm, - ["CHIP SPA"]: EnableWithdrawRaiForm, + "1915(b)": EnableWithdrawRaiForm, + "1915(c)": EnableWithdrawRaiForm, + "Medicaid SPA": EnableWithdrawRaiForm, + "CHIP SPA": EnableWithdrawRaiForm, }, "disable-rai-withdraw": { - ["1915(b)"]: DisableWithdrawRaiForm, - ["1915(c)"]: DisableWithdrawRaiForm, - ["Medicaid SPA"]: DisableWithdrawRaiForm, - ["CHIP SPA"]: DisableWithdrawRaiForm, + "1915(b)": DisableWithdrawRaiForm, + "1915(c)": DisableWithdrawRaiForm, + "Medicaid SPA": DisableWithdrawRaiForm, + "CHIP SPA": DisableWithdrawRaiForm, + }, + "temporary-extension": { + "1915(b)": TemporaryExtensionForm, }, "upload-subsequent-documents": { ["1915(b)"]: UploadSubsequentDocuments, @@ -52,8 +58,12 @@ export const postSubmissionForms: Record React.Reac }; export const PostSubmissionWrapper = () => { - const { type, authority } = useParams(); - const PostSubmissionForm = postSubmissionForms[type][authority]; + const { type, authority } = useParams<{ authority: AuthorityUnion; type: string }>(); + const PostSubmissionForm = postSubmissionForms?.[type]?.[authority]; + + if (PostSubmissionForm === undefined) { + return ; + } return ; }; diff --git a/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx b/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx index fbaf997c8e..122360d474 100644 --- a/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx +++ b/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx @@ -110,7 +110,7 @@ export const UploadSubsequentDocuments = () => { Provide revised or additional documentation for your submission. 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 follow up buy email. + If CMS needs any additional information, they will follow up by email. `} fields={PackageSection} promptPreSubmission={{ diff --git a/react-app/src/features/forms/waiver/temporary-extension/index.tsx b/react-app/src/features/forms/waiver/temporary-extension/index.tsx index 790a2692a6..73535ddf7a 100644 --- a/react-app/src/features/forms/waiver/temporary-extension/index.tsx +++ b/react-app/src/features/forms/waiver/temporary-extension/index.tsx @@ -14,133 +14,150 @@ import { SelectTrigger, SelectValue, } from "@/components"; -import { Link } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { formSchemas } from "@/formSchemas"; import { FAQ_TAB } from "@/router"; +import { useGetItem } from "@/api"; import { getFAQLinkForAttachments } from "../../faqLinks"; -export const TemporaryExtensionForm = () => ( - ( - <> - ( - - - Temporary Extension Type{" "} - - - - - - - - - - 1915(b) - 1915(c) - - - - +export const TemporaryExtensionForm = () => { + const { id: waiverId } = useParams<{ id: string }>(); + const { data: submission } = useGetItem(waiverId, { enabled: waiverId !== undefined }); + + return ( + ( + <> + {submission ? ( + + Approved Initial or Renewal Waiver Number + {submission._source.authority} + + ) : ( + ( + + + Temporary Extension Type{" "} + + + + + + + + + + 1915(b) + 1915(c) + + + + + )} + /> )} - /> - { - return ( + {waiverId && submission ? ( + + Temporary Extension Type + {waiverId} + + ) : ( + { + return ( + { + await form.trigger("ids.validAuthority.authority"); + }} + > + + + Approved Initial or Renewal Waiver Number + {" "} + + + + Enter the existing waiver number in the format it was approved, using a dash + after the two character state abbreviation. + + + field.onChange(e.currentTarget.value.toUpperCase())} + /> + + + + ); + }} + /> + )} + ( { await form.trigger("ids.validAuthority.authority"); }} > - + - Approved Initial or Renewal Waiver Number - {" "} - + Temporary Extension Request Number + + + + What is my Temporary Extension Request Number? + - - Enter the existing waiver number in the format it was - approved, using a dash after the two character state - abbreviation. + + Must use a waiver extension request number with the format SS-####.R##.TE## or + SS-#####.R##.TE## - field.onChange(e.currentTarget.value.toUpperCase()) - } + onChange={(e) => field.onChange(e.currentTarget.value.toUpperCase())} /> - ); - }} - /> - ( - { - await form.trigger("ids.validAuthority.authority"); - }} - > - - - Temporary Extension Request Number - - - - What is my Temporary Extension Request Number? - - - - Must use a waiver extension request number with the format - SS-####.R##.TE## or SS-#####.R##.TE## - - - - field.onChange(e.currentTarget.value.toUpperCase()) - } - /> - - - - )} - /> - > - )} - attachments={{ - faqLink: getFAQLinkForAttachments("temporary-extension"), - }} - documentPollerArgs={{ - property: (data) => data.id, - documentChecker: (check) => check.recordExists, - }} - bannerPostSubmission={{ - header: "Temporary extension request submitted", - body: "Your submission has been received.", - variant: "success", - }} - /> -); + )} + /> + > + )} + attachments={{ + faqLink: getFAQLinkForAttachments("temporary-extension"), + }} + documentPollerArgs={{ + property: (data) => data.id, + documentChecker: (check) => check.recordExists, + }} + bannerPostSubmission={{ + header: "Temporary extension request submitted", + body: "Your submission has been received.", + variant: "success", + }} + /> + ); +}; diff --git a/react-app/src/features/forms/waiver/temporary-extension/temporary-extension.test.tsx b/react-app/src/features/forms/waiver/temporary-extension/temporary-extension.test.tsx index 6fd30cfd43..c47ede106e 100644 --- a/react-app/src/features/forms/waiver/temporary-extension/temporary-extension.test.tsx +++ b/react-app/src/features/forms/waiver/temporary-extension/temporary-extension.test.tsx @@ -1,25 +1,40 @@ -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { uploadFiles } from "@/utils/test-helpers/uploadFiles"; import { formSchemas } from "@/formSchemas"; import { TemporaryExtensionForm } from "."; -import { renderForm } from "@/utils/test-helpers/renderForm"; -import { skipCleanup } from "@/utils/test-helpers/skipCleanup"; +import { renderForm, renderFormWithPackageSection } from "@/utils/test-helpers/renderForm"; +import { mockApiRefinements, skipCleanup } from "@/utils/test-helpers/skipCleanup"; +import * as api from "@/api"; const upload = uploadFiles<(typeof formSchemas)["temporary-extension"]>(); -// 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 - describe("Temporary Extension", () => { beforeAll(() => { - skipCleanup(); + mockApiRefinements(); + }); - renderForm(); + test("EXISTING WAIVER ID", () => { + renderFormWithPackageSection(); + + // "Medicaid SPA" comes from `useGetItem` in testing/setup.ts + const waiverNumberLabel = screen.getByText("Medicaid SPA"); + const existentIdLabel = screen.getByText(/Temporary Extension Type/); + + expect(waiverNumberLabel).toBeInTheDocument(); + expect(existentIdLabel).toBeInTheDocument(); }); test("TEMPORARY EXTENSION TYPE", async () => { + // mock `useGetItem` to signal there's temp-ext submission to render + // @ts-ignore - expects the _whole_ React-Query object (annoying to type out) + vi.spyOn(api, "useGetItem").mockImplementation(() => ({ data: undefined })); + // render temp-ext form with no route params + renderForm(); + // enable render cleanup here + skipCleanup(); + const teTypeDropdown = screen.getByRole("combobox"); await userEvent.click(teTypeDropdown); @@ -34,9 +49,7 @@ describe("Temporary Extension", () => { }); test("APPROVED INITIAL OR RENEWAL WAIVER NUMBER", async () => { - const waiverNumberInput = screen.getByLabelText( - /Approved Initial or Renewal Waiver Number/, - ); + const waiverNumberInput = screen.getByLabelText(/Approved Initial or Renewal Waiver Number/); const waiverNumberLabel = screen.getByTestId("waiverNumber-label"); // test record does not exist error occurs @@ -61,9 +74,7 @@ describe("Temporary Extension", () => { }); test("TEMPORARY EXTENSION REQUEST NUMBER", async () => { - const requestNumberInput = screen.getByLabelText( - /Temporary Extension Request Number/, - ); + const requestNumberInput = screen.getByLabelText(/Temporary Extension Request Number/); const requestNumberLabel = screen.getByTestId("requestNumber-label"); // invalid TE request format diff --git a/react-app/testing/setup.ts b/react-app/testing/setup.ts index 1acec17cdd..0c52164e82 100644 --- a/react-app/testing/setup.ts +++ b/react-app/testing/setup.ts @@ -47,10 +47,7 @@ beforeAll(() => { return idsThatAreApproved.includes(id); }), canBeRenewedOrAmended: vi.fn(async (id: string) => { - const idsThatCanBeRenewedOrAmended = [ - "MD-0000.R00.00", - "MD-0002.R00.00", - ]; + const idsThatCanBeRenewedOrAmended = ["MD-0000.R00.00", "MD-0002.R00.00"]; return idsThatCanBeRenewedOrAmended.includes(id); }), @@ -71,14 +68,14 @@ beforeAll(() => { _source: { _id: "12345", changelog: [{ _source: { event: "new-medicaid-submission" } }], + authority: "Medicaid SPA", }, }, }), useGetUser: () => ({ data: { user: { - "custom:cms-roles": - "onemac-micro-statesubmitter,onemac-micro-super", + "custom:cms-roles": "onemac-micro-statesubmitter,onemac-micro-super", }, }, }),
Drag file here or{" "} - - choose from folder - + choose from folder
My Account
Approved Initial or Renewal Waiver Number
{submission._source.authority}
Temporary Extension Type
{waiverId}