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}
+
+ {data?.map((org, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ -
+ {org.name}
+
+ ))}
+
+
+ {activeOrganizations?.map((org, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ -
+ {org.name}
+
+ ))}
+
+
+ );
+};
+
+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" }}
>
- {orgData?.map((org: Organization) => (
+ {activeOrganizations?.map((org: Organization) => (
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) => (