diff --git a/react-app/src/api/index.test.ts b/react-app/src/api/index.test.ts deleted file mode 100644 index 46b1cc472..000000000 --- a/react-app/src/api/index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { it, describe, expect } from "vitest"; -describe("checkEnvVars", () => { - it("is the greatest test ever written", () => { - expect(1 + 1).toEqual(2); - }); -}); diff --git a/react-app/src/api/useGetCounties.ts b/react-app/src/api/useGetCounties.ts index b4b9c6124..b8736005a 100644 --- a/react-app/src/api/useGetCounties.ts +++ b/react-app/src/api/useGetCounties.ts @@ -1,29 +1,18 @@ import { useQuery } from "@tanstack/react-query"; +import { useGetUser } from "./useGetUser"; +import { getUserStateCodes } from "@/utils"; +import { FULL_CENSUS_STATES } from "shared-types"; +import { useMemo } from "react"; -const fetchPopulationData = async (stateString: string) => { - try { - if (stateString) { - const response = await fetch( - `https://api.census.gov/data/2019/pep/population?get=NAME&for=county:*&in=state:${stateString}`, - ); - if (!response.ok) { - throw new Error("Failed to fetch county data"); - } - - const data = await response.json(); - return data.slice(1).map((item) => item[0]); - } - return []; - } catch (error) { - console.error("Error fetching county data:", error); - throw error; - } -}; - -export const usePopulationData = (stateString: string) => { +const usePopulationData = (stateString: string) => { return useQuery( ["populationData", stateString], - () => fetchPopulationData(stateString), + () => + fetch( + `https://api.census.gov/data/2019/pep/population?get=NAME&for=county:*&in=state:${stateString}`, + ) + .then((response) => response.json()) + .then((population) => population.slice(1).map((item) => item[0])), { refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -32,4 +21,34 @@ export const usePopulationData = (stateString: string) => { ); }; -export default usePopulationData; +export const useGetCounties = (): { label: string; value: string }[] => { + const { data: userData } = useGetUser(); + + const stateCodes = useMemo( + () => getUserStateCodes(userData?.user), + [userData], + ); + + const stateNumericCodesString = useMemo( + () => + stateCodes + .map( + (code) => + FULL_CENSUS_STATES.find((state) => state.value === code)?.code, + ) + .filter((code): code is string => code !== undefined && code !== "00") + .join(","), + [stateCodes], + ); + + const { data: populationData = [] } = usePopulationData( + stateNumericCodesString, + ); + + return ( + populationData.map((county) => { + const [label] = county.split(","); + return { label, value: county }; + }) ?? [] + ); +}; diff --git a/react-app/src/components/ActionForm/ActionForm.test.tsx b/react-app/src/components/ActionForm/ActionForm.test.tsx index 50d1a8c82..606f6b152 100644 --- a/react-app/src/components/ActionForm/ActionForm.test.tsx +++ b/react-app/src/components/ActionForm/ActionForm.test.tsx @@ -1,6 +1,4 @@ -import { ReactElement } from "react"; -import { MemoryRouter } from "react-router-dom"; -import { render, waitFor } from "@testing-library/react"; +import { waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, test, expect, vi } from "vitest"; import { ActionForm } from "./index"; @@ -9,6 +7,8 @@ import { attachmentArraySchemaOptional } from "shared-types"; import * as userPrompt from "@/components/ConfirmationDialog/userPrompt"; import * as banner from "@/components/Banner/banner"; import * as documentPoller from "@/utils/Poller/documentPoller"; +import { renderForm } from "@/utils/test-helpers/renderForm"; +import { isCmsReadonlyUser } from "shared-utils"; vi.mock("aws-amplify", async (importOriginal) => { const actual = await importOriginal(); @@ -28,17 +28,12 @@ vi.mock("../../utils/Poller/DataPoller", () => { }; }); -const renderWithMemoryRouter = (ActionFormArg: ReactElement) => - render(ActionFormArg, { - wrapper: ({ children }) => {children}, - }); - const PROGRESS_REMINDER = /If you leave this page, you will lose your progress on this form./; describe("ActionForm", () => { test("renders `breadcrumbText`", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders `title`", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders `attachments.faqLink`", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { ); }); + test("doesn't render form if user access is denied", () => { + const { queryByText } = renderForm( + null} + documentPollerArgs={{ + property: () => "id", + documentChecker: () => true, + }} + attachments={{ faqLink: "hello-world-link" }} + conditionsDeterminingUserAccess={[isCmsReadonlyUser]} + breadcrumbText="Example Breadcrumb" + />, + ); + + expect(queryByText("Action Form Title")).not.toBeInTheDocument(); + }); + test("renders `defaultValues` in appropriate input", () => { - const { queryByDisplayValue } = renderWithMemoryRouter( + const { queryByDisplayValue } = renderForm( { }); test("renders `attachments.specialInstructions`", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders custom `promptOnLeavingForm` when clicking Cancel", async () => { - const { container } = renderWithMemoryRouter( + const { container } = renderForm( { }); test("renders custom `promptPreSubmission` when clicking Submit", async () => { - const { container } = renderWithMemoryRouter( + const { container } = renderForm( { test("calls `documentPoller` with `documentPollerArgs`", async () => { const documentCheckerFunc = vi.fn(); - const { container } = renderWithMemoryRouter( + const { container } = renderForm( { test("calls `banner` with `bannerPostSubmission`", async () => { const documentCheckerFunc = vi.fn(); - const { container } = renderWithMemoryRouter( + const { container } = renderForm( { }); test("renders all attachment properties within `attachments`", async () => { - const { queryAllByText } = renderWithMemoryRouter( + const { queryAllByText } = renderForm( { }); test("renders Additional Information if `additionalInformation` is defined in schema", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("doesn't render Additional Information if `additionalInformation` is undefined in schema", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders Attachments if `attachments` is defined in schema", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("doesn't render Attachments if `attachments` is undefined in schema", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders ProgressReminder if schema has `attachments` property", () => { - const { queryAllByText } = renderWithMemoryRouter( + const { queryAllByText } = renderForm( { }); test("renders ProgressReminder if `fields` property is defined", () => { - const { queryAllByText } = renderWithMemoryRouter( + const { queryAllByText } = renderForm( { }); test("doesn't render ProgressReminder `fields` is undefined and `attachments` isn't defined in schema", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders default wrapper if `fieldsLayout` is undefined", () => { - const { queryAllByText } = renderWithMemoryRouter( + const { queryAllByText } = renderForm( { }); test("renders `fieldsLayout`", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( { }); test("renders `fieldsLayout` with correct `title`", () => { - const { queryByText } = renderWithMemoryRouter( + const { queryByText } = renderForm( = z.ZodObject< Shape & { @@ -81,6 +88,9 @@ type ActionFormProps = { | ((values: z.TypeOf) => string); documentChecker: CheckDocumentFunction; }; + conditionsDeterminingUserAccess?: (( + user: CognitoUserAttributes | null, + ) => boolean)[]; breadcrumbText: string; }; @@ -105,11 +115,13 @@ export const ActionForm = ({ promptPreSubmission, documentPollerArgs, attachments, + conditionsDeterminingUserAccess = [isStateUser], breadcrumbText, }: ActionFormProps) => { const { id, authority } = useParams<{ id: string; authority: Authority }>(); const { pathname } = useLocation(); const navigate = useNavigate(); + const { data: userObj } = useGetUser(); const breadcrumbs = optionCrumbsFromPath(pathname, authority); @@ -166,6 +178,14 @@ export const ActionForm = ({ [attachmentsFromSchema, Fields, form], ); + const doesUserHaveAccessToForm = conditionsDeterminingUserAccess.some( + (condition) => condition(userObj.user), + ); + + if (doesUserHaveAccessToForm === false) { + return ; + } + return ( (initialState); - -export const UserContextProvider = ({ children }: PropsWithChildren) => { - const { data: userData, error: userError } = useGetUser(); - - const stateCodes = useMemo( - () => getUserStateCodes(userData?.user), - [userData], - ); - - const stateNumericCodesString = useMemo( - () => - stateCodes - .map( - (code) => - FULL_CENSUS_STATES.find((state) => state.value === code)?.code, - ) - .filter((code): code is string => code !== undefined && code !== "00") - .join(","), - [stateCodes], - ); - - const { data: populationData, error: populationError } = usePopulationData( - stateNumericCodesString, - ); - - const counties = useMemo( - () => - populationData?.map((county) => { - const [label] = county.split(","); - return { label, value: county }; - }) ?? [], - [populationData], - ); - - const contextValue = useMemo( - () => ({ - user: userData?.user ?? null, - counties, - }), - [userData, counties], - ); - - if (userError || populationError) { - console.error("Error fetching data:", userError || populationError); - return null; - } - - return ( - {children} - ); -}; - -export const useUserContext = () => useContext(UserContext); diff --git a/react-app/src/components/Layout/index.tsx b/react-app/src/components/Layout/index.tsx index 791c9d6b5..85554b6e3 100644 --- a/react-app/src/components/Layout/index.tsx +++ b/react-app/src/components/Layout/index.tsx @@ -8,25 +8,19 @@ import { import oneMacLogo from "@/assets/onemac_logo.svg"; import { useMediaQuery } from "@/hooks"; import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useGetUser } from "@/api"; import { Auth } from "aws-amplify"; import { AwsCognitoOAuthOpts } from "@aws-amplify/auth/lib-esm/types"; import { Footer } from "../Footer"; import { UsaBanner } from "../UsaBanner"; -import { useUserContext } from "../Context"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import config from "@/config"; import { SimplePageContainer, UserPrompt, Banner } from "@/components"; import { isFaqPage, isProd } from "@/utils"; const useGetLinks = () => { - const { isLoading, data } = useGetUser(); - const userContext = useUserContext(); - - const role = useMemo(() => { - return userContext?.user?.["custom:cms-roles"] ? true : false; - }, []); + const { isLoading, data: userObj } = useGetUser(); const links = isLoading || isFaqPage @@ -40,7 +34,7 @@ const useGetLinks = () => { { name: "Dashboard", link: "/dashboard", - condition: !!data?.user && role, + condition: userObj.user && userObj.user["custom:cms-roles"], }, { name: "FAQ", @@ -50,7 +44,7 @@ const useGetLinks = () => { { name: "Webforms", link: "/webforms", - condition: !!data?.user && !isProd, + condition: userObj.user && !isProd, }, ].filter((l) => l.condition); diff --git a/react-app/src/components/RHF/SlotField.tsx b/react-app/src/components/RHF/SlotField.tsx index 114f18fb7..9330f8015 100644 --- a/react-app/src/components/RHF/SlotField.tsx +++ b/react-app/src/components/RHF/SlotField.tsx @@ -7,12 +7,7 @@ import { import { CalendarIcon } from "lucide-react"; import { format } from "date-fns"; import { cn } from "@/utils"; -import { - Popover, - PopoverContent, - PopoverTrigger, - useUserContext, -} from "@/components"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components"; import { DependencyWrapper, RHFFieldArray, @@ -43,6 +38,7 @@ import { Textarea, Upload, } from "@/components/Inputs"; +import { useGetCounties } from "@/api"; type SlotFieldProps = RHFSlotProps & { control: any; field: any }; type SelectedSubsetProps = RHFOption & { @@ -62,7 +58,7 @@ export const SlotField = ({ horizontalLayout, index, }: SlotFieldProps) => { - const userContext = useUserContext(); + const counties = useGetCounties(); switch (rhf) { case "Input": @@ -109,7 +105,7 @@ export const SlotField = ({ case "countySelect": opts = - userContext?.counties?.sort((a, b) => + counties.sort((a, b) => props.customSort ? sortFunctions[props.customSort](a.label, b.label) : stringCompare(a, b), diff --git a/react-app/src/components/RHF/tests/FieldArray.test.tsx b/react-app/src/components/RHF/tests/FieldArray.test.tsx index e0e59507f..c6ebbd85c 100644 --- a/react-app/src/components/RHF/tests/FieldArray.test.tsx +++ b/react-app/src/components/RHF/tests/FieldArray.test.tsx @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { fireEvent, render } from "@testing-library/react"; import { RHFSlot } from ".."; import { Form, FormField } from "../../Inputs"; @@ -121,6 +121,16 @@ const testWrapperDependency: RHFSlotProps = { ], }; +vi.mock("@/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useGetCounties: vi.fn(() => { + return { data: [], isLoading: false, error: null }; + }), + }; +}); + describe("Field Tests", () => { test("renders FieldArray", () => { const rend = render(); diff --git a/react-app/src/components/RHF/tests/NameGen.test.tsx b/react-app/src/components/RHF/tests/NameGen.test.tsx index 970c53d28..a05121701 100644 --- a/react-app/src/components/RHF/tests/NameGen.test.tsx +++ b/react-app/src/components/RHF/tests/NameGen.test.tsx @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { render } from "@testing-library/react"; import { RHFDocument } from "../."; import { Form } from "../../Inputs"; @@ -69,6 +69,16 @@ const TestWrapper = (props: { onSubmit: (d: any) => void }) => { ); }; +vi.mock("@/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useGetCounties: vi.fn(() => { + return { data: [], isLoading: false, error: null }; + }), + }; +}); + describe("Test Name Generation", () => { test("Generate Structure Correctly", () => { const rend = render( diff --git a/react-app/src/components/RHF/tests/Section.test.tsx b/react-app/src/components/RHF/tests/Section.test.tsx index 400263a79..1cee48ecb 100644 --- a/react-app/src/components/RHF/tests/Section.test.tsx +++ b/react-app/src/components/RHF/tests/Section.test.tsx @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { render } from "@testing-library/react"; import { RHFDocument, documentInitializer } from ".."; import { Form } from "../../Inputs"; @@ -49,6 +49,16 @@ const testDocData: FormSchema = { ], }; +vi.mock("@/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useGetCounties: vi.fn(() => { + return { data: [], isLoading: false, error: null }; + }), + }; +}); + describe("Section Tests", () => { test("renders, subsections distinct", () => { const rend = render(); diff --git a/react-app/src/components/RHF/tests/Slot.test.tsx b/react-app/src/components/RHF/tests/Slot.test.tsx index 8e370bf1d..3401503a1 100644 --- a/react-app/src/components/RHF/tests/Slot.test.tsx +++ b/react-app/src/components/RHF/tests/Slot.test.tsx @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { render } from "@testing-library/react"; import { RHFSlot } from "../."; import { Form, FormField } from "../../Inputs"; @@ -35,6 +35,16 @@ const testValues: RHFSlotProps = { formItemClassName: "py-4", }; +vi.mock("@/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useGetCounties: vi.fn(() => { + return { data: [], isLoading: false, error: null }; + }), + }; +}); + describe("RHFSlot tests", () => { test("render label, desc, and comp", () => { const rend = render(); diff --git a/react-app/src/components/RHF/tests/SlotField.test.tsx b/react-app/src/components/RHF/tests/SlotField.test.tsx index 364de9502..e4c847b6d 100644 --- a/react-app/src/components/RHF/tests/SlotField.test.tsx +++ b/react-app/src/components/RHF/tests/SlotField.test.tsx @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import { render, fireEvent } from "@testing-library/react"; import { RHFSlot } from "../."; import { Form, FormField } from "../../Inputs"; @@ -35,6 +35,16 @@ const testValues: RHFSlotProps = { formItemClassName: "py-4", }; +vi.mock("@/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useGetCounties: vi.fn(() => { + return { data: [], isLoading: false, error: null }; + }), + }; +}); + describe("Slot Input Field Tests", () => { test("renders Input", () => { const rend = render(); diff --git a/react-app/src/components/index.tsx b/react-app/src/components/index.tsx index 7f3f88df2..85a207c5a 100644 --- a/react-app/src/components/index.tsx +++ b/react-app/src/components/index.tsx @@ -3,7 +3,6 @@ export * from "./BreadCrumb"; export * from "./Cards"; export * from "./Chip"; export * from "./Container"; -export * from "./Context"; export * from "./DetailsSection"; export * from "./Dialog"; export * from "./ErrorAlert"; diff --git a/react-app/src/features/dashboard/index.tsx b/react-app/src/features/dashboard/index.tsx index d55b14c40..e3ea8590a 100644 --- a/react-app/src/features/dashboard/index.tsx +++ b/react-app/src/features/dashboard/index.tsx @@ -1,7 +1,6 @@ -import { useMemo } from "react"; import { QueryClient } from "@tanstack/react-query"; import { Plus as PlusIcon } from "lucide-react"; -import { getUser } from "@/api"; +import { getUser, useGetUser } from "@/api"; import { WaiversList } from "./Lists/waivers"; import { SpasList } from "./Lists/spas"; import { @@ -9,13 +8,13 @@ import { type OsTab, useOsData, FilterDrawerProvider, - useUserContext, Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components"; import { useScrollToTop } from "@/hooks"; +import { isStateUser } from "shared-utils"; import { Link, Navigate, redirect } from "react-router-dom"; const loader = (queryClient: QueryClient) => { @@ -41,15 +40,11 @@ const loader = (queryClient: QueryClient) => { export const dashboardLoader = loader; export const Dashboard = () => { - const userContext = useUserContext(); + const { data: userObj } = useGetUser(); const osData = useOsData(); useScrollToTop(); - const role = useMemo(() => { - return userContext?.user?.["custom:cms-roles"] ? true : false; - }, []); - - if (!role) { + if (userObj === undefined) { return ; } @@ -63,10 +58,9 @@ export const Dashboard = () => { >
- {/* Header */}

Dashboard

- {!userContext?.isCms && ( + {isStateUser(userObj.user) && ( { )}
- {/* Tabs */}
( @@ -34,6 +36,12 @@ type OptionsPageProps = { /** A page for rendering an array of {@link OptionData} */ const OptionsPage = ({ options, title, fieldsetLegend }: OptionsPageProps) => { const location = useLocation(); + const { data: userObj } = useGetUser(); + + if (userObj && isStateUser(userObj.user) === false) { + return ; + } + return ( diff --git a/react-app/src/main.tsx b/react-app/src/main.tsx index 4e37464f1..8cb6356fa 100644 --- a/react-app/src/main.tsx +++ b/react-app/src/main.tsx @@ -2,11 +2,11 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider } from "react-router-dom"; import "@fontsource/open-sans"; -import "./index.css"; // this one second +import "./index.css"; import { queryClient, router } from "./router"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { UserContextProvider, TimeoutModal } from "@/components"; +import { TimeoutModal } from "@/components"; import config from "@/config"; import { asyncWithLDProvider } from "launchdarkly-react-client-sdk"; @@ -31,12 +31,10 @@ const initializeLaunchDarkly = async () => { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - + + + + , diff --git a/react-app/testing/setup.ts b/react-app/testing/setup.ts index af2d9db3b..1992144bb 100644 --- a/react-app/testing/setup.ts +++ b/react-app/testing/setup.ts @@ -1,6 +1,6 @@ import { expect, afterEach, beforeAll, afterAll, vi } from "vitest"; import { cleanup } from "@testing-library/react"; -import matchers from "@testing-library/jest-dom/matchers"; +import * as matchers from "@testing-library/jest-dom/matchers"; global.ResizeObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), @@ -66,6 +66,14 @@ beforeAll(() => { return idsThatExist.includes(id); }), + useGetUser: () => ({ + data: { + user: { + "custom:cms-roles": + "onemac-micro-statesubmitter,onemac-micro-super", + }, + }, + }), })); vi.mock("@/utils/user", () => ({ isAuthorizedState: vi.fn(async (id: string) => { @@ -74,8 +82,6 @@ beforeAll(() => { return validStates.includes(id.substring(0, 2)); }), })); - // mock the api calls that the frontend uses here - // vi.mock("@/api/stuff") } });