diff --git a/apps/spruce/cypress/integration/projectSettings/repo_settings.ts b/apps/spruce/cypress/integration/projectSettings/repo_settings.ts index b93c1233bb..af6603b19f 100644 --- a/apps/spruce/cypress/integration/projectSettings/repo_settings.ts +++ b/apps/spruce/cypress/integration/projectSettings/repo_settings.ts @@ -297,11 +297,4 @@ describe("Repo Settings", () => { cy.dataCy("command-input").eq(1).should("have.value", "command 1"); }); }); - - describe("redirects", () => { - it("redirects to repo page from project settings if it's a repo", () => { - cy.visit(getProjectSettingsRoute(repo)); - cy.location("pathname").should("equal", `/${getRepoSettingsRoute(repo)}`); - }); - }); }); diff --git a/apps/spruce/src/hooks/useProjectRedirect/index.ts b/apps/spruce/src/hooks/useProjectRedirect/index.ts index e75ed9e4fa..2791bf5cf4 100644 --- a/apps/spruce/src/hooks/useProjectRedirect/index.ts +++ b/apps/spruce/src/hooks/useProjectRedirect/index.ts @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { useQuery } from "@apollo/client/react"; -import { useParams, useLocation, useNavigate } from "react-router-dom"; +import { skipToken, useQuery } from "@apollo/client/react"; +import { useParams } from "react-router-dom"; +import { useErrorToast, useQueryCompleted } from "@evg-ui/lib/hooks"; import { slugs } from "constants/routes"; import { ProjectQuery, ProjectQueryVariables } from "gql/generated/types"; import { PROJECT } from "gql/queries"; @@ -9,76 +9,57 @@ import { validators } from "utils"; const { validateObjectId } = validators; interface UseProjectRedirectProps { - sendAnalyticsEvent: (projectId: string, projectIdentifier: string) => void; - shouldRedirect?: boolean; - onError?: (repoId: string) => void; + onRedirect?: (projectId: string, projectIdentifier: string) => void; } /** * useProjectRedirect will replace the project id with the project identifier in the URL. * @param props - Object containing the following: - * @param props.sendAnalyticsEvent - analytics event to send upon redirect - * @param props.shouldRedirect - boolean to indicate if a redirect should be attempted - * @param props.onError - function to call if an error occurs during the redirect - * @returns isRedirecting - boolean to indicate if a redirect is in progress + * @param props.onRedirect - callback to call when a redirect is about to occur + * @returns + * - redirectIdentifier: the project identifier to redirect to + * - needsRedirect: boolean indicating whether a redirect is needed + * - loading: boolean indicating whether the query is still loading + * - error: any error that occurred during the query */ export const useProjectRedirect = ({ - onError, - sendAnalyticsEvent = () => {}, - shouldRedirect, -}: UseProjectRedirectProps) => { + onRedirect, +}: UseProjectRedirectProps = {}) => { const { [slugs.projectIdentifier]: projectIdentifier = "" } = useParams(); - const navigate = useNavigate(); - const location = useLocation(); - - const needsRedirect = validateObjectId(projectIdentifier) && shouldRedirect; - - const [attemptedRedirect, setAttemptedRedirect] = useState(false); - const hasRedirected = useRef(false); + const needsRedirect = validateObjectId(projectIdentifier); const { data, error, loading } = useQuery< ProjectQuery, ProjectQueryVariables - >(PROJECT, { - skip: !needsRedirect, - variables: { - idOrIdentifier: projectIdentifier, - }, - }); + >( + PROJECT, + needsRedirect + ? { + variables: { + idOrIdentifier: projectIdentifier, + }, + } + : skipToken, + ); - // Reset redirect flag when project changes - useEffect(() => { - hasRedirected.current = false; - setAttemptedRedirect(false); - }, [projectIdentifier]); + const redirectIdentifier = data?.project?.identifier; - // Handle successful redirect - useEffect(() => { - if (data?.project && !hasRedirected.current) { - hasRedirected.current = true; - const { identifier } = data.project; - const currentUrl = location.pathname.concat(location.search); - const redirectPathname = currentUrl.replace( - projectIdentifier, - identifier, - ); - sendAnalyticsEvent(projectIdentifier, identifier); - navigate(redirectPathname, { replace: true }); - setAttemptedRedirect(true); + const onRedirectCallback = () => { + if (onRedirect && redirectIdentifier) { + onRedirect(projectIdentifier, redirectIdentifier); } - }, [data, location, navigate, projectIdentifier, sendAnalyticsEvent]); + }; - // Handle error - useEffect(() => { - if (error && !hasRedirected.current) { - hasRedirected.current = true; - setAttemptedRedirect(true); - onError?.(projectIdentifier ?? ""); - } - }, [error, onError, projectIdentifier]); + useErrorToast( + error, + `Failed to redirect to project identifier for project '${projectIdentifier}'`, + ); + useQueryCompleted(loading, onRedirectCallback); return { - isRedirecting: needsRedirect && loading, - attemptedRedirect, + redirectIdentifier, + needsRedirect, + loading, + error, }; }; diff --git a/apps/spruce/src/hooks/useProjectRedirect/useProjectRedirect.test.tsx b/apps/spruce/src/hooks/useProjectRedirect/useProjectRedirect.test.tsx index ab8912d8f1..74a0047123 100644 --- a/apps/spruce/src/hooks/useProjectRedirect/useProjectRedirect.test.tsx +++ b/apps/spruce/src/hooks/useProjectRedirect/useProjectRedirect.test.tsx @@ -1,183 +1,139 @@ import { GraphQLError } from "graphql"; -import { MemoryRouter, Routes, Route, useLocation } from "react-router-dom"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { RenderFakeToastContext } from "@evg-ui/lib/context/toast/__mocks__"; import { MockedProvider, renderHook, waitFor } from "@evg-ui/lib/test_utils"; import { ApolloMock } from "@evg-ui/lib/test_utils/types"; import { ProjectQuery, ProjectQueryVariables } from "gql/generated/types"; import { PROJECT } from "gql/queries"; import { useProjectRedirect } from "."; -const useJointHook = (props: Parameters[0]) => { - const { attemptedRedirect, isRedirecting } = useProjectRedirect({ ...props }); - const { pathname, search } = useLocation(); - return { isRedirecting, pathname, search, attemptedRedirect }; -}; - const ProviderWrapper: React.FC<{ children: React.ReactNode; location: string; mocks?: ApolloMock[]; -}> = ({ children, location, mocks = [repoMock, projectMock] }) => ( - - - - - - - -); +}> = ({ children, location, mocks = [projectMock] }) => { + const { Component } = RenderFakeToastContext(
{children}
); + return ( + + + + } + path="/project/:projectIdentifier/settings" + /> + + + + ); +}; describe("useProjectRedirect", () => { - it("should not redirect if URL has project identifier", async () => { - const sendAnalyticsEvent = vi.fn(); - const onError = vi.fn(); - const { result } = renderHook( - () => useJointHook({ sendAnalyticsEvent, onError, shouldRedirect: true }), - { - wrapper: ({ children }) => - ProviderWrapper({ - children, - location: "/project/my-project/settings", - }), - }, - ); - expect(result.current).toMatchObject({ - isRedirecting: false, - attemptedRedirect: false, - pathname: "/project/my-project/settings", - search: "", + it("should not need redirect if URL has project identifier", () => { + const { result } = renderHook(() => useProjectRedirect(), { + wrapper: ({ children }) => + ProviderWrapper({ + children, + location: "/project/my-project/settings", + }), }); - expect(sendAnalyticsEvent).toHaveBeenCalledTimes(0); + expect(result.current.needsRedirect).toBe(false); + expect(result.current.redirectIdentifier).toBeUndefined(); + expect(result.current.loading).toBe(false); }); - it("should redirect if URL has project ID", async () => { - const sendAnalyticsEvent = vi.fn(); - const onError = vi.fn(); - - const { result } = renderHook( - () => useJointHook({ sendAnalyticsEvent, onError, shouldRedirect: true }), - { - wrapper: ({ children }) => - ProviderWrapper({ - children, - location: `/project/${projectId}/settings`, - mocks: [projectMock], - }), - }, - ); - expect(result.current).toMatchObject({ - isRedirecting: true, - attemptedRedirect: false, - pathname: "/project/5f74d99ab2373627c047c5e5/settings", - search: "", + it("should need redirect and fetch data if URL has project ID", async () => { + const { result } = renderHook(() => useProjectRedirect(), { + wrapper: ({ children }) => + ProviderWrapper({ + children, + location: `/project/${projectId}/settings`, + mocks: [projectMock], + }), }); + expect(result.current.needsRedirect).toBe(true); + expect(result.current.loading).toBe(true); + expect(result.current.redirectIdentifier).toBeUndefined(); + + // Check values again after query finishes. await waitFor(() => { - expect(result.current.pathname).toBe("/project/my-project/settings"); + expect(result.current.loading).toBe(false); }); - expect(result.current.isRedirecting).toBe(false); - expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1); - expect(sendAnalyticsEvent).toHaveBeenCalledWith( - "5f74d99ab2373627c047c5e5", - "my-project", - ); + expect(result.current.redirectIdentifier).toBe("my-project"); + expect(result.current.error).toBeUndefined(); }); - it("should not redirect if shouldRedirect is disabled", async () => { - const sendAnalyticsEvent = vi.fn(); - const onError = vi.fn(); - - const { result } = renderHook( - () => - useJointHook({ sendAnalyticsEvent, onError, shouldRedirect: false }), - { - wrapper: ({ children }) => - ProviderWrapper({ - children, - location: `/project/${projectId}/settings`, - }), - }, - ); - expect(result.current).toMatchObject({ - isRedirecting: false, - attemptedRedirect: false, - pathname: "/project/5f74d99ab2373627c047c5e5/settings", - search: "", + it("should call onRedirect callback when redirect identifier is available", async () => { + const onRedirect = vi.fn(); + renderHook(() => useProjectRedirect({ onRedirect }), { + wrapper: ({ children }) => + ProviderWrapper({ + children, + location: `/project/${projectId}/settings`, + mocks: [projectMock], + }), }); + expect(onRedirect).not.toHaveBeenCalled(); await waitFor(() => { - expect(result.current).toMatchObject({ - isRedirecting: false, - attemptedRedirect: false, - pathname: "/project/5f74d99ab2373627c047c5e5/settings", - search: "", - }); + expect(onRedirect).toHaveBeenCalledTimes(1); }); - expect(sendAnalyticsEvent).toHaveBeenCalledTimes(0); + expect(onRedirect).toHaveBeenCalledWith(projectId, "my-project"); }); - it("should preserve query params when redirecting", async () => { - const sendAnalyticsEvent = vi.fn(); - const onError = vi.fn(); - - const { result } = renderHook( - () => useJointHook({ sendAnalyticsEvent, onError, shouldRedirect: true }), - { - wrapper: ({ children }) => - ProviderWrapper({ - children, - location: `/project/${projectId}/settings?taskName=thirdparty`, - mocks: [projectMock], - }), - }, - ); - expect(result.current).toMatchObject({ - isRedirecting: true, - attemptedRedirect: false, - pathname: "/project/5f74d99ab2373627c047c5e5/settings", - search: "?taskName=thirdparty", + it("should not call onRedirect if no redirect is needed", async () => { + const onRedirect = vi.fn(); + renderHook(() => useProjectRedirect({ onRedirect }), { + wrapper: ({ children }) => + ProviderWrapper({ + children, + location: "/project/my-project/settings", + }), }); - await waitFor(() => { - expect(result.current.pathname).toBe("/project/my-project/settings"); - expect(result.current.search).toBe("?taskName=thirdparty"); + // Wait a bit to ensure the callback doesn't get called. + await new Promise((resolve) => { + setTimeout(resolve, 100); }); - expect(result.current.isRedirecting).toBe(false); - expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1); - expect(sendAnalyticsEvent).toHaveBeenCalledWith( - "5f74d99ab2373627c047c5e5", - "my-project", - ); + expect(onRedirect).not.toHaveBeenCalled(); }); - it("should attempt redirect if URL has repo ID but stop attempting after query", async () => { - const sendAnalyticsEvent = vi.fn(); - const onError = vi.fn(); - - const { result } = renderHook( - () => useJointHook({ sendAnalyticsEvent, onError, shouldRedirect: true }), - { - wrapper: ({ children }) => - ProviderWrapper({ - children, - location: `/project/${repoId}/settings`, - mocks: [repoMock], - }), - }, - ); - expect(result.current).toMatchObject({ - isRedirecting: true, - attemptedRedirect: false, - pathname: "/project/5e6bb9e23066155a993e0f1a/settings", - search: "", + it("should handle query errors gracefully", async () => { + const { result } = renderHook(() => useProjectRedirect(), { + wrapper: ({ children }) => + ProviderWrapper({ + children, + location: `/project/${repoId}/settings`, + mocks: [repoMock], + }), }); + expect(result.current.needsRedirect).toBe(true); + expect(result.current.loading).toBe(true); await waitFor(() => { - expect(result.current.isRedirecting).toBe(false); + expect(result.current.loading).toBe(false); }); - await waitFor(() => { - expect(result.current.attemptedRedirect).toBe(true); + expect(result.current.error).toBeDefined(); + expect(result.current.redirectIdentifier).toBeUndefined(); + }); + + it("should only call onRedirect once per redirect", async () => { + const onRedirect = vi.fn(); + const { rerender } = renderHook(() => useProjectRedirect({ onRedirect }), { + wrapper: ({ children }) => + ProviderWrapper({ + children, + location: `/project/${projectId}/settings`, + mocks: [projectMock], + }), }); + await waitFor(() => { - expect(onError).toHaveBeenCalledTimes(1); + expect(onRedirect).toHaveBeenCalledTimes(1); }); - expect(onError).toHaveBeenCalledWith(repoId); - expect(sendAnalyticsEvent).toHaveBeenCalledTimes(0); + + // Force re-renders shouldn't trigger the callback again. + rerender(); + rerender(); + rerender(); + + expect(onRedirect).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/spruce/src/pages/projectAndRepoSettings/ProjectSettings.tsx b/apps/spruce/src/pages/projectAndRepoSettings/ProjectSettings.tsx index a24b1e19c0..e0952f7db1 100644 --- a/apps/spruce/src/pages/projectAndRepoSettings/ProjectSettings.tsx +++ b/apps/spruce/src/pages/projectAndRepoSettings/ProjectSettings.tsx @@ -1,13 +1,12 @@ -import { useEffect, useRef } from "react"; import { useQuery } from "@apollo/client/react"; -import { useParams, useNavigate } from "react-router-dom"; +import { Navigate, useParams } from "react-router-dom"; import { useToastContext } from "@evg-ui/lib/context/toast"; -import { useErrorToast } from "@evg-ui/lib/hooks"; +import { useErrorToast, useQueryCompleted } from "@evg-ui/lib/hooks"; import { usePageTitle } from "@evg-ui/lib/hooks/usePageTitle"; import { useProjectSettingsAnalytics } from "analytics"; import { + getProjectSettingsRoute, ProjectSettingsTabRoutes, - getRepoSettingsRoute, slugs, } from "constants/routes"; import { @@ -18,13 +17,9 @@ import { } from "gql/generated/types"; import { PROJECT_SETTINGS, REPO_SETTINGS } from "gql/queries"; import { useProjectRedirect } from "hooks/useProjectRedirect"; -import { validators } from "utils"; import SharedSettings from "./shared"; -import { projectOnlyTabs } from "./shared/tabs/types"; import { ProjectType } from "./shared/tabs/utils"; -const { validateObjectId } = validators; - const ProjectSettings: React.FC = () => { const { sendEvent } = useProjectSettingsAnalytics(); const dispatchToast = useToastContext(); @@ -35,26 +30,11 @@ const ProjectSettings: React.FC = () => { [slugs.projectIdentifier]: string; [slugs.tab]: ProjectSettingsTabRoutes; }>(); - usePageTitle(`Project Settings | ${projectIdentifier}`); - const navigate = useNavigate(); - // If the path includes an Object ID, we should redirect the user so that they use the identifier. - const identifierIsObjectId = validateObjectId(projectIdentifier); + usePageTitle(`Project Settings | ${projectIdentifier}`); - useProjectRedirect({ - shouldRedirect: identifierIsObjectId, - onError: (repoId) => { - // DEVPROD-18977: Redirect can be removed once the repo settings URL change has been baked in long enough. - navigate( - getRepoSettingsRoute( - repoId, - tab && projectOnlyTabs.has(tab) - ? ProjectSettingsTabRoutes.General - : tab, - ), - ); - }, - sendAnalyticsEvent: (projectId: string, identifier: string) => { + const { needsRedirect, redirectIdentifier } = useProjectRedirect({ + onRedirect: (projectId, identifier) => { sendEvent({ name: "Redirected to project identifier", "project.id": projectId, @@ -70,7 +50,7 @@ const ProjectSettings: React.FC = () => { } = useQuery( PROJECT_SETTINGS, { - skip: identifierIsObjectId || !projectIdentifier, + skip: needsRedirect || !projectIdentifier, variables: { projectIdentifier }, }, ); @@ -79,22 +59,12 @@ const ProjectSettings: React.FC = () => { `There was an error loading the project ${projectIdentifier}`, ); - // Show error toast if project is hidden - const hasShownHiddenToast = useRef(false); - useEffect(() => { - // Reset ref when project changes - hasShownHiddenToast.current = false; - }, [projectIdentifier]); - - useEffect(() => { - if ( - projectData?.projectSettings?.projectRef?.hidden && - !hasShownHiddenToast.current - ) { - hasShownHiddenToast.current = true; + // Show error toast if project is hidden. + useQueryCompleted(projectLoading, () => { + if (projectData?.projectSettings?.projectRef?.hidden) { dispatchToast.error(`Project is hidden.`); } - }, [projectData?.projectSettings?.projectRef?.hidden, dispatchToast]); + }); const projectIsHidden = projectData?.projectSettings?.projectRef?.hidden; const repoId = projectData?.projectSettings?.projectRef?.repoRefId ?? ""; @@ -113,6 +83,10 @@ const ProjectSettings: React.FC = () => { return null; } + if (needsRedirect && redirectIdentifier) { + return ; + } + const projectType = repoId ? ProjectType.AttachedProject : ProjectType.Project; diff --git a/apps/spruce/src/pages/variantHistory/ColumnHeaders.tsx b/apps/spruce/src/pages/variantHistory/ColumnHeaders.tsx index 7a43674225..778a5ee226 100644 --- a/apps/spruce/src/pages/variantHistory/ColumnHeaders.tsx +++ b/apps/spruce/src/pages/variantHistory/ColumnHeaders.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef } from "react"; import { useQuery } from "@apollo/client/react"; import styled from "@emotion/styled"; import { useToastContext } from "@evg-ui/lib/context/toast"; +import { useQueryCompleted } from "@evg-ui/lib/hooks"; import { reportError } from "@evg-ui/lib/utils/errorReporting"; import { trimStringFromMiddle } from "@evg-ui/lib/utils/string"; import { useProjectHistoryAnalytics } from "analytics/projectHistory/useProjectHistoryAnalytics"; @@ -30,7 +30,6 @@ const ColumnHeaders: React.FC = ({ }) => { const { sendEvent } = useProjectHistoryAnalytics({ page: "Variant history" }); const dispatchToast = useToastContext(); - const hasReportedError = useRef(false); // Fetch the column headers from the same query used on the dropdown. const { data: columnData, loading } = useQuery< @@ -43,20 +42,15 @@ const ColumnHeaders: React.FC = ({ }, }); - // Reset error flag when project or variant changes - useEffect(() => { - hasReportedError.current = false; - }, [projectIdentifier, variantName]); - - // Handle empty results - const taskNamesForBuildVariant = columnData?.taskNamesForBuildVariant; - useEffect(() => { - if (columnData && !taskNamesForBuildVariant && !hasReportedError.current) { - hasReportedError.current = true; + // Handle empty results when query completes. + useQueryCompleted(loading, () => { + if (columnData && !columnData.taskNamesForBuildVariant) { reportError(new Error("No task names found for build variant")).warning(); dispatchToast.error(`No tasks found for build variant: ${variantName}}`); } - }, [columnData, taskNamesForBuildVariant, dispatchToast, variantName]); + }); + + const taskNamesForBuildVariant = columnData?.taskNamesForBuildVariant; // @ts-expect-error: FIXME. This comment was added by an automated script. const { columnLimit, visibleColumns } = useHistoryTable(); diff --git a/apps/spruce/src/pages/variantHistory/index.tsx b/apps/spruce/src/pages/variantHistory/index.tsx index 7258a5854b..84bc56e01f 100644 --- a/apps/spruce/src/pages/variantHistory/index.tsx +++ b/apps/spruce/src/pages/variantHistory/index.tsx @@ -1,10 +1,9 @@ -import { useEffect, useRef } from "react"; import { skipToken, useQuery } from "@apollo/client/react"; import styled from "@emotion/styled"; import { H2 } from "@leafygreen-ui/typography"; import { useParams } from "react-router-dom"; import { size } from "@evg-ui/lib/constants/tokens"; -import { useErrorToast } from "@evg-ui/lib/hooks"; +import { useErrorToast, useQueryCompleted } from "@evg-ui/lib/hooks"; import { usePageTitle } from "@evg-ui/lib/hooks/usePageTitle"; import { leaveBreadcrumb, @@ -76,10 +75,9 @@ const VariantHistoryContents: React.FC = () => { : skipToken, ); - const prevLoadingRef = useRef(loading); - useEffect(() => { - // Trigger only when loading transitions from true to false (query completed). - if (prevLoadingRef.current && !loading && data?.mainlineCommits) { + // Ingest new commits when query completes. + useQueryCompleted(loading, () => { + if (data?.mainlineCommits) { leaveBreadcrumb( "Loaded more commits for variant history", { @@ -91,8 +89,7 @@ const VariantHistoryContents: React.FC = () => { ); ingestNewCommits(data.mainlineCommits); } - prevLoadingRef.current = loading; - }, [loading, data, projectIdentifier, variantName, ingestNewCommits]); + }); useErrorToast(error, "There was an error loading the variant history"); diff --git a/packages/lib/src/hooks/index.ts b/packages/lib/src/hooks/index.ts index f92390cd79..ed5189778c 100644 --- a/packages/lib/src/hooks/index.ts +++ b/packages/lib/src/hooks/index.ts @@ -3,4 +3,5 @@ export { useKeyboardShortcut } from "./useKeyboardShortcut"; export { useOnClickOutside } from "./useOnClickOutside"; export { usePageTitle } from "./usePageTitle"; export { usePrevious } from "./usePrevious"; +export { useQueryCompleted } from "./useQueryCompleted"; export { useQueryParam, useQueryParams } from "./useQueryParam"; diff --git a/packages/lib/src/hooks/useQueryCompleted/index.ts b/packages/lib/src/hooks/useQueryCompleted/index.ts new file mode 100644 index 0000000000..06388c5c2c --- /dev/null +++ b/packages/lib/src/hooks/useQueryCompleted/index.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from "react"; + +/** + * useQueryCompleted executes a callback when a GraphQL query completes. + * It tracks whether the callback has been executed to avoid duplicate calls. + * This is a replacement for Apollo's deprecated onCompleted callback. + * @param loading - The loading state from useQuery or useLazyQuery + * @param callback - The function to execute when the query completes + * @example + * const { data, loading } = useQuery(MY_QUERY); + * useQueryCompleted(loading, () => { + * console.log("Query completed"); + * }); + */ +export const useQueryCompleted = (loading: boolean, callback: () => void) => { + const hasCompletedRef = useRef(false); + const wasLoadingRef = useRef(loading); + + useEffect(() => { + // When loading transitions from true to false, execute the callback. + if (wasLoadingRef.current && !loading && !hasCompletedRef.current) { + hasCompletedRef.current = true; + callback(); + } + + // Track loading state for next render. + wasLoadingRef.current = loading; + + // Reset when query starts loading again (e.g. from refetch). + if (loading && hasCompletedRef.current) { + hasCompletedRef.current = false; + } + }, [loading, callback]); +}; diff --git a/packages/lib/src/hooks/useQueryCompleted/useQueryCompleted.test.tsx b/packages/lib/src/hooks/useQueryCompleted/useQueryCompleted.test.tsx new file mode 100644 index 0000000000..7680ad8a91 --- /dev/null +++ b/packages/lib/src/hooks/useQueryCompleted/useQueryCompleted.test.tsx @@ -0,0 +1,84 @@ +import { MockedProvider, renderHook } from "test_utils"; +import { useQueryCompleted } from "."; + +const Provider = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useQueryCompleted", () => { + it("should not call callback when loading is false initially", () => { + const callback = vi.fn(); + renderHook(() => useQueryCompleted(false, callback), { + wrapper: Provider, + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should not call callback when loading is true", () => { + const callback = vi.fn(); + renderHook(() => useQueryCompleted(true, callback), { + wrapper: Provider, + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should call callback when loading transitions from true to false", () => { + const callback = vi.fn(); + const { rerender } = renderHook( + ({ loading }) => useQueryCompleted(loading, callback), + { + wrapper: Provider, + initialProps: { loading: true }, + }, + ); + expect(callback).not.toHaveBeenCalled(); + + // Transition to loaded state. + rerender({ loading: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should not call callback multiple times on re-renders", () => { + const callback = vi.fn(); + const { rerender } = renderHook( + ({ loading }) => useQueryCompleted(loading, callback), + { + wrapper: Provider, + initialProps: { loading: true }, + }, + ); + + // Transition to loaded state. + rerender({ loading: false }); + expect(callback).toHaveBeenCalledTimes(1); + + // Re-render multiple times with same loading state. + rerender({ loading: false }); + rerender({ loading: false }); + rerender({ loading: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should call callback again when query refetches", () => { + const callback = vi.fn(); + const { rerender } = renderHook( + ({ loading }) => useQueryCompleted(loading, callback), + { + wrapper: Provider, + initialProps: { loading: true }, + }, + ); + + // Transition to loaded state. + rerender({ loading: false }); + expect(callback).toHaveBeenCalledTimes(1); + + // Query refetches (loading becomes true again). + rerender({ loading: true }); + expect(callback).toHaveBeenCalledTimes(1); + + // Refetch completes. + rerender({ loading: false }); + expect(callback).toHaveBeenCalledTimes(2); + }); +});