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
6 changes: 6 additions & 0 deletions web/package/agama-web-ui.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Thu Jul 31 09:13:09 UTC 2025 - David Diaz <dgonzalez@suse.com>

- 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 <dgonzalez@suse.com>

Expand Down
6 changes: 5 additions & 1 deletion web/src/assets/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions web/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
157 changes: 157 additions & 0 deletions web/src/components/core/AlertOutOfSync.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AlertOutOfSync />);
expect(container).toBeEmptyDOMElement();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("must receive a value for `scope`"),
);
});

it("renders nothing if scope empty", () => {
const { container } = plainRender(<AlertOutOfSync scope="" />);
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(<AlertOutOfSync scope="Watched" />);

// 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(<AlertOutOfSync scope="Watched" />);

// 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(<AlertOutOfSync scope="Watched" />);

// 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();
});
});
115 changes: 115 additions & 0 deletions web/src/components/core/AlertOutOfSync.tsx
Original file line number Diff line number Diff line change
@@ -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<AlertProps> & {
/**
* 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
* <AlertOutOfSync scope="SoftwareProposal" />
* ```
*/
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 (
<AlertGroup hasAnimations isToast isLiveRegion aria-live="assertive">
{active && (
<Alert
variant="info"
title={title}
actionClose={
<AlertActionCloseButton
title={title as string}
variantLabel={_("Out of sync alert")}
onClose={() => setActive(false)}
/>
}
{...alertProps}
key={`${scope}-out-of-sync`}
>
<Content component="p">
{_(
"The configuration has been updated externally. \
Reload the page to get the latest data and avoid issues or data loss.",
)}
</Content>
<Button size="sm" onClick={locationReload}>
{_("Reload now")}
</Button>
</Alert>
)}
</AlertGroup>
);
}
26 changes: 23 additions & 3 deletions web/src/components/core/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ButtonProps, "component"> & {
/** 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;
};
Expand All @@ -37,11 +39,29 @@ export type LinkProps = Omit<ButtonProps, "component"> & {
* @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 (
<Button component="a" href={href} variant={linkVariant} {...props}>
<Button
component="a"
href={href}
variant={linkVariant}
onClick={(event: React.MouseEvent<HTMLElement>) => {
onClick?.(event as React.MouseEvent<HTMLButtonElement, MouseEvent>);
handleClick(event as React.MouseEvent<HTMLAnchorElement, MouseEvent>);
}}
{...props}
>
{children}
</Button>
);
Expand Down
7 changes: 7 additions & 0 deletions web/src/context/installer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down
5 changes: 5 additions & 0 deletions web/src/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down