diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 07cee2b824..7939dae67b 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Jul 31 09:13:09 UTC 2025 - David Diaz + +- Allow displaying out-of-sync alerts by listening to changes + events (gh#agama-project/agama#2630). + ------------------------------------------------------------------- Fri Jul 25 19:42:18 UTC 2025 - David Diaz diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 8c1dc3f55e..29723f3686 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -339,7 +339,7 @@ strong { // --pf-v6-c-nav--PaddingBlockStart: 0; // } -.pf-v6-c-alert { +:not(.pf-v6-c-alert-group__item) > .pf-v6-c-alert { --pf-v6-c-alert--m-info__title--Color: var(--agm-t--color--waterhole); --pf-v6-c-alert__icon--FontSize: var(--pf-t--global--font--size--md); --pf-v6-c-content--MarginBlockEnd: var(--pf-t--global--spacer--xs); @@ -377,6 +377,10 @@ strong { } } +.pf-v6-c-alert-group.pf-m-toast { + padding: var(--pf-t--global--spacer--xl); +} + .pf-v6-c-alert__title { font-size: var(--pf-t--global--font--size--md); } diff --git a/web/src/client/index.ts b/web/src/client/index.ts index 81c33e12db..c881f3d887 100644 --- a/web/src/client/index.ts +++ b/web/src/client/index.ts @@ -26,6 +26,8 @@ type VoidFn = () => void; type BooleanFn = () => boolean; export type InstallerClient = { + /** Unique client identifier. */ + id?: string; /** Whether the client is connected. */ isConnected: BooleanFn; /** Whether the client is recoverable after disconnecting. */ diff --git a/web/src/components/core/AlertOutOfSync.test.tsx b/web/src/components/core/AlertOutOfSync.test.tsx new file mode 100644 index 0000000000..9e2f76b1aa --- /dev/null +++ b/web/src/components/core/AlertOutOfSync.test.tsx @@ -0,0 +1,157 @@ +/* + * Copyright (c) [2025] 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 React, { act } from "react"; +import { screen } from "@testing-library/dom"; +import { installerRender, plainRender } from "~/test-utils"; +import AlertOutOfSync from "./AlertOutOfSync"; + +const mockOnEvent = jest.fn(); +const mockReload = jest.fn(); + +const mockClient = { + id: "current-client", + isConnected: jest.fn().mockResolvedValue(true), + isRecoverable: jest.fn(), + onConnect: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + onEvent: mockOnEvent, +}; + +let consoleErrorSpy: jest.SpyInstance; + +jest.mock("~/context/installer", () => ({ + ...jest.requireActual("~/context/installer"), + useInstallerClient: () => mockClient, +})); +jest.mock("~/utils", () => ({ + ...jest.requireActual("~/utils"), + locationReload: () => mockReload(), +})); + +describe("AlertOutOfSync", () => { + beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, "error"); + consoleErrorSpy.mockImplementation(); + }); + + it("renders nothing if scope is missing", () => { + // @ts-expect-error: scope is required prop + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must receive a value for `scope`"), + ); + }); + + it("renders nothing if scope empty", () => { + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must receive a value for `scope`"), + ); + }); + + it("shows alert on matching changes event from a different client for subscribed scope", () => { + let eventCallback; + mockClient.onEvent.mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }); + + installerRender(); + + // Should not render the alert initially + expect(screen.queryByText("Info alert:")).toBeNull(); + + // Simulate a change event for a different scope + act(() => { + eventCallback({ type: "NotWatchedChanged", clientId: "other-client" }); + }); + + expect(screen.queryByText("Info alert:")).toBeNull(); + + // Simulate a change event for the subscribed scope, from current client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "current-client" }); + }); + + expect(screen.queryByText("Info alert:")).toBeNull(); + + // Simulate a change event for the subscribed scope, from different client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "other-client" }); + }); + + screen.getByText("Info alert:"); + screen.getByText("Configuration out of sync"); + screen.getByText(/issues or data loss/); + screen.getByRole("button", { name: "Reload now" }); + }); + + it("dismisses automatically the alert on matching changes event from current client for subscribed scope", () => { + let eventCallback; + mockClient.onEvent.mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }); + + installerRender(); + + // Simulate a change event for the subscribed scope, from different client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "other-client" }); + }); + + screen.getByText("Info alert:"); + screen.getByText("Configuration out of sync"); + screen.getByText(/issues or data loss/); + screen.getByRole("button", { name: "Reload now" }); + + // Simulate a change event for the subscribed scope, from current client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "current-client" }); + }); + + expect(screen.queryByText("Info alert:")).toBeNull(); + }); + + it("triggers a location relaod when clicking on `Reload now`", async () => { + let eventCallback; + mockClient.onEvent.mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }); + + const { user } = installerRender(); + + // Simulate a change event for the subscribed scope, from different client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "other-client" }); + }); + + const reloadButton = screen.getByRole("button", { name: "Reload now" }); + await user.click(reloadButton); + expect(mockReload).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/core/AlertOutOfSync.tsx b/web/src/components/core/AlertOutOfSync.tsx new file mode 100644 index 0000000000..773bed87c2 --- /dev/null +++ b/web/src/components/core/AlertOutOfSync.tsx @@ -0,0 +1,115 @@ +/* + * Copyright (c) [2025] 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 React, { useEffect, useState } from "react"; +import { + Alert, + AlertActionCloseButton, + AlertGroup, + AlertProps, + Button, + Content, +} from "@patternfly/react-core"; +import { useInstallerClient } from "~/context/installer"; +import { isEmpty } from "radashi"; +import { _ } from "~/i18n"; +import { locationReload } from "~/utils"; + +type AlertOutOfSyncProps = Partial & { + /** + * The scope to listen for change events on (e.g., `SoftwareProposal`, + * `L10nConfig`). + */ + scope: string; +}; + +/** + * Reactive alert shown when the configuration for a given scope has been + * changed externally. + * + * It warns that the interface may be out of sync and recommends reloading + * before continuing to avoid issues and data loss. Reloading is intentionally + * left up to the user rather than forced automatically, to prevent confusion + * caused by unexpected refreshes. + * + * It works by listening for "Changed" events on the specified scope: + * + * - Displays a toast alert if the event originates from a different client + * (based on client ID). + * - Automatically dismisses the alert if a subsequent event originates from + * the current client. + * + * @example + * ```tsx + * + * ``` + */ +export default function AlertOutOfSync({ scope, ...alertProps }: AlertOutOfSyncProps) { + const client = useInstallerClient(); + const [active, setActive] = useState(false); + const missingScope = isEmpty(scope); + + useEffect(() => { + if (missingScope) return; + + return client.onEvent((event) => { + event.type === `${scope}Changed` && setActive(event.clientId !== client.id); + }); + }); + + if (missingScope) { + console.error("AlertOutOfSync must receive a value for `scope` prop"); + return; + } + + const title = _("Configuration out of sync"); + + return ( + + {active && ( + setActive(false)} + /> + } + {...alertProps} + key={`${scope}-out-of-sync`} + > + + {_( + "The configuration has been updated externally. \ +Reload the page to get the latest data and avoid issues or data loss.", + )} + + + + )} + + ); +} diff --git a/web/src/components/core/Link.tsx b/web/src/components/core/Link.tsx index c5816fcfa1..d05401db7b 100644 --- a/web/src/components/core/Link.tsx +++ b/web/src/components/core/Link.tsx @@ -22,11 +22,13 @@ import React from "react"; import { Button, ButtonProps } from "@patternfly/react-core"; -import { To, useHref } from "react-router-dom"; +import { To, useHref, useLinkClickHandler } from "react-router-dom"; export type LinkProps = Omit & { /** The target route */ to: string | To; + /** Whether the link should replace the current entry in the browser history */ + replace?: boolean; /** Whether use PF/Button primary variant */ isPrimary?: boolean; }; @@ -37,11 +39,29 @@ export type LinkProps = Omit & { * @note when isPrimary not given or false and props does not contain a variant prop, * it will default to "secondary" variant */ -export default function Link({ to, isPrimary, variant, children, ...props }: LinkProps) { +export default function Link({ + to, + replace = false, + isPrimary, + variant, + children, + onClick, + ...props +}: LinkProps) { const href = useHref(to); const linkVariant = isPrimary ? "primary" : variant || "secondary"; + const handleClick = useLinkClickHandler(to, { replace }); return ( - ); diff --git a/web/src/context/installer.tsx b/web/src/context/installer.tsx index 4a2f8c650f..799419ef79 100644 --- a/web/src/context/installer.tsx +++ b/web/src/context/installer.tsx @@ -53,6 +53,13 @@ function InstallerClientProvider({ children, client = null }: InstallerClientPro useEffect(() => { const connectClient = async () => { const client = await createDefaultClient(); + + client.onEvent((event) => { + if (event.type === "ClientConnected") { + client.id = event.clientId; + } + }); + setValue(client); }; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index c74603f33f..bd4fb144e3 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -96,6 +96,11 @@ jest.mock("react-router-dom", () => ({ Navigate: ({ to: route }) => <>Navigating to {route}, Outlet: () => <>Outlet Content, useRevalidator: () => mockUseRevalidator, + useLinkClickHandler: + ({ to }) => + () => { + to; + }, })); const Providers = ({ children, withL10n }) => {