diff --git a/src/components/Contexts/OrganizationListContext.test.tsx b/src/components/Contexts/OrganizationListContext.test.tsx new file mode 100644 index 00000000..e97db16e --- /dev/null +++ b/src/components/Contexts/OrganizationListContext.test.tsx @@ -0,0 +1,281 @@ +import React, { FC } from "react"; +import { render, waitFor } from "@testing-library/react"; +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import { GraphQLError } from "graphql"; +import { + OrganizationProvider, + Status as OrgStatus, + useOrganizationListContext, +} from "./OrganizationListContext"; +import { LIST_ORGS } from "../../graphql"; + +type Props = { + mocks?: MockedResponse[]; + preload?: boolean; + children?: React.ReactNode; +}; + +const TestChild: FC = () => { + const { status, data, activeOrganizations } = useOrganizationListContext(); + + if (status === OrgStatus.LOADING) { + return
Loading...
; + } + + return ( +
+
{status}
+ + +
+ ); +}; + +const TestParent: FC = ({ mocks, preload = true, children }: Props) => ( + + {children ?? } + +); + +describe("OrganizationListContext > useOrganizationListContext Tests", () => { + it("should throw an exception when used outside of the OrganizationProvider", () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + "OrganizationListContext cannot be used outside of the OrganizationProvider component" + ); + jest.spyOn(console, "error").mockRestore(); + }); +}); + +describe("OrganizationListContext > OrganizationProvider Tests", () => { + const emptyMocks = [ + { + request: { + query: LIST_ORGS, + }, + result: { + data: { + listOrganizations: [], + }, + }, + }, + ]; + + it("should render without crashing", () => { + render(); + }); + + it("should handle loading state correctly", async () => { + const { getByText } = render(); + expect(getByText("Loading...")).toBeInTheDocument(); + }); + + it("should load and display organization data", async () => { + const orgData = [ + { name: "Org One", status: "Active" }, + { name: "Org Two", status: "Active" }, + ]; + + const mocks = [ + { + request: { + query: LIST_ORGS, + }, + result: { + data: { + listOrganizations: orgData, + }, + }, + }, + ]; + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("status").textContent).toEqual(OrgStatus.LOADED); + expect(getByTestId("organization-0").textContent).toEqual("Org One"); + expect(getByTestId("organization-1").textContent).toEqual("Org Two"); + }); + }); + + it("should handle errors when failing to load organizations", async () => { + const mocks = [ + { + request: { + query: LIST_ORGS, + }, + result: { + errors: [new GraphQLError("Failed to fetch")], + }, + }, + ]; + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("status").textContent).toEqual(OrgStatus.ERROR); + }); + }); + + it("should only show active organizations in the activeOrganizations list", async () => { + const orgData = [ + { name: "Active Org", status: "Active" }, + { name: "Inactive Org", status: "Inactive" }, + ]; + + const mocks = [ + { + request: { + query: LIST_ORGS, + }, + result: { + data: { + listOrganizations: orgData, + }, + }, + }, + ]; + + const FilteredTestParent: FC = () => ( + + + + + + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("organization-list").textContent).toContain("Active Org"); + expect(getByTestId("organization-list").textContent).toContain("Inactive Org"); + expect(getByTestId("active-organization-list").textContent).not.toContain("Inactive Org"); + }); + }); + + it("should sort organizations by name in ascending order", async () => { + const orgData = [ + { name: "Organization Zeta", status: "Active" }, + { name: "Organization Alpha", status: "Active" }, + { name: "Organization Delta", status: "Active" }, + ]; + + const mocks = [ + { + request: { + query: LIST_ORGS, + }, + result: { + data: { + listOrganizations: orgData, + }, + }, + }, + ]; + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("organization-0").textContent).toEqual("Organization Alpha"); + expect(getByTestId("organization-1").textContent).toEqual("Organization Delta"); + expect(getByTestId("organization-2").textContent).toEqual("Organization Zeta"); + }); + }); + + it("should not execute query when preload is false", async () => { + const { queryByText } = render( + + + + ); + await waitFor(() => { + expect(queryByText("Loading...")).not.toBeInTheDocument(); + }); + }); + + it("should handle state changes gracefully", async () => { + const orgData = [{ name: "Org Fast", status: "Active" }]; + const loadingMock = { + request: { query: LIST_ORGS }, + result: { data: { listOrganizations: [] } }, + delay: 100, + }; + const loadedMock = { + request: { query: LIST_ORGS }, + result: { data: { listOrganizations: orgData } }, + }; + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText("Loading...")).toBeInTheDocument(); + }); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("status").textContent).toEqual(OrgStatus.LOADED); + expect(getByTestId("organization-0").textContent).toEqual("Org Fast"); + }); + }); + + it("should correctly update all consumers when state changes", async () => { + const orgData = [{ name: "Org Multi", status: "Active" }]; + + const mocks = [ + { + request: { query: LIST_ORGS }, + result: { data: { listOrganizations: orgData } }, + }, + ]; + + const DoubleConsumer: FC = () => ( + + + + + + + ); + + const { getAllByTestId } = render(); + + await waitFor(() => { + const statuses = getAllByTestId("status"); + expect(statuses[0].textContent).toEqual(OrgStatus.LOADED); + expect(statuses[1].textContent).toEqual(OrgStatus.LOADED); + }); + }); + + it("should handle partial data without crashing", async () => { + const partialDataMocks = [ + { + request: { query: LIST_ORGS }, + result: { + data: { listOrganizations: [{ name: "Org Partial" }] }, // Missing "status" + }, + }, + ]; + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("status").textContent).toEqual(OrgStatus.LOADED); + expect(getByTestId("organization-0").textContent).toEqual("Org Partial"); + }); + }); +}); diff --git a/src/components/Contexts/OrganizationListContext.tsx b/src/components/Contexts/OrganizationListContext.tsx index b9df76e9..fc53a6a5 100644 --- a/src/components/Contexts/OrganizationListContext.tsx +++ b/src/components/Contexts/OrganizationListContext.tsx @@ -5,6 +5,7 @@ import { LIST_ORGS, ListOrgsResp } from "../../graphql"; export type ContextState = { status: Status; data: Partial[]; + activeOrganizations: Partial[]; }; export enum Status { @@ -13,7 +14,11 @@ export enum Status { ERROR = "ERROR", } -const initialState: ContextState = { status: Status.LOADING, data: null }; +const initialState: ContextState = { + status: Status.LOADING, + data: [], + activeOrganizations: [], +}; /** * Organization List Context @@ -51,7 +56,6 @@ export const useOrganizationListContext = (): ContextState => { type ProviderProps = { preload: boolean; - filterInactive?: boolean; children: React.ReactNode; }; @@ -62,11 +66,7 @@ type ProviderProps = { * @param {ProviderProps} props * @returns {JSX.Element} Context provider */ -export const OrganizationProvider: FC = ({ - preload, - filterInactive, - children, -}: ProviderProps) => { +export const OrganizationProvider: FC = ({ preload, children }: ProviderProps) => { const [state, setState] = useState(initialState); const { data, loading, error } = preload @@ -78,20 +78,26 @@ export const OrganizationProvider: FC = ({ useEffect(() => { if (loading) { - setState({ status: Status.LOADING, data: null }); + setState({ status: Status.LOADING, data: [], activeOrganizations: [] }); return; } if (error) { - setState({ status: Status.ERROR, data: null }); + setState({ status: Status.ERROR, data: [], activeOrganizations: [] }); return; } + const sortedOrganizations = data?.listOrganizations?.sort( + (a, b) => a.name?.localeCompare(b.name) + ); + + const activeOrganizations = sortedOrganizations?.filter( + (org: Organization) => org.status === "Active" + ); + setState({ status: Status.LOADED, - data: - data?.listOrganizations?.filter((org: Organization) => - filterInactive ? org.status === "Active" : true - ) || [], + data: sortedOrganizations || [], + activeOrganizations: activeOrganizations || [], }); }, [loading, error, data]); diff --git a/src/content/dataSubmissions/Controller.tsx b/src/content/dataSubmissions/Controller.tsx index 275cf98f..ce2a8b9b 100644 --- a/src/content/dataSubmissions/Controller.tsx +++ b/src/content/dataSubmissions/Controller.tsx @@ -1,7 +1,15 @@ -import React from "react"; +import React, { memo } from "react"; import { useParams } from "react-router-dom"; import DataSubmission from "./DataSubmission"; import ListView from "./DataSubmissionsListView"; +import { OrganizationProvider } from "../../components/Contexts/OrganizationListContext"; + +/** + * A memoized version of OrganizationProvider + * + * @see OrganizationProvider + */ +const MemorizedProvider = memo(OrganizationProvider); /** * Render the correct view based on the URL @@ -16,7 +24,11 @@ const DataSubmissionController = () => { return ; } - return ; + return ( + + + + ); }; export default DataSubmissionController; diff --git a/src/content/dataSubmissions/DataSubmissionsListView.tsx b/src/content/dataSubmissions/DataSubmissionsListView.tsx index 7967f7a5..e0fd8041 100644 --- a/src/content/dataSubmissions/DataSubmissionsListView.tsx +++ b/src/content/dataSubmissions/DataSubmissionsListView.tsx @@ -22,10 +22,6 @@ import { import { useSnackbar } from "notistack"; import { useQuery } from "@apollo/client"; import { query, Response } from "../../graphql/listSubmissions"; -import { - query as listOrganizationsQuery, - Response as listOrganizationsResponse, -} from "../../graphql/listOrganizations"; import bannerSvg from "../../assets/banner/submission_banner.png"; import PageBanner from "../../components/PageBanner"; import { FormatDate } from "../../utils"; @@ -33,6 +29,10 @@ import { useAuthContext } from "../../components/Contexts/AuthContext"; import SuspenseLoader from "../../components/SuspenseLoader"; import usePageTitle from "../../hooks/usePageTitle"; import CreateDataSubmissionDialog from "./CreateDataSubmissionDialog"; +import { + Status, + useOrganizationListContext, +} from "../../components/Contexts/OrganizationListContext"; type T = Submission; @@ -247,6 +247,7 @@ const ListingView: FC = () => { const { state } = useLocation(); const { user } = useAuthContext(); const { enqueueSnackbar } = useSnackbar(); + const { status: orgStatus, activeOrganizations: allOrganizations } = useOrganizationListContext(); const [order, setOrder] = useState<"asc" | "desc">("desc"); const [orderBy, setOrderBy] = useState( @@ -269,12 +270,6 @@ const ListingView: FC = () => { ); const [statusFilter, setStatusFilter] = useState("All"); - const { data: allOrganizations } = useQuery(listOrganizationsQuery, { - variables: {}, - context: { clientName: "backend" }, - fetchPolicy: "no-cache", - }); - const { data, loading, error, refetch } = useQuery(query, { variables: { first: perPage, @@ -283,7 +278,7 @@ const ListingView: FC = () => { orderBy: orderBy.field, organization: organizationFilter !== "All" - ? allOrganizations?.listOrganizations?.find((org) => org.name === organizationFilter)?._id + ? allOrganizations?.find((org) => org.name === organizationFilter)?._id : "All", status: statusFilter, }, @@ -315,7 +310,7 @@ const ListingView: FC = () => { }); }; - const organizationNames: SelectOption[] = allOrganizations?.listOrganizations?.map((org) => ({ + const organizationNames: SelectOption[] = allOrganizations?.map((org) => ({ label: org.name, value: org.name, })); @@ -332,7 +327,7 @@ const ListingView: FC = () => { {/* NOTE For MVP-2: Organization Owners are just Users */} {/* Create a submission only available to org owners and submitters that have organizations assigned */} @@ -424,7 +419,7 @@ const ListingView: FC = () => { - {loading && ( + {(loading || orgStatus === Status.LOADING) && ( @@ -483,13 +478,16 @@ const ListingView: FC = () => { data?.listSubmissions?.total === 0 || data?.listSubmissions?.total <= (page + 1) * perPage || emptyRows > 0 || - loading, + loading || + orgStatus === Status.LOADING, }} SelectProps={{ inputProps: { "aria-label": "rows per page" }, native: true, }} - backIconButtonProps={{ disabled: page === 0 || loading }} + backIconButtonProps={{ + disabled: page === 0 || loading || orgStatus === Status.LOADING, + }} /> diff --git a/src/content/users/Controller.tsx b/src/content/users/Controller.tsx index ffed7bad..1b285586 100644 --- a/src/content/users/Controller.tsx +++ b/src/content/users/Controller.tsx @@ -54,7 +54,7 @@ const UserController = ({ type }: Props) => { // Show list of users to Admin or Org Owner if (!userId && isAdministrative) { return ( - + ); @@ -62,7 +62,7 @@ const UserController = ({ type }: Props) => { // Admin or Org Owner viewing a user's "Edit User" page or their own "Edit User" page return ( - + ); diff --git a/src/content/users/ListView.tsx b/src/content/users/ListView.tsx index 6e16e6f5..f221448e 100644 --- a/src/content/users/ListView.tsx +++ b/src/content/users/ListView.tsx @@ -225,7 +225,7 @@ const ListingView: FC = () => { const { user } = useAuthContext(); const { state } = useLocation(); - const { data: orgData } = useOrganizationListContext(); + const { data: orgData, activeOrganizations } = useOrganizationListContext(); const [order, setOrder] = useState<"asc" | "desc">("asc"); const [orderBy, setOrderBy] = useState( @@ -327,7 +327,7 @@ const ListingView: FC = () => { inputProps={{ id: "organization-filter" }} > All - {orgData?.map((org: Organization) => ( + {activeOrganizations?.map((org: Organization) => ( {org.name} diff --git a/src/content/users/ProfileView.tsx b/src/content/users/ProfileView.tsx index e0df75f0..cf5ba890 100644 --- a/src/content/users/ProfileView.tsx +++ b/src/content/users/ProfileView.tsx @@ -17,8 +17,11 @@ import { Controller, useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import bannerSvg from "../../assets/banner/profile_banner.png"; import profileIcon from "../../assets/icons/profile_icon.svg"; -import { useAuthContext } from "../../components/Contexts/AuthContext"; -import { useOrganizationListContext } from "../../components/Contexts/OrganizationListContext"; +import { useAuthContext, Status as AuthStatus } from "../../components/Contexts/AuthContext"; +import { + Status as OrgStatus, + useOrganizationListContext, +} from "../../components/Contexts/OrganizationListContext"; import GenericAlert from "../../components/GenericAlert"; import SuspenseLoader from "../../components/SuspenseLoader"; import { OrgAssignmentMap, OrgRequiredRoles, Roles } from "../../config/AuthRoles"; @@ -176,21 +179,20 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { usePageTitle(viewType === "profile" ? "User Profile" : `Edit User ${_id}`); const navigate = useNavigate(); - const { data: orgData } = useOrganizationListContext(); - const { user: currentUser, setData, logout } = useAuthContext(); + const { data: orgData, activeOrganizations, status: orgStatus } = useOrganizationListContext(); + const { user: currentUser, setData, logout, status: authStatus } = useAuthContext(); + const { handleSubmit, register, reset, watch, setValue, control, formState } = + useForm(); const isSelf = _id === currentUser._id; - const [user, setUser] = useState( isSelf && viewType === "profile" ? { ...currentUser } : null ); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [changesAlert, setChangesAlert] = useState(""); - - const { handleSubmit, register, reset, watch, setValue, control, formState } = - useForm(); - + const userOrg = orgData?.find((org) => org._id === user?.organization?.orgID); + const [orgList, setOrgList] = useState[]>(undefined); const role = watch("role"); const orgFieldDisabled = useMemo( () => !OrgRequiredRoles.includes(role) && role !== "User", @@ -220,6 +222,20 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { fetchPolicy: "no-cache", }); + useEffect(() => { + if (!user || orgStatus === OrgStatus.LOADING) { + return; + } + if (userOrg?.status === "Inactive") { + setOrgList( + [...activeOrganizations, userOrg].sort((a, b) => a.name?.localeCompare(b.name || "")) + ); + return; + } + + setOrgList(activeOrganizations || []); + }, [activeOrganizations, userOrg, user, orgStatus]); + /** * Updates the default form values after save or initial fetch * @@ -279,6 +295,8 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { if (d.editUser.userStatus === "Inactive") { logout(); } + } else { + setUser((prevUser) => ({ ...prevUser, ...d.editUser })); } } @@ -326,7 +344,12 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { } }, [role]); - if (!user) { + if ( + !user || + orgStatus === OrgStatus.LOADING || + authStatus === AuthStatus.LOADING || + orgList === undefined + ) { return ; } @@ -473,7 +496,7 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { }} > {""} - {orgData?.map((org) => ( + {orgList?.map((org) => ( {org.name}