From d90a5d8b7bc429b575b7e7d5ea151c30b75b5bb0 Mon Sep 17 00:00:00 2001 From: Asharon Baltazar Date: Wed, 7 Aug 2024 09:34:19 -0400 Subject: [PATCH 1/5] feat: create `userPrompt` observer and tests --- .../components/ConfirmationDialog/index.tsx | 2 + .../ConfirmationDialog/userPrompt.test.tsx | 131 ++++++++++++++++++ .../ConfirmationDialog/userPrompt.tsx | 106 ++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 react-app/src/components/ConfirmationDialog/index.tsx create mode 100644 react-app/src/components/ConfirmationDialog/userPrompt.test.tsx create mode 100644 react-app/src/components/ConfirmationDialog/userPrompt.tsx diff --git a/react-app/src/components/ConfirmationDialog/index.tsx b/react-app/src/components/ConfirmationDialog/index.tsx new file mode 100644 index 0000000000..33de8c19b6 --- /dev/null +++ b/react-app/src/components/ConfirmationDialog/index.tsx @@ -0,0 +1,2 @@ +export * from "./ConfirmationDialog"; +export * from "./userPrompt"; diff --git a/react-app/src/components/ConfirmationDialog/userPrompt.test.tsx b/react-app/src/components/ConfirmationDialog/userPrompt.test.tsx new file mode 100644 index 0000000000..2669c266fa --- /dev/null +++ b/react-app/src/components/ConfirmationDialog/userPrompt.test.tsx @@ -0,0 +1,131 @@ +import { act, render } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { UserPrompt, userPrompt } from "./userPrompt"; +import userEvent from "@testing-library/user-event"; + +describe("userPrompt", () => { + test("Hidden on initial render", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + test("Create a simple user prompt", () => { + const { getByTestId } = render(); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing", + onAccept: vi.fn(), + }); + }); + + expect(getByTestId("dialog-content")).toBeInTheDocument(); + }); + + test("User prompt header matches", () => { + const { getByTestId } = render(); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing body", + onAccept: vi.fn(), + }); + }); + + expect(getByTestId("dialog-title")).toHaveTextContent("Testing"); + }); + + test("User prompt body matches", () => { + const { getByTestId } = render(); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing body", + onAccept: vi.fn(), + }); + }); + + expect(getByTestId("dialog-body")).toHaveTextContent("testing body"); + }); + + test("Clicking Accept successfully closes the user prompt", async () => { + const user = userEvent.setup(); + + const { container, getByTestId } = render(); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing body", + onAccept: vi.fn(), + }); + }); + + user.click(getByTestId("dialog-accept")); + + expect(container).toBeEmptyDOMElement(); + }); + + test("Clicking Cancel successfully closes the user prompt", async () => { + const user = userEvent.setup(); + + const { container, getByTestId } = render(); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing body", + onAccept: vi.fn(), + }); + }); + + await user.click(getByTestId("dialog-cancel")); + + expect(container).toBeEmptyDOMElement(); + }); + + test("Clicking Accept successfully calls the onAccept callback", async () => { + const user = userEvent.setup(); + + const { getByTestId } = render(); + + const mockOnAccept = vi.fn(() => {}); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing body", + onAccept: mockOnAccept, + }); + }); + + await user.click(getByTestId("dialog-accept")); + + expect(mockOnAccept).toHaveBeenCalled(); + }); + + test("Clicking Cancel successfully calls the onCancel callback", async () => { + const user = userEvent.setup(); + + const { getByTestId } = render(); + + const mockOnCancel = vi.fn(() => {}); + + act(() => { + userPrompt({ + header: "Testing", + body: "testing body", + onAccept: vi.fn(), + onCancel: mockOnCancel, + }); + }); + + await user.click(getByTestId("dialog-cancel")); + + expect(mockOnCancel).toHaveBeenCalled(); + }); +}); diff --git a/react-app/src/components/ConfirmationDialog/userPrompt.tsx b/react-app/src/components/ConfirmationDialog/userPrompt.tsx new file mode 100644 index 0000000000..23b4c17a48 --- /dev/null +++ b/react-app/src/components/ConfirmationDialog/userPrompt.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { ConfirmationDialog } from "@/components/ConfirmationDialog"; + +export type UserPrompt = { + header: string; + body: string; + cancelButtonText?: string; + acceptButtonText?: string; + areButtonsReversed?: boolean; + onAccept: () => void; + onCancel?: () => void; +}; + +class Observer { + subscribers: Array<(userPrompt: UserPrompt) => void>; + userPrompt: UserPrompt | null; + + constructor() { + this.subscribers = []; + this.userPrompt = null; + } + + subscribe = (subscriber: (userPrompt: UserPrompt | null) => void) => { + this.subscribers.push(subscriber); + + return () => { + const index = this.subscribers.indexOf(subscriber); + this.subscribers.splice(index, 1); + }; + }; + + private publish = (data: UserPrompt | null) => { + this.subscribers.forEach((subscriber) => subscriber(data)); + }; + + create = (data: UserPrompt) => { + this.publish(data); + this.userPrompt = { ...data }; + }; + + dismiss = () => { + this.publish(null); + this.userPrompt = null; + }; +} + +const userPromptState = new Observer(); + +export const userPrompt = (newuserPrompt: UserPrompt) => { + return userPromptState.create(newuserPrompt); +}; + +export const UserPrompt = () => { + const [activeuserPrompt, setActiveuserPrompt] = useState( + null, + ); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const unsubscribe = userPromptState.subscribe((userPrompt) => { + if (userPrompt) { + setActiveuserPrompt(userPrompt); + setIsOpen(true); + } else { + // artificial delay to prevent content from disappearing first + setTimeout(() => setActiveuserPrompt(null), 100); + setIsOpen(false); + } + }); + + return unsubscribe; + }, []); + + const onCancel = () => { + if (activeuserPrompt) { + if (activeuserPrompt.onCancel) { + activeuserPrompt.onCancel(); + } + userPromptState.dismiss(); + } + }; + + const onAccept = () => { + if (activeuserPrompt) { + activeuserPrompt.onAccept(); + userPromptState.dismiss(); + } + }; + + if (activeuserPrompt === null) { + return null; + } + + return ( + + ); +}; From dd805319dc9c053935d4d712ffd5ecaeff62cf19 Mon Sep 17 00:00:00 2001 From: Asharon Baltazar Date: Wed, 7 Aug 2024 09:35:21 -0400 Subject: [PATCH 2/5] chore: replace modal context logic with `userPrompt` --- .../features/package-actions/ActionForm.tsx | 15 +++++---------- .../package-actions/lib/contentSwitch.ts | 4 ++-- .../SubmitAndCancelBtnSection.tsx | 18 +++++++----------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/react-app/src/features/package-actions/ActionForm.tsx b/react-app/src/features/package-actions/ActionForm.tsx index 69124e9418..f38592b8b9 100644 --- a/react-app/src/features/package-actions/ActionForm.tsx +++ b/react-app/src/features/package-actions/ActionForm.tsx @@ -2,10 +2,10 @@ import { ErrorBanner, Form, LoadingSpinner, + userPrompt, PreSubmitNotice, Route, useAlertContext, - useModalContext, } from "@/components"; import { useGetUser } from "@/api/useGetUser"; import { getFormOrigin } from "@/utils"; @@ -34,7 +34,6 @@ export const ActionForm = ({ }: ActionFormProps) => { const navigate = useNavigate(); const alert = useAlertContext(); - const modal = useModalContext(); const { data: user } = useGetUser(); const { data: item } = useGetItem(id); @@ -90,15 +89,11 @@ export const ActionForm = ({ const onConfirmWithdraw = () => { if (content.confirmationModal) { - modal.setContent(content.confirmationModal); + userPrompt({ + ...content.confirmationModal, + onAccept: onSubmit, + }); } - - modal.setModalOpen(true); - - modal.setOnAccept(() => () => { - modal.setModalOpen(false); - onSubmit(); - }); }; return ( diff --git a/react-app/src/features/package-actions/lib/contentSwitch.ts b/react-app/src/features/package-actions/lib/contentSwitch.ts index 5b6d746b90..11e3624f08 100644 --- a/react-app/src/features/package-actions/lib/contentSwitch.ts +++ b/react-app/src/features/package-actions/lib/contentSwitch.ts @@ -1,4 +1,4 @@ -import { BannerContent, SubmissionAlert } from "@/components"; +import { BannerContent, UserPrompt } from "@/components"; import { Action, AuthorityUnion, opensearch } from "shared-types"; import { defaultIssueRaiContent, @@ -18,7 +18,7 @@ type FormContent = { title: string; successBanner: BannerContent; preSubmitNotice?: string; - confirmationModal?: SubmissionAlert; + confirmationModal?: Omit; enableSubmit?: boolean; }; /** Form content sometimes requires data values for templating, so forms diff --git a/react-app/src/features/submission/waiver/shared-components/SubmitAndCancelBtnSection.tsx b/react-app/src/features/submission/waiver/shared-components/SubmitAndCancelBtnSection.tsx index 0f46a0b6fd..e7b8136b45 100644 --- a/react-app/src/features/submission/waiver/shared-components/SubmitAndCancelBtnSection.tsx +++ b/react-app/src/features/submission/waiver/shared-components/SubmitAndCancelBtnSection.tsx @@ -1,5 +1,5 @@ -import { LoadingSpinner, useModalContext, Button } from "@/components"; -import * as Inputs from "@/components"; +import { LoadingSpinner, Button } from "@/components"; +import { Alert, userPrompt } from "@/components"; import { useNavigate, useParams } from "react-router-dom"; import { useFormContext } from "react-hook-form"; import { useMemo } from "react"; @@ -20,13 +20,10 @@ export const SubmitAndCancelBtnSection = ({ enableSubmit, }: buttonProps) => { const form = useFormContext(); - const modal = useModalContext(); const navigate = useNavigate(); const { id, authority } = useParams<{ id: string; authority: Authority }>(); const acceptAction = () => { - modal.setModalOpen(false); - const origin = getFormOrigin({ id, authority }); navigate(origin); }; @@ -47,16 +44,16 @@ export const SubmitAndCancelBtnSection = ({ )} {showAlert && Object.keys(form.formState.errors).length !== 0 && ( - + Missing or malformed information. Please see errors above. - + )}
)} {cancelButtonVisible && ( - )} diff --git a/react-app/src/components/index.tsx b/react-app/src/components/index.tsx index bdab79c7b7..6fb02d94b7 100644 --- a/react-app/src/components/index.tsx +++ b/react-app/src/components/index.tsx @@ -16,7 +16,6 @@ export * from "./Inputs"; export * from "./Layout"; export * from "./LoadingSpinner"; export * from "./LockIcon"; -export * from "./Modal"; export * from "./Opensearch"; export * from "./Pagination"; export * from "./Popover"; @@ -28,7 +27,6 @@ export * from "./Table"; export * from "./Tabs"; export * from "./UsaBanner"; export * from "./MaintenanceBanner"; -export * from "./Modal"; -export * from "./Modal/ConfirmationModal"; +export * from "./ConfirmationDialog"; export * from "./SearchForm"; export * from "./TimeoutModal"; diff --git a/react-app/src/features/package/package-details/appk.tsx b/react-app/src/features/package/package-details/appk.tsx index e6fbc045ce..ff44e93a6c 100644 --- a/react-app/src/features/package/package-details/appk.tsx +++ b/react-app/src/features/package/package-details/appk.tsx @@ -1,5 +1,5 @@ import { - ConfirmationModal, + ConfirmationDialog, LoadingSpinner, Route, useAlertContext, @@ -98,7 +98,7 @@ export const AppK = () => { {(submission.isLoading || loading) && } - onChildRemove(removeChild)} onCancel={() => setRemoveChild("")} From 9ba4c3d3201925ab2921d824c3bf863a7d9bef5f Mon Sep 17 00:00:00 2001 From: Asharon Baltazar Date: Wed, 7 Aug 2024 12:00:38 -0400 Subject: [PATCH 5/5] chore: `activeuserPrompt` -> `activerUserPrompt` --- .../ConfirmationDialog/userPrompt.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/react-app/src/components/ConfirmationDialog/userPrompt.tsx b/react-app/src/components/ConfirmationDialog/userPrompt.tsx index 23b4c17a48..fa4cff81d3 100644 --- a/react-app/src/components/ConfirmationDialog/userPrompt.tsx +++ b/react-app/src/components/ConfirmationDialog/userPrompt.tsx @@ -51,7 +51,7 @@ export const userPrompt = (newuserPrompt: UserPrompt) => { }; export const UserPrompt = () => { - const [activeuserPrompt, setActiveuserPrompt] = useState( + const [activeUserPrompt, setActiveUserPrompt] = useState( null, ); const [isOpen, setIsOpen] = useState(false); @@ -59,11 +59,11 @@ export const UserPrompt = () => { useEffect(() => { const unsubscribe = userPromptState.subscribe((userPrompt) => { if (userPrompt) { - setActiveuserPrompt(userPrompt); + setActiveUserPrompt(userPrompt); setIsOpen(true); } else { // artificial delay to prevent content from disappearing first - setTimeout(() => setActiveuserPrompt(null), 100); + setTimeout(() => setActiveUserPrompt(null), 100); setIsOpen(false); } }); @@ -72,35 +72,35 @@ export const UserPrompt = () => { }, []); const onCancel = () => { - if (activeuserPrompt) { - if (activeuserPrompt.onCancel) { - activeuserPrompt.onCancel(); + if (activeUserPrompt) { + if (activeUserPrompt.onCancel) { + activeUserPrompt.onCancel(); } userPromptState.dismiss(); } }; const onAccept = () => { - if (activeuserPrompt) { - activeuserPrompt.onAccept(); + if (activeUserPrompt) { + activeUserPrompt.onAccept(); userPromptState.dismiss(); } }; - if (activeuserPrompt === null) { + if (activeUserPrompt === null) { return null; } return ( ); };