Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion web/src/assets/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,19 @@ strong {
font-weight: var(--pf-t--global--font--weight--400);
}

#main-content {
// Freeze scrolling when Agama overlay is visible
.pf-v6-c-page__main:has(.agm-main-content-overlay) {
overflow: hidden;
}

.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);
}
}

Expand Down
12 changes: 4 additions & 8 deletions web/src/components/core/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ const Content = ({ children, ...pageSectionProps }: PageSectionProps) => {
* @example
* Page with progress tracking for software operations
* ```tsx
* <Page progressScope="software">
* <Page progress={{ scope: "software" }}>
* <Page.Header>
* <h2>{_("Software")}</h2>
* </Page.Header>
Expand All @@ -343,18 +343,14 @@ const Content = ({ children, ...pageSectionProps }: PageSectionProps) => {
* ```
*/
const Page = ({
progress,
children,
progressScope,
additionalProgressKeys,
...pageGroupProps
}: PageGroupProps & ProgressBackdropProps): React.ReactNode => {
}: PageGroupProps & { progress?: ProgressBackdropProps }): React.ReactNode => {
return (
<PageGroup {...pageGroupProps} tabIndex={-1} id="main-content">
{children}
<ProgressBackdrop
progressScope={progressScope}
additionalProgressKeys={additionalProgressKeys}
/>
<ProgressBackdrop {...progress} />
</PageGroup>
);
};
Expand Down
37 changes: 16 additions & 21 deletions web/src/components/core/ProgressBackdrop.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("ProgressBackdrop", () => {

describe("when progress scope is provided but no matching progress exists", () => {
it("does not render the backdrop", () => {
installerRender(<ProgressBackdrop progressScope="software" />);
installerRender(<ProgressBackdrop scope="software" />);
expect(screen.queryByRole("alert")).toBeNull();
});
});
Expand All @@ -73,7 +73,7 @@ describe("ProgressBackdrop", () => {
size: 5,
},
]);
installerRender(<ProgressBackdrop progressScope="software" />);
installerRender(<ProgressBackdrop scope="software" />);
const backdrop = screen.getByRole("alert", { name: /Installing packages/ });
expect(backdrop.classList).toContain("agm-main-content-overlay");
within(backdrop).getByText(/step 2 of 5/);
Expand Down Expand Up @@ -106,14 +106,14 @@ describe("ProgressBackdrop", () => {
},
]);

const { rerender } = installerRender(<ProgressBackdrop progressScope="storage" />);
const { rerender } = installerRender(<ProgressBackdrop scope="storage" />);

const backdrop = screen.getByRole("alert", { name: /Calculating proposal/ });

// Progress finishes
mockProgresses([]);

rerender(<ProgressBackdrop progressScope="storage" />);
rerender(<ProgressBackdrop scope="storage" />);

// Should show "Refreshing data..." message
await waitFor(() => {
Expand All @@ -138,14 +138,14 @@ describe("ProgressBackdrop", () => {
},
]);

const { rerender } = installerRender(<ProgressBackdrop progressScope="storage" />);
const { rerender } = installerRender(<ProgressBackdrop scope="storage" />);

// Progress finishes
mockProgresses([]);

const backdrop = screen.getByRole("alert", { name: /Calculating proposal/ });

rerender(<ProgressBackdrop progressScope="storage" />);
rerender(<ProgressBackdrop scope="storage" />);

// Should show refreshing message
await waitFor(() => {
Expand Down Expand Up @@ -174,7 +174,7 @@ describe("ProgressBackdrop", () => {
size: 5,
},
]);
installerRender(<ProgressBackdrop progressScope="storage" />);
installerRender(<ProgressBackdrop scope="storage" />);
expect(screen.queryByRole("alert", { name: /Installing packages/ })).toBeNull();
});
});
Expand All @@ -190,7 +190,7 @@ describe("ProgressBackdrop", () => {
size: 5,
},
]);
const { rerender } = installerRender(<ProgressBackdrop progressScope="software" />);
const { rerender } = installerRender(<ProgressBackdrop scope="software" />);
const backdrop = screen.getByRole("alert", { name: /Downloading packages/ });
within(backdrop).getByText(/step 1 of 5/);

