diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 6e918c1d45..447ffd0385 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,11 @@ +------------------------------------------------------------------- +Tue Mar 3 10:09:09 UTC 2026 - David Diaz + +- Restores DASD format progress with API v2 event model + (gh#agama-project/agama#3143). +- Improves progress backdrop layout and readbility + (gh#agama-project/agama#2947). + ------------------------------------------------------------------- Mon Mar 2 23:06:08 UTC 2026 - David Diaz diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 9847f66651..1fe088ba7e 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -188,14 +188,18 @@ strong { overflow: hidden; } +// ProgressBackdrop overlay styles .pf-v6-c-page__main-container:has(.agm-main-content-overlay) { position: relative; .agm-main-content-overlay { position: absolute; - padding-block-start: 2.2rem; backdrop-filter: blur(2px); - background-color: color-mix(in srgb, var(--agm-t--color--fog) 50%, transparent); + background-color: color-mix( + in srgb, + var(--pf-t--global--background--color--backdrop--default) 80%, + transparent + ); } } diff --git a/web/src/components/core/ProgressBackdrop.test.tsx b/web/src/components/core/ProgressBackdrop.test.tsx index c15ca8e10f..0ea4417434 100644 --- a/web/src/components/core/ProgressBackdrop.test.tsx +++ b/web/src/components/core/ProgressBackdrop.test.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { screen, waitFor, within } from "@testing-library/react"; +import { act, screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockProgresses } from "~/test-utils"; import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; @@ -85,7 +85,7 @@ describe("ProgressBackdrop", () => { }); }); - it("shows 'Refreshing data...' message temporarily", async () => { + it("shows 'Refreshing data...' message temporarily by default", async () => { // Start with active progress mockProgresses([ { @@ -115,6 +115,33 @@ describe("ProgressBackdrop", () => { expect(mockStartTracking).toHaveBeenCalled(); }); + it("shows custom `waitingLabel` when provided", async () => { + mockProgresses([ + { + scope: "storage", + step: "Calculating proposal", + steps: ["Calculating proposal"], + index: 1, + size: 1, + }, + ]); + + const { rerender } = installerRender( + , + ); + + const backdrop = screen.getByRole("alert", { name: /Calculating proposal/ }); + + mockProgresses([]); + rerender(); + + await waitFor(() => { + within(backdrop).getByText(/Applying storage settings/); + }); + + expect(within(backdrop).queryByText(/Refreshing data/)).toBeNull(); + }); + it("hides backdrop after queries are refetched", async () => { // Start with active progress mockProgresses([ @@ -143,7 +170,9 @@ describe("ProgressBackdrop", () => { // Simulate queries completing by calling the callback const startedAt = Date.now(); - mockCallback(startedAt, startedAt + 100); + act(() => { + mockCallback(startedAt, startedAt + 100); + }); // Backdrop should be hidden await waitFor(() => { @@ -289,4 +318,24 @@ describe("ProgressBackdrop", () => { }); }); }); + describe("when extraContent is provided", () => { + it("renders the extra content below the progress information", () => { + mockProgresses([ + { + scope: "software", + step: "Installing packages", + steps: [], + index: 1, + size: 3, + }, + ]); + + installerRender( + Extra content} />, + ); + + const backdrop = screen.getByRole("alert", { name: /Installing packages/ }); + within(backdrop).getByText("Extra content"); + }); + }); }); diff --git a/web/src/components/core/ProgressBackdrop.tsx b/web/src/components/core/ProgressBackdrop.tsx index 0e4ced567e..464ef13f14 100644 --- a/web/src/components/core/ProgressBackdrop.tsx +++ b/web/src/components/core/ProgressBackdrop.tsx @@ -21,14 +21,29 @@ */ import React from "react"; -import { Alert, Backdrop, Flex, FlexItem, Spinner } from "@patternfly/react-core"; import { concat } from "radashi"; import { sprintf } from "sprintf-js"; +import { + Alert, + Backdrop, + Card, + CardBody, + CardTitle, + Flex, + FlexItem, + Spinner, +} from "@patternfly/react-core"; +import NestedContent from "~/components/core/NestedContent"; import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; -import type { Scope } from "~/model/status"; +import { useProgressTracking } from "~/hooks/use-progress-tracking"; import { _ } from "~/i18n"; + +import type { Scope } from "~/model/status"; + import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -import { useProgressTracking } from "~/hooks/use-progress-tracking"; +import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import shadowStyles from "@patternfly/react-styles/css/utilities/BoxShadow/box-shadow"; /** * Props for the ProgressBackdrop component. @@ -40,7 +55,6 @@ export type ProgressBackdropProps = { * displayed. */ scope: Scope; - /** * Additional query keys to track during progress operations. * @@ -64,6 +78,26 @@ export type ProgressBackdropProps = { * > */ ensureRefetched?: string | string[]; + /** + * Additional content to render below the progress information. + * + * Use this to display extra UI within the backdrop overlay, such as + * per-device progress details for long-running operations. + * + * @example + * } /> + */ + extraContent?: React.ReactNode; + /** + * Label displayed when no active progress step is available but the backdrop + * is still visible because queries have not finished refetching yet. + * + * Defaults to `"Refreshing data..."` if not provided. + * + * @example + * + */ + waitingLabel?: string; }; /** @@ -84,6 +118,10 @@ export type ProgressBackdropProps = { export default function ProgressBackdrop({ scope, ensureRefetched, + extraContent, + // TRANSLATORS: Message shown next to a spinner while the UI is being updated + // after an operation has completed. + waitingLabel = _("Refreshing data..."), }: ProgressBackdropProps): React.ReactNode { const { loading: isBlocked, progress } = useProgressTracking( scope, @@ -93,31 +131,65 @@ export default function ProgressBackdrop({ if (!isBlocked) return null; return ( - - } - title={ - - - - {progress ? ( - <> - {progress.step}{" "} - {sprintf(_("(step %s of %s)"), progress.index, progress.size)} - - ) : ( - <>{_("Refreshing data...")} - )} - - - } - /> + + + + + } + title={ + + + + {progress ? ( + <> + {progress.step}{" "} + + {sprintf(_("(step %s of %s)"), progress.index, progress.size)} + + + ) : ( + <>{waitingLabel} + )} + + + } + /> + + + {extraContent && {extraContent}} + + + ); } diff --git a/web/src/components/storage/dasd/DASDFormatProgress.test.tsx b/web/src/components/storage/dasd/DASDFormatProgress.test.tsx index 9815d64aca..073d8003eb 100644 --- a/web/src/components/storage/dasd/DASDFormatProgress.test.tsx +++ b/web/src/components/storage/dasd/DASDFormatProgress.test.tsx @@ -21,89 +21,112 @@ */ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, act } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import type { Device } from "~/model/system/dasd"; - import DASDFormatProgress from "./DASDFormatProgress"; -// FIXME: adapt to new API -type FormatSummary = { - total: number; - step: number; - done: boolean; -}; - -type FormatJob = { - jobId: string; - summary?: { [key: string]: FormatSummary }; -}; - -/* eslint-disable @typescript-eslint/no-unused-vars */ -let mockDASDFormatJobs: FormatJob[]; -let mockDASDDevices: Device[]; - -// Skipped during migration to v2 -describe.skip("DASDFormatProgress", () => { - describe("when there is already some progress", () => { - beforeEach(() => { - mockDASDFormatJobs = [ - { - jobId: "0.0.0200", - summary: { - "0.0.0200": { - total: 5, - step: 1, - done: false, - }, - }, - }, - ]; - - mockDASDDevices = [ - { - channel: "0.0.0200", - active: false, - deviceName: "dasda", - type: "eckd", - formatted: false, - diag: false, - status: "active", - accessType: "rw", - partitionInfo: "1", - }, - ]; - }); +const mockOnEvent = jest.fn(); - it("renders the progress", () => { - installerRender(); - expect(screen.queryByRole("progressbar")).toBeInTheDocument(); - screen.getByText("0.0.0200 - dasda"); +jest.mock("~/context/installer", () => ({ + useInstallerClient: () => ({ + onEvent: mockOnEvent, + }), +})); + +describe("DASDFormatProgress", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("when there are no signal progress events", () => { + it("renders nothing", () => { + mockOnEvent.mockImplementation(() => jest.fn()); + + const { container } = installerRender(); + + expect(container).toBeEmptyDOMElement(); }); }); - describe("when there are no running jobs", () => { - beforeEach(() => { - mockDASDFormatJobs = []; - - mockDASDDevices = [ - { - channel: "0.0.0200", - active: false, - deviceName: "dasda", - type: "eckd", - formatted: false, - diag: false, - status: "active", - accessType: "rw", - partitionInfo: "1", - }, - ]; + describe("when signal contains empty summary", () => { + it("renders nothing", () => { + let eventCallback: (event: unknown) => void; + + mockOnEvent.mockImplementation((cb) => { + eventCallback = cb; + return jest.fn(); + }); + + const { container } = installerRender(); + + act(() => { + eventCallback({ + type: "DASDFormatChanged", + summary: [], + }); + }); + + expect(container).toBeEmptyDOMElement(); }); + }); + + describe("when there is progress", () => { + it("renders progress bars sorted by channel after DASDFormatChanged event", () => { + let eventCallback: (event: unknown) => void; + + mockOnEvent.mockImplementation((cb) => { + eventCallback = cb; + return jest.fn(); + }); - it("does not render any progress", () => { installerRender(); - expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + + act(() => { + eventCallback({ + type: "DASDFormatChanged", + summary: [ + { channel: "0.0.0500", totalCylinders: 5, formattedCylinders: 1, finished: false }, + { channel: "0.0.0160", totalCylinders: 5, formattedCylinders: 5, finished: true }, + { channel: "0.0.0200", totalCylinders: 5, formattedCylinders: 1, finished: false }, + ], + }); + }); + + const progresses = screen.getAllByRole("progressbar"); + expect(progresses[0]).toHaveAccessibleName("0.0.0160"); + expect(progresses[1]).toHaveAccessibleName("0.0.0200"); + expect(progresses[2]).toHaveAccessibleName("0.0.0500"); + // Finished progress should use success variant + expect(progresses[0].closest(".pf-v6-c-progress")).toHaveClass("pf-m-success"); + }); + }); + + describe("when a DASDFormatFinished event is received", () => { + it("clears the progress", () => { + let eventCallback: (event: unknown) => void; + mockOnEvent.mockImplementation((cb) => { + eventCallback = cb; + return jest.fn(); + }); + + const { container } = installerRender(); + + act(() => { + eventCallback({ + type: "DASDFormatChanged", + summary: [ + { channel: "0.0.0200", totalCylinders: 5, formattedCylinders: 5, finished: true }, + ], + }); + }); + + screen.getByRole("progressbar"); + + act(() => { + eventCallback({ type: "DASDFormatFinished" }); + }); + + expect(container).toBeEmptyDOMElement(); }); }); }); diff --git a/web/src/components/storage/dasd/DASDFormatProgress.tsx b/web/src/components/storage/dasd/DASDFormatProgress.tsx index 7877781f7b..e02d5aa897 100644 --- a/web/src/components/storage/dasd/DASDFormatProgress.tsx +++ b/web/src/components/storage/dasd/DASDFormatProgress.tsx @@ -20,53 +20,97 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { Progress, Stack } from "@patternfly/react-core"; -import { Popup } from "~/components/core"; +import React, { useEffect, useState } from "react"; +import { Card, CardBody, CardTitle, Progress, Stack } from "@patternfly/react-core"; +import { useInstallerClient } from "~/context/installer"; +import { sortCollection } from "~/utils"; import { _ } from "~/i18n"; import type { Device } from "~/model/system/dasd"; -// FIXME: adapt to new API +/** + * Summary of an ongoing DASD formatting operation for a single device. + * + * It is received from the installer client via the `DASDFormatChanged` event + * and represents the current formatting state of one DASD device. + */ type FormatSummary = { - total: number; - step: number; - done: boolean; -}; - -type FormatJob = { - jobId: string; - summary?: { [key: string]: FormatSummary }; + /** + * The channel identifier of the DASD device (e.g. "0.0.0200"). + */ + channel: Device["channel"]; + /** + * Total number of cylinders to be formatted. + */ + totalCylinders: number; + /** + * Number of cylinders that have already been formatted. + */ + formattedCylinders: number; + /** + * Whether the formatting operation has completed. + */ + finished: boolean; }; -const DeviceProgress = ({ device, progress }: { device: Device; progress: FormatSummary }) => ( +/** + * Renders a small progress bar for a single DASD formatting operation. + */ +const DeviceProgress = ({ progress }: { progress: FormatSummary }) => ( ); +/** + * Displays progress information for currently running DASD format operations. + * + * The component subscribes to the installer client's `DASDFormatChanged` events + * and updates its internal state accordingly. + * + * Rendering behavior: + * - If no formatting operations are running, nothing is rendered. + * - If at least one formatting summary is present, a progress card is shown. + * + * The component automatically unsubscribes from installer events when unmounted. + */ export default function DASDFormatProgress() { - const devices = []; // FIXME: use APIv2 equivalent to useDASDDevices(); - const runningJobs: FormatJob[] = []; // FIXME use APIv2 equivalent to useDASDRunningFormatJobs() + const client = useInstallerClient(); + const [progress, setProgress] = useState([]); + + useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "DASDFormatChanged") { + setProgress(sortCollection(event.summary, "asc", (p) => p.channel)); + } + + if (event.type === "DASDFormatFinished") { + setProgress([]); + } + }); + }, [client]); + + if (progress.length === 0) { + return null; + } return ( - 0} disableFocusTrap> - - {runningJobs.map((job) => - Object.entries(job.summary).map(([id, progress]) => { - const device = devices.find((d) => d.id === id); - return ( - - ); - }), - )} - - + + {_("Formatting devices")} + + + {progress.map((p) => { + return ; + })} + + + ); } diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index 9a0ba9c275..06adaf2eeb 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -24,7 +24,8 @@ import React from "react"; import { isEmpty } from "radashi"; import { EmptyState, EmptyStateBody } from "@patternfly/react-core"; import Page from "~/components/core/Page"; -import DASDTable from "./DASDTable"; +import DASDTable from "~/components/storage/dasd/DASDTable"; +import DASDFormatProgress from "~/components/storage/dasd/DASDFormatProgress"; import { useSystem } from "~/hooks/model/system/dasd"; import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; @@ -67,7 +68,10 @@ export default function DASDPage() { return ( , + }} > diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index ccc0094577..78b00b535b 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -241,7 +241,7 @@ const buildActions = ({ deactivate: device.active, diagOn: !device.diag, diagOff: device.diag, - format: !device.formatted, + format: device.active, }; return actions.filter((a) => keptActions[a.id]);