diff --git a/web/src/components/core/ProgressBackdrop.test.tsx b/web/src/components/core/ProgressBackdrop.test.tsx index 0f2db8de4f..c15ca8e10f 100644 --- a/web/src/components/core/ProgressBackdrop.test.tsx +++ b/web/src/components/core/ProgressBackdrop.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -85,9 +85,7 @@ describe("ProgressBackdrop", () => { }); }); - // Test skipped because rerender fails when using installerRender, - // caused by how InstallerProvider manages context. - it.skip("shows 'Refreshing data...' message temporarily", async () => { + it("shows 'Refreshing data...' message temporarily", async () => { // Start with active progress mockProgresses([ { @@ -117,9 +115,7 @@ describe("ProgressBackdrop", () => { expect(mockStartTracking).toHaveBeenCalled(); }); - // Test skipped because rerender fails when using installerRender, - // caused by how InstallerProvider manages context. - it.skip("hides backdrop after queries are refetched", async () => { + it("hides backdrop after queries are refetched", async () => { // Start with active progress mockProgresses([ { @@ -265,9 +261,7 @@ describe("ProgressBackdrop", () => { ); }); - // Test skipped because rerender fails when using installerRender, - // caused by how InstallerProvider manages context. - it.skip("starts tracking when progress finishes", async () => { + it("starts tracking when progress finishes", async () => { // Start with active progress mockProgresses([ { diff --git a/web/src/components/overview/OverviewPage.tsx b/web/src/components/overview/OverviewPage.tsx index 9ec97dc516..e0b6f3f37a 100644 --- a/web/src/components/overview/OverviewPage.tsx +++ b/web/src/components/overview/OverviewPage.tsx @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useDeferredValue, useState } from "react"; import { Navigate } from "react-router"; import { Button, @@ -51,7 +51,7 @@ import { _ } from "~/i18n"; import type { Product } from "~/types/software"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -import { useProgress } from "~/hooks/use-progress-tracking"; +import { useProgressTracking } from "~/hooks/use-progress-tracking"; type ConfirmationPopupProps = { product: Product; @@ -93,19 +93,15 @@ const ConfirmationPopup = ({ ); }; -export default function OverviewPage() { - const product = useProductInfo(); +const OverviewPageContent = ({ product }) => { const issues = useIssues(); - const progresses = useProgress(); + const { loading } = useProgressTracking(); + const isReady = useDeferredValue(!loading); const { actions } = useDestructiveActions(); const [showConfirmation, setShowConfirmation] = useState(false); const hasIssues = !isEmpty(issues); const hasDestructiveActions = actions.length > 0; - if (!product) { - return ; - } - const [buttonLocationStart, buttonLocationLabel, buttonLocationEnd] = _( // TRANSLATORS: This hint helps users locate the install button. Text inside // square brackets [] appears in bold. Keep brackets for proper formatting. @@ -123,10 +119,8 @@ export default function OverviewPage() { const onCancel = () => setShowConfirmation(false); - const isProposalReady = isEmpty(progresses); - const getInstallButtonText = () => { - if (hasIssues || !isProposalReady) return _("Install"); + if (hasIssues || !isReady) return _("Install"); if (hasDestructiveActions) return _("Install now with potential data loss"); return _("Install now"); }; @@ -169,20 +163,20 @@ export default function OverviewPage() { size="lg" variant={hasDestructiveActions ? "danger" : "primary"} onClick={onInstallClick} - isDisabled={hasIssues || !isProposalReady} + isDisabled={hasIssues || !isReady} > {getInstallButtonText()} - {!isProposalReady && ( + {!isReady && ( - + {_("Wait until current operations are completed.")} )} - {hasIssues && isProposalReady && ( + {hasIssues && isReady && ( {_( @@ -204,4 +198,14 @@ export default function OverviewPage() { )} ); +}; + +export default function OverviewPage() { + const product = useProductInfo(); + + if (!product) { + return ; + } + + return ; } diff --git a/web/src/hooks/model/progress.test.ts b/web/src/hooks/model/progress.test.ts new file mode 100644 index 0000000000..e15823813a --- /dev/null +++ b/web/src/hooks/model/progress.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { renderHook } from "@testing-library/react"; +import { mockProgresses } from "~/test-utils"; +import type { Progress } from "~/model/status"; +import { useProgress } from "./progress"; + +const fakeSoftwareProgress: Progress = { + scope: "software", + size: 3, + steps: [ + "Updating the list of repositories", + "Refreshing metadata from the repositories", + "Calculating the software proposal", + ], + step: "Updating the list of repositories", + index: 1, +}; + +const fakeStorageProgress: Progress = { + scope: "storage", + size: 3, + steps: [], + step: "Activating storage devices", + index: 1, +}; + +describe("useProgress", () => { + beforeEach(() => { + mockProgresses([fakeStorageProgress, fakeSoftwareProgress]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("returns all progresses when no scope is provided", () => { + const { result } = renderHook(() => useProgress()); + + expect(result.current).toEqual([fakeStorageProgress, fakeSoftwareProgress]); + }); + + it("returns the progress matching the given scope", () => { + const { result } = renderHook(() => useProgress("software")); + + expect(result.current).toBe(fakeSoftwareProgress); + }); + + it("returns undefined when the given scope has no progress", () => { + const { result } = renderHook(() => useProgress("network")); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/web/src/hooks/model/progress.ts b/web/src/hooks/model/progress.ts new file mode 100644 index 0000000000..9b93b7b377 --- /dev/null +++ b/web/src/hooks/model/progress.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { isUndefined } from "radashi"; +import { useStatus } from "./status"; + +import type { Progress, Scope } from "~/model/status"; + +/** + * Convenience hook that returns the currently active progress(es). + * + * - When no `scope` is provided, all active progress objects are returned. + * - When a `scope` is provided, only the progress associated with that scope + * is returned (or `undefined` if none exists). + * + * This hook is a lightweight wrapper around `useStatus`/`useState`. It is + * intentionally defined in a separate file/module to enable proper testing: + * consumers can mock progress data directly without depending on or + * re-implementing the internal logic, which would be fragile and error-prone. + * + * An example of this kind of indirect testing can be found in the + * `useProgressTracking` unit tests, which would not be possible if + * `useStatus` and `useProgress` lived in the same file/module. + */ +export function useProgress(scope?: undefined): Progress[]; +export function useProgress(scope: Scope): Progress | undefined; +export function useProgress(scope?: Scope): Progress[] | Progress | undefined { + const { progresses } = useStatus(); + + if (isUndefined(scope)) { + return progresses; + } + + return progresses.find((p) => p.scope === scope); +} diff --git a/web/src/hooks/use-progress-tracking.test.ts b/web/src/hooks/use-progress-tracking.test.ts index 98632343b5..d01a07bba3 100644 --- a/web/src/hooks/use-progress-tracking.test.ts +++ b/web/src/hooks/use-progress-tracking.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -20,32 +20,34 @@ * find current contact information at www.suse.com. */ +import { act } from "react"; import { renderHook, waitFor } from "@testing-library/react"; -import { useProgressTracking } from "./use-progress-tracking"; -import { useStatus } from "~/hooks/model/status"; +import { mockProgresses } from "~/test-utils"; import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; import type { Progress } from "~/model/status"; -import { act } from "react"; - -const mockProgressesFn: jest.Mock = jest.fn(); +import { useProgressTracking } from "./use-progress-tracking"; jest.mock("~/hooks/use-track-queries-refetch"); -jest.mock("~/hooks/model/status", () => ({ - ...jest.requireActual("~/hooks/model/status"), - useStatus: (): ReturnType => ({ - stage: "configuring", - progresses: mockProgressesFn(), - }), -})); - -const fakeProgress: Progress = { - index: 1, +const fakeSoftwareProgress: Progress = { scope: "software", size: 3, - steps: ["one", "two", "three"], - step: "two", + steps: [ + "Updating the list of repositories", + "Refreshing metadata from the repositories", + "Calculating the software proposal", + ], + step: "Updating the list of repositories", + index: 1, +}; + +const fakeStorageProgress: Progress = { + scope: "storage", + size: 3, + steps: [], + step: "Activating storage devices", + index: 1, }; describe("useProgressTracking", () => { @@ -61,8 +63,6 @@ describe("useProgressTracking", () => { mockRefetchCallback = callback; return { startTracking: mockStartTracking }; }); - - mockProgressesFn.mockReturnValue([]); }); afterEach(() => { @@ -76,70 +76,149 @@ describe("useProgressTracking", () => { expect(useTrackQueriesRefetch).toHaveBeenCalledWith(COMMON_PROPOSAL_KEYS, expect.any(Function)); }); - it("returns loading false when there is no active progress", () => { - const { result } = renderHook(() => useProgressTracking("software")); + describe("with a specific scope", () => { + it("returns loading false when there is no active progress", () => { + const { result } = renderHook(() => useProgressTracking("software")); - expect(result.current.loading).toBe(false); - expect(result.current.progress).toBeUndefined(); - }); + expect(result.current.loading).toBe(false); + expect(result.current.progress).toBeUndefined(); + }); - it("returns loading true when progress starts", () => { - mockProgressesFn.mockReturnValue([fakeProgress]); - const { result } = renderHook(() => useProgressTracking("software")); + it("returns loading false when there is active progress of different scope", () => { + mockProgresses([fakeStorageProgress]); + const { result } = renderHook(() => useProgressTracking("software")); - expect(result.current.loading).toBe(true); - expect(result.current.progress).toBe(fakeProgress); - }); + expect(result.current.loading).toBe(false); + expect(result.current.progress).toBeUndefined(); + }); - it("keeps loading true until all queries refetch after progress completes", async () => { - const { result, rerender } = renderHook(() => useProgressTracking("software")); + it("returns loading true when there is an active progress of gien scope", () => { + mockProgresses([fakeSoftwareProgress]); + const { result } = renderHook(() => useProgressTracking("software")); - // Start progress - mockProgressesFn.mockReturnValue([fakeProgress]); - rerender(); + expect(result.current.loading).toBe(true); + expect(result.current.progress).toBe(fakeSoftwareProgress); + }); - // Complete progress - jest.setSystemTime(1000); - mockProgressesFn.mockReturnValue([]); - rerender(); + it("keeps loading true until all queries refetch after progress completes", async () => { + const { result, rerender } = renderHook(() => useProgressTracking("software")); - await waitFor(() => { - expect(mockStartTracking).toHaveBeenCalledTimes(1); - }); + // Start progress + mockProgresses([fakeSoftwareProgress]); + rerender(); + + // Complete progress + jest.setSystemTime(1000); + mockProgresses([]); + rerender(); + + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalledTimes(1); + }); + + expect(result.current.loading).toBe(true); - expect(result.current.loading).toBe(true); + // Queries refetch after progress finished + jest.setSystemTime(2000); - // Queries refetch after progress finished - jest.setSystemTime(2000); + act(() => { + mockRefetchCallback(1000, 2000); + }); - act(() => { - mockRefetchCallback(1000, 2000); + expect(result.current.loading).toBe(false); }); - expect(result.current.loading).toBe(false); - }); + it("ignores query refetches completed before progress finished", async () => { + const { result, rerender } = renderHook(() => useProgressTracking("software")); + + // Start progress + mockProgresses([fakeSoftwareProgress]); + rerender(); + + // Complete progress + jest.setSystemTime(2000); + mockProgresses([]); + rerender(); - it("ignores query refetches completed before progress finished", async () => { - const { result, rerender } = renderHook(() => useProgressTracking("software")); + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalled(); + }); - // Start progress - mockProgressesFn.mockReturnValue([fakeProgress]); - rerender(); + // Queries refetched before progress finished, must be ignored + act(() => { + mockRefetchCallback(500, 1000); + }); - // Complete progress - jest.setSystemTime(2000); - mockProgressesFn.mockReturnValue([]); - rerender(); + expect(result.current.loading).toBe(true); + }); + }); + describe("without scope", () => { + it("returns loading false when there are no active progress", () => { + mockProgresses([]); + const { result } = renderHook(() => useProgressTracking()); - await waitFor(() => { - expect(mockStartTracking).toHaveBeenCalled(); + expect(result.current.loading).toBe(false); + expect(result.current.progress).toEqual([]); }); - // Queries refetched before progress finished, must be ignored - act(() => { - mockRefetchCallback(500, 1000); + it("returns loading true when there is an active progress", () => { + mockProgresses([fakeSoftwareProgress]); + const { result } = renderHook(() => useProgressTracking()); + + expect(result.current.loading).toBe(true); + expect(result.current.progress).toEqual([fakeSoftwareProgress]); }); - expect(result.current.loading).toBe(true); + it("keeps loading true until all queries refetch after progress completes", async () => { + const { result, rerender } = renderHook(() => useProgressTracking()); + + // Start progress + mockProgresses([fakeSoftwareProgress]); + rerender(); + + // Complete progress + jest.setSystemTime(1000); + mockProgresses([]); + rerender(); + + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalledTimes(1); + }); + + expect(result.current.loading).toBe(true); + + // Queries refetch after progress finished + jest.setSystemTime(2000); + + act(() => { + mockRefetchCallback(1000, 2000); + }); + + expect(result.current.loading).toBe(false); + }); + + it("ignores query refetches completed before progress finished", async () => { + const { result, rerender } = renderHook(() => useProgressTracking()); + + // Start progress + mockProgresses([fakeSoftwareProgress]); + rerender(); + + // Complete progress + jest.setSystemTime(2000); + mockProgresses([]); + rerender(); + + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalled(); + }); + + // Queries refetched before progress finished, must be ignored + act(() => { + mockRefetchCallback(500, 1000); + }); + + expect(result.current.loading).toBe(true); + }); }); }); diff --git a/web/src/hooks/use-progress-tracking.ts b/web/src/hooks/use-progress-tracking.ts index 2086c6cdfe..ec3a21719f 100644 --- a/web/src/hooks/use-progress-tracking.ts +++ b/web/src/hooks/use-progress-tracking.ts @@ -21,23 +21,11 @@ */ import { useEffect, useRef, useState } from "react"; -import { isUndefined } from "radashi"; +import { isEmpty } from "radashi"; import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; -import { useStatus } from "~/hooks/model/status"; +import { useProgress } from "~/hooks/model/progress"; import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; -import type { Progress, Scope } from "~/model/status"; - -export function useProgress(scope?: undefined): Progress[]; -export function useProgress(scope: Scope): Progress | undefined; -export function useProgress(scope?: Scope): Progress[] | Progress | undefined { - const { progresses } = useStatus(); - - if (isUndefined(scope)) { - return progresses; - } - - return progresses.find((p) => p.scope === scope); -} +import type { Scope } from "~/model/status"; /** * Custom hook that manages loading state for operations with progress tracking. @@ -113,14 +101,16 @@ export function useProgressTracking( } }); + const progressesFinished = scope ? !progress : isEmpty(progress); + useEffect(() => { - if (!progress && loading && !progressFinishedAtRef.current) { + if (progressesFinished && loading && !progressFinishedAtRef.current) { progressFinishedAtRef.current = Date.now(); startTracking(); } - }, [progress, startTracking, loading, progressFinishedAtRef]); + }, [progressesFinished, startTracking, loading, progressFinishedAtRef]); - if (progress && !loading) { + if (!progressesFinished && !loading) { setLoading(true); progressFinishedAtRef.current = null; }