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 ( - <> - -
- {explanation} -
- - ); - }; - return (
- - } /> + +
+ {explanation} +
); }; @@ -297,8 +285,6 @@ export default function ProposalSettingsSection({ encryptionMethods = [], onChange = noop }) { - const { selectedProduct } = useProduct(); - const changeEncryption = ({ password, method }) => { onChange({ encryptionPassword: password, encryptionMethod: method }); }; @@ -318,29 +304,12 @@ export default function ProposalSettingsSection({ const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; - const transactional = isTransactionalSystem(settings?.volumes || []); - 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);