diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss
index 30e95e4aa4..e568ae24db 100644
--- a/web/src/assets/styles/blocks.scss
+++ b/web/src/assets/styles/blocks.scss
@@ -469,6 +469,27 @@ ul[data-type="agama/list"][role="grid"] {
}
}
+[data-type="agama/reminder"] {
+ --accent-color: var(--color-primary-lighter);
+ --inline-margin: calc(var(--header-icon-size) + var(--spacer-small));
+
+ display: flex;
+ gap: var(--spacer-small);
+ margin-inline: var(--inline-margin);
+ margin-block-end: var(--spacer-normal);
+ padding: var(--spacer-smaller) var(--spacer-small);
+ border-inline-start: 3px solid var(--accent-color);
+
+ svg {
+ fill: var(--accent-color);
+ }
+
+ h4 {
+ color: var(--accent-color);
+ margin-block-end: var(--spacer-smaller);
+ }
+}
+
[role="dialog"] {
section:not([class^="pf-c"]) {
> svg:first-child {
diff --git a/web/src/components/core/Reminder.jsx b/web/src/components/core/Reminder.jsx
new file mode 100644
index 0000000000..997e870a91
--- /dev/null
+++ b/web/src/components/core/Reminder.jsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) [2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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.
+ */
+
+// @ts-check
+
+import React from "react";
+import { Icon } from "~/components/layout";
+
+/**
+ * Internal component for rendering the icon
+ *
+ * @param {object} props
+ * @params {string} [props.name] - The icon name.
+ */
+const ReminderIcon = ({ name }) => {
+ if (!name?.length) return;
+
+ return (
+
+
+
+ );
+};
+
+/**
+ * Internal component for rendering the title
+ *
+ * @param {object} props
+ * @params {JSX.Element|string} [props.children] - The title content.
+ */
+const ReminderTitle = ({ children }) => {
+ if (!children) return;
+ if (typeof children === "string" && !children.length) return;
+
+ return (
+
{children}
+ );
+};
+
+/**
+ * Renders a reminder with given role, status by default
+ * @component
+ *
+ * @param {object} props
+ * @param {string} [props.icon] - The name of desired icon.
+ * @param {JSX.Element|string} [props.title] - The content for the title.
+ * @param {string} [props.role="status"] - The reminder's role, "status" by
+ * default. See {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role}
+ * @param {JSX.Element} [props.children] - The content for the description.
+ */
+export default function Reminder ({
+ icon,
+ title,
+ role = "status",
+ children
+}) {
+ return (
+
+
+
+ {title}
+ { children }
+
+
+ );
+}
diff --git a/web/src/components/core/Reminder.test.jsx b/web/src/components/core/Reminder.test.jsx
new file mode 100644
index 0000000000..24527cf2d0
--- /dev/null
+++ b/web/src/components/core/Reminder.test.jsx
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) [2023] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 from "react";
+import { screen, within } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import { Reminder } from "~/components/core";
+
+describe("Reminder", () => {
+ it("renders a status region by default", () => {
+ plainRender(Example);
+ const reminder = screen.getByRole("status");
+ within(reminder).getByText("Example");
+ });
+
+ it("renders a region with given role", () => {
+ plainRender(Example);
+ const reminder = screen.getByRole("alert");
+ within(reminder).getByText("Example");
+ });
+
+ it("renders given title", () => {
+ plainRender(
+ Kindly reminder}>
+ Visit the settings section
+
+ );
+ screen.getByRole("heading", { name: "Kindly reminder", level: 4 });
+ });
+
+ it("does not render a heading if title is not given", () => {
+ plainRender(Without title);
+ expect(screen.queryByRole("heading")).toBeNull();
+ });
+
+ it("does not render a heading if title is an empty string", () => {
+ plainRender(Without title);
+ expect(screen.queryByRole("heading")).toBeNull();
+ });
+
+ it("renders given children", () => {
+ plainRender(
+ Visit the settings section
+ );
+ screen.getByRole("link", { name: "Visit the settings section" });
+ });
+});
diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js
index 0213c66215..6bdac86248 100644
--- a/web/src/components/core/index.js
+++ b/web/src/components/core/index.js
@@ -57,3 +57,4 @@ export { default as PasswordInput } from "./PasswordInput";
export { default as DevelopmentInfo } from "./DevelopmentInfo";
export { default as Selector } from "./Selector";
export { default as OptionsPicker } from "./OptionsPicker";
+export { default as Reminder } from "./Reminder";
diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx
index 51ab17e7bf..f39a4d9db6 100644
--- a/web/src/components/storage/ProposalPage.jsx
+++ b/web/src/components/storage/ProposalPage.jsx
@@ -31,7 +31,8 @@ import {
ProposalSettingsSection,
ProposalSpacePolicySection,
ProposalDeviceSection,
- ProposalFileSystemsSection
+ ProposalFileSystemsSection,
+ ProposalTransactionalInfo
} from "~/components/storage";
import { IDLE } from "~/client/status";
@@ -201,6 +202,9 @@ export default function ProposalPage() {
const PageContent = () => {
return (
<>
+ {
- const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \
+ const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \
version of the system after configuration changes or software upgrades.");
- return (
- <>
-
-
- {/* TRANSLATORS: %s is replaced by a product name (e.g., openSUSE Tumbleweed) */}
- {sprintf(_("%s is an immutable system with atomic updates using a read-only Btrfs \
-root file system."), selectedProduct.name)}
-
-
- }
- else={
-
- }
+ {
};
});
-jest.mock("~/context/product", () => ({
- ...jest.requireActual("~/context/product"),
- useProduct: () => ({
- selectedProduct : { name: "Test" }
- })
-}));
-
let props;
beforeEach(() => {
@@ -48,7 +41,7 @@ beforeEach(() => {
const rootVolume = { mountPath: "/", fsType: "Btrfs", outline: { snapshotsConfigurable: true } };
-describe("if the system is not transactional", () => {
+describe("if snapshots are configurable", () => {
beforeEach(() => {
props.settings = { volumes: [rootVolume] };
});
@@ -60,15 +53,15 @@ describe("if the system is not transactional", () => {
});
});
-describe("if the system is transactional", () => {
+describe("if snapshots are not configurable", () => {
beforeEach(() => {
- props.settings = { volumes: [{ ...rootVolume, transactional: true }] };
+ props.settings = { volumes: [{ ...rootVolume, outline: { ...rootVolume.outline, snapshotsConfigurable: false } }] };
});
- it("renders explanation about transactional system", () => {
+ it("does not render the snapshots switch", () => {
plainRender();
- screen.getByText("Transactional system");
+ expect(screen.queryByRole("checkbox", { name: "Use Btrfs Snapshots" })).toBeNull();
});
});
diff --git a/web/src/components/storage/ProposalTransactionalInfo.jsx b/web/src/components/storage/ProposalTransactionalInfo.jsx
new file mode 100644
index 0000000000..aa20477a6d
--- /dev/null
+++ b/web/src/components/storage/ProposalTransactionalInfo.jsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) [2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 from "react";
+
+import { sprintf } from "sprintf-js";
+import { _ } from "~/i18n";
+import { Reminder } from "~/components/core";
+import { isTransactionalSystem } from "~/components/storage/utils";
+import { useProduct } from "~/context/product";
+
+/**
+ * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings
+ */
+
+/**
+ * Information about the system being transactional, if needed
+ * @component
+ *
+ * @param {object} props
+ * @param {ProposalSettings} props.settings - Settings used for calculating a proposal.
+ */
+export default function ProposalTransactionalInfo({ settings }) {
+ const { selectedProduct } = useProduct();
+
+ if (!isTransactionalSystem(settings?.volumes)) return;
+
+ const title = _("Transactional root file system");
+ /* TRANSLATORS: %s is replaced by a product name (e.g., openSUSE Tumbleweed) */
+ const description = sprintf(
+ _("%s is an immutable system with atomic updates. It uses a read-only Btrfs file system updated via snapshots."),
+ selectedProduct.name
+ );
+
+ return {description};
+}
diff --git a/web/src/components/storage/ProposalTransactionalInfo.test.jsx b/web/src/components/storage/ProposalTransactionalInfo.test.jsx
new file mode 100644
index 0000000000..e9556107fa
--- /dev/null
+++ b/web/src/components/storage/ProposalTransactionalInfo.test.jsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) [2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * 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 from "react";
+import { screen } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import { ProposalTransactionalInfo } from "~/components/storage";
+
+jest.mock("~/context/product", () => ({
+ ...jest.requireActual("~/context/product"),
+ useProduct: () => ({
+ selectedProduct : { name: "Test" }
+ })
+}));
+
+let props;
+
+beforeEach(() => {
+ props = {};
+});
+
+const rootVolume = { mountPath: "/", fsType: "Btrfs" };
+
+describe("if the system is not transactional", () => {
+ beforeEach(() => {
+ props.settings = { volumes: [rootVolume] };
+ });
+
+ it("renders nothing", () => {
+ const { container } = plainRender();
+ expect(container).toBeEmptyDOMElement();
+ });
+});
+
+describe("if the system is transactional", () => {
+ beforeEach(() => {
+ props.settings = { volumes: [{ ...rootVolume, transactional: true }] };
+ });
+
+ it("renders an explanation about the transactional system", () => {
+ plainRender();
+
+ screen.getByText("Transactional root file system");
+ });
+});
diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js
index 6c518c51a0..d4dd30a180 100644
--- a/web/src/components/storage/index.js
+++ b/web/src/components/storage/index.js
@@ -26,6 +26,7 @@ export { default as ProposalSpacePolicySection } from "./ProposalSpacePolicySect
export { default as ProposalDeviceSection } from "./ProposalDeviceSection";
export { default as ProposalFileSystemsSection } from "./ProposalFileSystemsSection";
export { default as ProposalActionsSection } from "./ProposalActionsSection";
+export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo";
export { default as ProposalVolumes } from "./ProposalVolumes";
export { default as DASDPage } from "./DASDPage";
export { default as DASDTable } from "./DASDTable";
diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js
index d2322bc167..082756b08c 100644
--- a/web/src/components/storage/utils.js
+++ b/web/src/components/storage/utils.js
@@ -180,8 +180,12 @@ const isTransactionalRoot = (volume) => {
* @param {Volume[]} volumes
* @returns {boolean}
*/
-const isTransactionalSystem = (volumes) => {
- return volumes.find(v => isTransactionalRoot(v)) !== undefined;
+const isTransactionalSystem = (volumes = []) => {
+ try {
+ return volumes?.find(v => isTransactionalRoot(v)) !== undefined;
+ } catch {
+ return false;
+ }
};
export {
diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.js
index a973e27a0a..e5eb3cf0aa 100644
--- a/web/src/components/storage/utils.test.js
+++ b/web/src/components/storage/utils.test.js
@@ -139,6 +139,14 @@ describe("isTransactionalRoot", () => {
});
describe("isTransactionalSystem", () => {
+ it("returns false when a list of volumes is not given", () => {
+ expect(isTransactionalSystem(false)).toBe(false);
+ expect(isTransactionalSystem(undefined)).toBe(false);
+ expect(isTransactionalSystem(null)).toBe(false);
+ expect(isTransactionalSystem([])).toBe(false);
+ expect(isTransactionalSystem("fake")).toBe(false);
+ });
+
it("returns false if volumes does not include a transactional root", () => {
expect(isTransactionalSystem([])).toBe(false);