Expand All @@ -203,13 +203,13 @@ describe("ProgressBackdrop", () => {
size: 5,
},
]);
rerender(<ProgressBackdrop progressScope="software" />);
rerender(<ProgressBackdrop scope="software" />);
within(backdrop).getByText(/Installing packages/);
within(backdrop).getByText(/step 3 of 5/);
});
});

describe("additionalProgressKeys prop", () => {
describe("query keys refetch tracking", () => {
it("tracks common proposal keys by default", () => {
mockProgresses([
{
Expand All @@ -221,7 +221,7 @@ describe("ProgressBackdrop", () => {
},
]);

installerRender(<ProgressBackdrop progressScope="software" />);
installerRender(<ProgressBackdrop scope="software" />);

// Should be called with COMMON_PROPOSAL_KEYS and undefined additionalKeys
expect(mockUseTrackQueriesRefetch).toHaveBeenCalledWith(
Expand All @@ -241,9 +241,7 @@ describe("ProgressBackdrop", () => {
},
]);

installerRender(
<ProgressBackdrop progressScope="storage" additionalProgressKeys="storageModel" />,
);
installerRender(<ProgressBackdrop scope="storage" ensureRefetched="storageModel" />);

// Should be called with COMMON_PROPOSAL_KEYS + storageModel
expect(mockUseTrackQueriesRefetch).toHaveBeenCalledWith(
Expand All @@ -264,10 +262,7 @@ describe("ProgressBackdrop", () => {
]);

installerRender(
<ProgressBackdrop
progressScope="network"
additionalProgressKeys={["networkConfig", "connections"]}
/>,
<ProgressBackdrop scope="network" ensureRefetched={["networkConfig", "connections"]} />,
);

// Should be called with COMMON_PROPOSAL_KEYS + networkConfig + connections
Expand All @@ -292,14 +287,14 @@ describe("ProgressBackdrop", () => {
]);

const { rerender } = installerRender(
<ProgressBackdrop progressScope="storage" additionalProgressKeys="storageModel" />,
<ProgressBackdrop scope="storage" ensureRefetched="storageModel" />,
);

// Progress finishes
mockProgresses([]);

rerender(<ProgressBackdrop progressScope="storage" additionalProgressKeys="storageModel" />);
rerender(<ProgressBackdrop progressScope="storage" additionalProgressKeys="storageModel" />);
rerender(<ProgressBackdrop scope="storage" ensureRefetched="storageModel" />);
rerender(<ProgressBackdrop scope="storage" ensureRefetched="storageModel" />);

// Should have called startTracking
await waitFor(() => {
Expand Down
75 changes: 43 additions & 32 deletions web/src/components/core/ProgressBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,27 @@
*/

import React, { useEffect, useState } from "react";
import { Alert, Backdrop, Spinner } from "@patternfly/react-core";
import { Alert, Backdrop, Flex, FlexItem, Spinner } from "@patternfly/react-core";
import { concat, isEmpty } from "radashi";
import { sprintf } from "sprintf-js";
import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal";
import { useStatus } from "~/hooks/model/status";
import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch";
import type { Scope } from "~/model/status";
import { _ } from "~/i18n";
import textStyles from "@patternfly/react-styles/css/utilities/Text/text";

/**
* Props for the ProgressBackdrop component.
*/
export type ProgressBackdropProps = {
/**
* Optional scope identifier to filter which progresses trigger the backgrop
* Scope identifier to filter which progresses trigger the backgrop
* overlay. If undefined or no matching tasks exist, the backdrop won't be
* displayed.
*/
progressScope?: Scope;
scope: Scope;

/**
* Additional query keys to track during progress operations.
*
Expand All @@ -52,43 +55,43 @@ export type ProgressBackdropProps = {
*
* @example
* // Track storage model updates in addition to common proposal queries
* <Page progressScope="storage" additionalProgressKeys={STORAGE_MODEL_KEY}>
* <ProgressBackdrop track={{ scope: "storage", ensureRefecthed={STORAGE_MODEL_KEY}} />
*
* @example
* // Track multiple additional queries
* <Page
* progressScope="network"
* additionalProgressKeys={[NETWORK_CONFIG_KEY, CONNECTIONS_KEY]}
* <ProgressBackdrop
* track="network"
* ensureRefecthed={[NETWORK_CONFIG_KEY, CONNECTIONS_KEY]}
* >
*/
additionalProgressKeys?: string | string[];
ensureRefetched?: string | string[];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea here is to use a Tagged type, similar to what was already done for
TranslatedString. This would allow queries to export their keys as a QueryKey (or similar), enabling the compiler to verify that valid values are provided to the ensureRefetched prop.

};

/**
* Helper component for blocking user interaction by displaying a blurred
* overlay with a progress information when progresses matching the specified
* scope are active.
* Helper component that blocks user interaction by displaying a blurred overlay
* with progress information while operations matching the specified scope are
* active.
*
* @remarks
* The component uses two mechanisms to manage its visibility:
* - Monitors active tasks from useStatus() that match the progressScope
* - Listens to proposal update events to automatically unblock when
* operations complete
* Visibility is controlled through two mechanisms:
* - Monitors active tasks from `useStatus()` that match the provided scope.
* - Tracks refetches for common proposal queries as well as any queries
* specified via `ensureRefetched`.
*
* The backdrop remains visible until a proposal update event with a timestamp
* newer than when the progress finished arrives, ensuring the UI doesn't
* Once shown, the backdrop remains visible until all involved queries have been
* refetched after the tracked progress has finished, ensuring the UI does not
* unblock prematurely.
*/
export default function ProgressBackdrop({
progressScope,
additionalProgressKeys,
scope,
ensureRefetched,
}: ProgressBackdropProps): React.ReactNode {
const { progresses: tasks } = useStatus();
const [isBlocked, setIsBlocked] = useState(false);
const [progressFinishedAt, setProgressFinishedAt] = useState<number | null>(null);
const progress = !isEmpty(progressScope) && tasks.find((t) => t.scope === progressScope);
const progress = !isEmpty(scope) && tasks.find((t) => t.scope === scope);
const { startTracking } = useTrackQueriesRefetch(
concat(COMMON_PROPOSAL_KEYS, additionalProgressKeys),
concat(COMMON_PROPOSAL_KEYS, ensureRefetched),
(_, completedAt) => {
if (completedAt > progressFinishedAt) {
setIsBlocked(false);
Expand All @@ -115,18 +118,26 @@ export default function ProgressBackdrop({
<Backdrop className="agm-main-content-overlay" role="alert" aria-labelledby="progressStatus">
<Alert
isPlain
customIcon={<Spinner size="sm" aria-hidden />}
customIcon={<></>}
title={
<div id="progressStatus">
{progress ? (
<>
{progress.step}{" "}
<small>{sprintf(_("(step %s of %s)"), progress.index, progress.size)}</small>
</>
) : (
<>{_("Refreshing data...")}</>
)}
</div>
<Flex
id="progressStatus"
gap={{ default: "gapMd" }}
alignItems={{ default: "alignItemsCenter" }}
className={textStyles.fontSizeXl}
>
<Spinner size="lg" aria-hidden />
<FlexItem>
{progress ? (
<>
{progress.step}{" "}
<small>{sprintf(_("(step %s of %s)"), progress.index, progress.size)}</small>
</>
) : (
<>{_("Refreshing data...")}</>
)}
</FlexItem>
</Flex>
}
/>
</Backdrop>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/software/SoftwareConflicts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function SoftwareConflicts(): React.ReactNode {
const conflicts = useConflicts();

return (
<Page progressScope="software">
<Page progress={{ scope: "software" }}>
<Page.Header>
<Content component="h2">{_("Software conflicts resolution")}</Content>
</Page.Header>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/software/SoftwarePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function SoftwarePage(): React.ReactNode {
const showReposAlert = repos.some((r) => !r.loaded);

return (
<Page progressScope="software">
<Page progress={{ scope: "software" }}>
<Page.Header>
<Content component="h2">{_("Software")}</Content>
</Page.Header>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/software/SoftwarePatternsSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ function SoftwarePatternsSelection(): React.ReactNode {
});

return (
<Page progressScope="software">
<Page progress={{ scope: "software" }}>
<Page.Header>
<Content component="h2">{_("Software selection")}</Content>
<SearchInput
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/storage/ProposalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ export default function ProposalPage(): React.ReactNode {
if (resetNeeded) return;

return (
<Page progressScope="storage" additionalProgressKeys={STORAGE_MODEL_KEY}>
<Page progress={{ scope: "storage", ensureRefetched: STORAGE_MODEL_KEY }}>
<Page.Header>
<Flex>
<FlexItem>
Expand Down
Loading