diff --git a/products.d/ALP-Dolomite.yaml b/products.d/ALP-Dolomite.yaml index 345c7a2461..c2d4e7d039 100644 --- a/products.d/ALP-Dolomite.yaml +++ b/products.d/ALP-Dolomite.yaml @@ -15,16 +15,6 @@ translations: bezpečnost pro poskytování úplného minima ke spuštění úloh a služeb v kontejnerech nebo virtuálních strojích. software: - installation_repositories: - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/ - archs: x86_64 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/ - archs: aarch64 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/s390x/product/ - archs: s390 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/ppc64le/product/ - archs: ppc - mandatory_patterns: - alp_base_zypper - alp_cockpit diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 5085e42444..6c94418a9d 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -142,6 +142,10 @@ def requirement # Tries to register with the given registration code. # + # @note Software is not automatically probed after registering the product. The reason is + # to avoid dealing with possible probing issues in the registration D-Bus API. Clients + # have to explicitly call to #Probe after registering a product. + # # @param reg_code [String] # @param email [String, nil] # @@ -174,6 +178,10 @@ def register(reg_code, email: nil) # Tries to deregister. # + # @note Software is not automatically probed after deregistering the product. The reason is + # to avoid dealing with possible probing issues in the deregistration D-Bus API. Clients + # have to explicitly call to #Probe after deregistering a product. + # # @return [Array(Integer, String)] Result code and a description. # Possible result codes: # 0: success diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index cb398b00dc..be47087609 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -286,16 +286,7 @@ def used_disk_space end def registration - return @registration if @registration - - @registration = Registration.new(self, @logger) - @registration.on_change do - # reprobe and repropose when system is register or deregistered - probe - proposal - end - - @registration + @registration ||= Registration.new(self, logger) end # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 77bec63e2a..f899ab64b3 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -191,7 +191,7 @@ stub_const("Agama::Software::Manager::REPOS_DIR", repos_dir) stub_const("Agama::Software::Manager::REPOS_BACKUP", backup_repos_dir) FileUtils.mkdir_p(repos_dir) - subject.select_product("ALP-Dolomite") + subject.select_product("Tumbleweed") end after do @@ -218,7 +218,7 @@ end it "registers the repository from config" do - expect(repositories).to receive(:add).with(/Dolomite/) + expect(repositories).to receive(:add).with(/tumbleweed/) expect(repositories).to receive(:load) subject.probe end diff --git a/web/src/App.jsx b/web/src/App.jsx index c02367ddd4..4099afcd10 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -24,7 +24,7 @@ import { Outlet } from "react-router-dom"; import { _ } from "~/i18n"; import { useInstallerClient, useInstallerClientStatus } from "~/context/installer"; -import { useSoftware } from "./context/software"; +import { useProduct } from "./context/product"; import { STARTUP, INSTALL } from "~/client/phase"; import { BUSY } from "~/client/status"; @@ -39,7 +39,6 @@ import { ShowTerminalButton, Sidebar } from "~/components/core"; -import { ChangeProductLink } from "~/components/software"; import { LanguageSwitcher } from "./components/l10n"; import { Layout, Loading, Title } from "./components/layout"; import { useL10n } from "./context/l10n"; @@ -57,7 +56,7 @@ const ATTEMPTS = 3; function App() { const client = useInstallerClient(); const { attempt } = useInstallerClientStatus(); - const { products } = useSoftware(); + const { products } = useProduct(); const { language } = useL10n(); const [status, setStatus] = useState(undefined); const [phase, setPhase] = useState(undefined); @@ -107,7 +106,6 @@ function App() { <>
- diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 84092633b6..59b0753521 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -31,9 +31,9 @@ jest.mock("~/client"); // list of available products let mockProducts; -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, selectedProduct: null diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index 6b64d2f47b..1f84016877 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -142,6 +142,10 @@ padding: 0; } +.p-0 { + padding: 0; +} + .no-stack-gutter { --stack-gutter: 0; } diff --git a/web/src/client/index.js b/web/src/client/index.js index b4d56f18e0..34a656972e 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -30,7 +30,6 @@ import { UsersClient } from "./users"; import phase from "./phase"; import { QuestionsClient } from "./questions"; import { NetworkClient } from "./network"; -import { IssuesClient } from "./issues"; import cockpit from "../lib/cockpit"; const BUS_ADDRESS_FILE = "/run/agama/bus.address"; @@ -38,21 +37,34 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; /** * @typedef {object} InstallerClient - * @property {LanguageClient} language - language client - * @property {ManagerClient} manager - manager client - * @property {Monitor} monitor - service monitor - * @property {NetworkClient} network - network client - * @property {SoftwareClient} software - software client - * @property {StorageClient} storage - storage client - * @property {UsersClient} users - users client - * @property {QuestionsClient} questions - questions client - * @property {IssuesClient} issues - issues client + * @property {LanguageClient} language - language client. + * @property {ManagerClient} manager - manager client. + * @property {Monitor} monitor - service monitor. + * @property {NetworkClient} network - network client. + * @property {SoftwareClient} software - software client. + * @property {StorageClient} storage - storage client. + * @property {UsersClient} users - users client. + * @property {QuestionsClient} questions - questions client. + * @property {() => Promise} issues - issues from all contexts. + * @property {(handler: IssuesHandler) => (() => void)} onIssuesChange - registers a handler to run + * when issues from any context change. It returns a function to deregister the handler. * @property {() => Promise} isConnected - determines whether the client is connected * @property {(handler: () => void) => (() => void)} onDisconnect - registers a handler to run * when the connection is lost. It returns a function to deregister the * handler. */ +/** + * @typedef {import ("~/client/mixins").Issue} Issue + * + * @typedef {object} Issues + * @property {Issue[]} [product] - Issues from product. + * @property {Issue[]} [storage] - Issues from storage. + * @property {Issue[]} [software] - Issues from software. + * + * @typedef {(issues: Issues) => void} IssuesHandler +*/ + /** * Creates the Agama client * @@ -67,7 +79,38 @@ const createClient = (address = "unix:path=/run/agama/bus") => { const storage = new StorageClient(address); const users = new UsersClient(address); const questions = new QuestionsClient(address); - const issues = new IssuesClient({ storage }); + + /** + * Gets all issues, grouping them by context. + * + * TODO: issues are requested by several components (e.g., overview sections, notifications + * provider, issues page, storage page, etc). There should be an issues provider. + * + * @returns {Promise} + */ + const issues = async () => { + return { + product: await software.product.getIssues(), + storage: await storage.getIssues(), + software: await software.getIssues() + }; + }; + + /** + * Registers a callback to be executed when issues change. + * + * @param {IssuesHandler} handler - Callback function. + * @return {() => void} - Function to deregister the callback. + */ + const onIssuesChange = (handler) => { + const unsubscribeCallbacks = []; + + unsubscribeCallbacks.push(software.product.onIssuesChange(i => handler({ product: i }))); + unsubscribeCallbacks.push(storage.onIssuesChange(i => handler({ storage: i }))); + unsubscribeCallbacks.push(software.onIssuesChange(i => handler({ software: i }))); + + return () => { unsubscribeCallbacks.forEach(cb => cb()) }; + }; const isConnected = async () => { try { @@ -88,6 +131,7 @@ const createClient = (address = "unix:path=/run/agama/bus") => { users, questions, issues, + onIssuesChange, isConnected, onDisconnect: (handler) => monitor.onDisconnect(handler) }; diff --git a/web/src/client/issues.js b/web/src/client/issues.js deleted file mode 100644 index f6a38d4e26..0000000000 --- a/web/src/client/issues.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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. - */ - -// @ts-check - -/** - * @typedef {object} ClientsIssues - * @property {import ("~/client/mixins").Issue[]} storage - Issues from storage client - */ - -/** - * Client for managing all issues, independently on the service owning the issues - */ -class IssuesClient { - /** - * @param {object} clients - Clients managing issues - * @param {import ("~/client/storage").StorageClient} clients.storage - */ - constructor(clients) { - this.clients = clients; - } - - /** - * Get issues from all clients managing issues - * - * @returns {Promise} - */ - async getAll() { - const storage = await this.clients.storage.getIssues(); - - return { storage }; - } - - /** - * Checks whether there is some error - * - * @returns {Promise} - */ - async any() { - const clientsIssues = await this.getAll(); - const issues = Object.values(clientsIssues).flat(); - - return issues.length > 0; - } - - /** - * Registers a callback for each service to be executed when its issues change - * - * @param {import ("~/client/mixins").IssuesHandler} handler - callback function - * @return {import ("./dbus").RemoveFn} function to disable the callback - */ - onIssuesChange(handler) { - const unsubscribeCallbacks = []; - unsubscribeCallbacks.push(this.clients.storage.onIssuesChange(handler)); - - return () => { unsubscribeCallbacks.forEach(cb => cb()) }; - } -} - -export { IssuesClient }; diff --git a/web/src/client/issues.test.js b/web/src/client/issues.test.js deleted file mode 100644 index 2eec15df3f..0000000000 --- a/web/src/client/issues.test.js +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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. - */ - -// @ts-check - -import { IssuesClient } from "./issues"; -import { StorageClient } from "./storage"; - -const storageIssues = [ - { description: "Storage issue 1", severity: "error", details: "", source: "" }, - { description: "Storage issue 2", severity: "warn", details: "", source: "" }, - { description: "Storage issue 3", severity: "error", details: "", source: "" } -]; - -const issues = { - storage: [] -}; - -jest.spyOn(StorageClient.prototype, 'getIssues').mockImplementation(async () => issues.storage); -jest.spyOn(StorageClient.prototype, 'onIssuesChange'); - -const clientsWithIssues = { - storage: new StorageClient() -}; - -describe("#getAll", () => { - beforeEach(() => { - issues.storage = storageIssues; - }); - - it("returns all the storage issues", async () => { - const client = new IssuesClient(clientsWithIssues); - - const { storage } = await client.getAll(); - expect(storage).toEqual(expect.arrayContaining(storageIssues)); - }); -}); - -describe("#any", () => { - describe("if there are storage issues", () => { - beforeEach(() => { - issues.storage = storageIssues; - }); - - it("returns true", async () => { - const client = new IssuesClient(clientsWithIssues); - - const result = await client.any(); - expect(result).toEqual(true); - }); - }); - - describe("if there are no issues", () => { - beforeEach(() => { - issues.storage = []; - }); - - it("returns false", async () => { - const client = new IssuesClient(clientsWithIssues); - - const result = await client.any(); - expect(result).toEqual(false); - }); - }); -}); - -describe("#onIssuesChange", () => { - it("subscribes to changes in storage issues", () => { - const client = new IssuesClient(clientsWithIssues); - - const handler = jest.fn(); - client.onIssuesChange(handler); - - expect(clientsWithIssues.storage.onIssuesChange).toHaveBeenCalledWith(handler); - }); -}); diff --git a/web/src/client/software.js b/web/src/client/software.js index 193f90575e..edec2fcc21 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -29,6 +29,7 @@ const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; const SOFTWARE_PATH = "/org/opensuse/Agama/Software1"; const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; +const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; /** * @typedef {object} Product @@ -37,6 +38,175 @@ const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; * @property {string} description - Product description */ +/** + * @typedef {object} Registration + * @property {string} requirement - Registration requirement (i.e., "not-required", "optional", + * "mandatory"). + * @property {string|null} code - Registration code, if any. + * @property {string|null} email - Registration email, if any. + */ + +/** + * @typedef {object} ActionResult + * @property {boolean} success - Whether the action was successfully done. + * @property {string} message - Result message. + */ + +/** + * Product manager. + * @ignore + */ +class BaseProductManager { + /** + * @param {DBusClient} client + */ + constructor(client) { + this.client = client; + } + + /** + * Returns the list of available products. + * + * @return {Promise>} + */ + async getAll() { + const proxy = await this.client.proxy(PRODUCT_IFACE); + return proxy.AvailableProducts.map(product => { + const [id, name, meta] = product; + return { id, name, description: meta.description?.v }; + }); + } + + /** + * Returns the selected product. + * + * @return {Promise} + */ + async getSelected() { + const products = await this.getAll(); + const proxy = await this.client.proxy(PRODUCT_IFACE); + if (proxy.SelectedProduct === "") { + return null; + } + return products.find(product => product.id === proxy.SelectedProduct); + } + + /** + * Selects a product for installation. + * + * @param {string} id - Product ID. + */ + async select(id) { + const proxy = await this.client.proxy(PRODUCT_IFACE); + return proxy.SelectProduct(id); + } + + /** + * Registers a callback to run when properties in the Product object change. + * + * @param {(id: string) => void} handler - Callback function. + */ + onChange(handler) { + return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { + if ("SelectedProduct" in changes) { + const selected = changes.SelectedProduct.v.toString(); + handler(selected); + } + }); + } + + /** + * Returns the registration of the selected product. + * + * @return {Promise} + */ + async getRegistration() { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const requirement = this.registrationRequirement(proxy.Requirement); + const code = proxy.RegCode; + const email = proxy.Email; + + const registration = { requirement, code, email }; + if (code.length === 0) registration.code = null; + if (email.length === 0) registration.email = null; + + return registration; + } + + /** + * Tries to register the selected product. + * + * @param {string} code + * @param {string} [email] + * @returns {Promise} + */ + async register(code, email = "") { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const result = await proxy.Register(code, { Email: { t: "s", v: email } }); + + return { + success: result[0] === 0, + message: result[1] + }; + } + + /** + * Tries to deregister the selected product. + * + * @returns {Promise} + */ + async deregister() { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const result = await proxy.Deregister(); + + return { + success: result[0] === 0, + message: result[1] + }; + } + + /** + * Registers a callback to run when the registration changes. + * + * @param {(registration: Registration) => void} handler - Callback function. + */ + onRegistrationChange(handler) { + return this.client.onObjectChanged(PRODUCT_PATH, REGISTRATION_IFACE, () => { + this.getRegistration().then(handler); + }); + } + + /** + * Helper method to generate the requirement representation. + * @private + * + * @param {number} value - D-Bus registration value. + * @returns {string} + */ + registrationRequirement(value) { + let requirement; + + switch (value) { + case 0: + requirement = "not-required"; + break; + case 1: + requirement = "optional"; + break; + case 2: + requirement = "mandatory"; + break; + } + + return requirement; + } +} + +/** + * Manages product selection. + */ +class ProductManager extends WithIssues(BaseProductManager, PRODUCT_PATH) { } + /** * Software client * @@ -48,6 +218,7 @@ class SoftwareBaseClient { */ constructor(address = undefined) { this.client = new DBusClient(SOFTWARE_SERVICE, address); + this.product = new ProductManager(this.client); } /** @@ -60,19 +231,6 @@ class SoftwareBaseClient { return proxy.Probe(); } - /** - * Returns the list of available products - * - * @return {Promise>} - */ - async getProducts() { - const proxy = await this.client.proxy(PRODUCT_IFACE); - return proxy.AvailableProducts.map(product => { - const [id, name, meta] = product; - return { id, name, description: meta.description?.v }; - }); - } - /** * Returns how much space installation takes on disk * @@ -130,48 +288,10 @@ class SoftwareBaseClient { const proxy = await this.client.proxy(SOFTWARE_IFACE); return proxy.RemovePattern(name); } - - /** - * Returns the selected product - * - * @return {Promise} - */ - async getSelectedProduct() { - const products = await this.getProducts(); - const proxy = await this.client.proxy(PRODUCT_IFACE); - if (proxy.SelectedProduct === "") { - return null; - } - return products.find(product => product.id === proxy.SelectedProduct); - } - - /** - * Selects a product for installation - * - * @param {string} id - product ID - */ - async selectProduct(id) { - const proxy = await this.client.proxy(PRODUCT_IFACE); - return proxy.SelectProduct(id); - } - - /** - * Registers a callback to run when properties in the Software object change - * - * @param {(id: string) => void} handler - callback function - */ - onProductChange(handler) { - return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { - if ("SelectedProduct" in changes) { - const selected = changes.SelectedProduct.v.toString(); - handler(selected); - } - }); - } } /** - * Allows getting the list the available products and selecting one for installation. + * Manages software and product configuration. */ class SoftwareClient extends WithIssues( WithProgress( diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index 65fd0e2a9a..dfcdaeaed7 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -27,6 +27,7 @@ import { SoftwareClient } from "./software"; jest.mock("./dbus"); const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; +const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; const productProxy = { wait: jest.fn(), @@ -37,34 +38,154 @@ const productProxy = { SelectedProduct: "MicroOS" }; +const registrationProxy = {}; + beforeEach(() => { // @ts-ignore DBusClient.mockImplementation(() => { return { proxy: (iface) => { if (iface === PRODUCT_IFACE) return productProxy; + if (iface === REGISTRATION_IFACE) return registrationProxy; } }; }); }); -describe("#getProducts", () => { - it("returns the list of available products", async () => { - const client = new SoftwareClient(); - const availableProducts = await client.getProducts(); - expect(availableProducts).toEqual([ - { id: "MicroOS", name: "openSUSE MicroOS" }, - { id: "Tumbleweed", name: "openSUSE Tumbleweed" } - ]); +describe("#product", () => { + describe("#getAll", () => { + it("returns the list of available products", async () => { + const client = new SoftwareClient(); + const availableProducts = await client.product.getAll(); + expect(availableProducts).toEqual([ + { id: "MicroOS", name: "openSUSE MicroOS" }, + { id: "Tumbleweed", name: "openSUSE Tumbleweed" } + ]); + }); }); -}); -describe('#getSelectedProduct', () => { - it("returns the selected product", async () => { - const client = new SoftwareClient(); - const selectedProduct = await client.getSelectedProduct(); - expect(selectedProduct).toEqual( - { id: "MicroOS", name: "openSUSE MicroOS" } - ); + describe("#getSelected", () => { + it("returns the selected product", async () => { + const client = new SoftwareClient(); + const selectedProduct = await client.product.getSelected(); + expect(selectedProduct).toEqual( + { id: "MicroOS", name: "openSUSE MicroOS" } + ); + }); + }); + + describe("#getRegistration", () => { + describe("if the product is not registered yet", () => { + beforeEach(() => { + registrationProxy.RegCode = ""; + registrationProxy.Email = ""; + registrationProxy.Requirement = 1; + }); + + it("returns the expected registration result", async () => { + const client = new SoftwareClient(); + const registration = await client.product.getRegistration(); + expect(registration).toStrictEqual({ + code: null, + email: null, + requirement: "optional" + }); + }); + }); + + describe("if the product is registered", () => { + beforeEach(() => { + registrationProxy.RegCode = "111222"; + registrationProxy.Email = "test@test.com"; + registrationProxy.Requirement = 2; + }); + + it("returns the expected registration", async () => { + const client = new SoftwareClient(); + const registration = await client.product.getRegistration(); + expect(registration).toStrictEqual({ + code: "111222", + email: "test@test.com", + requirement: "mandatory" + }); + }); + }); + }); + + describe("#register", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); + }); + + it("performs the expected D-Bus call", async () => { + const client = new SoftwareClient(); + await client.product.register("111222", "test@test.com"); + expect(registrationProxy.Register).toHaveBeenCalledWith( + "111222", + { Email: { t: "s", v: "test@test.com" } } + ); + }); + + describe("when the action is correctly done", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); + }); + + it("returns a successful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.register("111222", "test@test.com"); + expect(result).toStrictEqual({ + success: true, + message: "" + }); + }); + }); + + describe("when the action fails", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([1, "error message"]); + }); + + it("returns an unsuccessful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.register("111222", "test@test.com"); + expect(result).toStrictEqual({ + success: false, + message: "error message" + }); + }); + }); + }); + + describe("#deregister", () => { + describe("when the action is correctly done", () => { + beforeEach(() => { + registrationProxy.Deregister = jest.fn().mockResolvedValue([0, ""]); + }); + + it("returns a successful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.deregister(); + expect(result).toStrictEqual({ + success: true, + message: "" + }); + }); + }); + + describe("when the action fails", () => { + beforeEach(() => { + registrationProxy.Deregister = jest.fn().mockResolvedValue([1, "error message"]); + }); + + it("returns an unsuccessful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.deregister(); + expect(result).toStrictEqual({ + success: false, + message: "error message" + }); + }); + }); }); }); diff --git a/web/src/components/core/EmailInput.jsx b/web/src/components/core/EmailInput.jsx new file mode 100644 index 0000000000..dc84711157 --- /dev/null +++ b/web/src/components/core/EmailInput.jsx @@ -0,0 +1,86 @@ +/* + * 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, { useEffect, useState } from "react"; +import { InputGroup, TextInput } from "@patternfly/react-core"; +import { noop } from "~/utils"; + +/** + * Email validation. + * + * Code inspired by https://github.com/manishsaraan/email-validator/blob/master/index.js + * + * @param {string} email + * @returns {boolean} + */ +const validateEmail = (email) => { + const regexp = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + + const validateFormat = (email) => { + const parts = email.split('@'); + + return parts.length === 2 && regexp.test(email); + }; + + const validateSizes = (email) => { + const [account, address] = email.split('@'); + + if (account.length > 64) return false; + if (address.length > 255) return false; + + const domainParts = address.split('.'); + + if (domainParts.find(p => p.length > 63)) return false; + + return true; + }; + + return validateFormat(email) && validateSizes(email); +}; + +/** + * Renders an email input field which validates its value. + * @component + * + * @param {(boolean) => void} onValidate - Callback to be called every time the input value is + * validated. + * @param {Object} props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, + * except `type` and `validated` which are managed by the component. + */ +export default function EmailInput({ onValidate = noop, ...props }) { + const [isValid, setIsValid] = useState(true); + + useEffect(() => { + const isValid = props.value.length === 0 || validateEmail(props.value); + setIsValid(isValid); + onValidate(isValid); + }, [onValidate, props.value, setIsValid]); + + return ( + + + + ); +} diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.jsx new file mode 100644 index 0000000000..0c64ce08b7 --- /dev/null +++ b/web/src/components/core/EmailInput.test.jsx @@ -0,0 +1,92 @@ +/* + * 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, { useState } from "react"; +import { screen } from "@testing-library/react"; + +import EmailInput from "./EmailInput"; +import { plainRender } from "~/test-utils"; + +describe("EmailInput component", () => { + it("renders an email input", () => { + plainRender( + + ); + + const inputField = screen.getByRole('textbox', { name: "User email" }); + expect(inputField).toHaveAttribute("type", "email"); + }); + + // Using a controlled component for testing the rendered result instead of testing if + // the given onChange callback is called. The former is more aligned with the + // React Testing Library principles, https://testing-library.com/docs/guiding-principles/ + const EmailInputTest = (props) => { + const [email, setEmail] = useState(""); + const [isValid, setIsValid] = useState(true); + + return ( + <> + setEmail(v)} + onValidate={setIsValid} + /> + {email &&

Email value updated!

} + {isValid === false &&

Email is not valid!

} + + ); + }; + + it("triggers onChange callback", async () => { + const { user } = plainRender(); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); + + expect(screen.queryByText("Email value updated!")).toBeNull(); + + await user.type(emailInput, "test@test.com"); + screen.getByText("Email value updated!"); + }); + + it("triggers onValidate callback", async () => { + const { user } = plainRender(); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); + + expect(screen.queryByText("Email is not valid!")).toBeNull(); + + await user.type(emailInput, "foo"); + await screen.findByText("Email is not valid!"); + }); + + it("marks the input as invalid if the value is not a valid email", async () => { + const { user } = plainRender(); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); + + await user.type(emailInput, "foo"); + + expect(emailInput).toHaveAttribute("aria-invalid"); + }); +}); diff --git a/web/src/components/core/IssuesLink.test.jsx b/web/src/components/core/IssuesLink.test.jsx index dc2fdae1ba..8073276beb 100644 --- a/web/src/components/core/IssuesLink.test.jsx +++ b/web/src/components/core/IssuesLink.test.jsx @@ -25,17 +25,15 @@ import { installerRender, withNotificationProvider } from "~/test-utils"; import { createClient } from "~/client"; import { IssuesLink } from "~/components/core"; -let hasIssues = false; +let mockIssues = {}; jest.mock("~/client"); beforeEach(() => { createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(hasIssues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: jest.fn() }; }); }); @@ -48,7 +46,9 @@ it("renders a link for navigating to the issues page", async () => { describe("if there are issues", () => { beforeEach(() => { - hasIssues = true; + mockIssues = { + storage: [{ description: "issue 1" }] + }; }); it("includes a notification mark", async () => { @@ -60,7 +60,7 @@ describe("if there are issues", () => { describe("if there are not issues", () => { beforeEach(() => { - hasIssues = false; + mockIssues = {}; }); it("does not include a notification mark", async () => { diff --git a/web/src/components/core/IssuesPage.jsx b/web/src/components/core/IssuesPage.jsx index 7d137c5fca..f076c6aece 100644 --- a/web/src/components/core/IssuesPage.jsx +++ b/web/src/components/core/IssuesPage.jsx @@ -21,17 +21,17 @@ import React, { useCallback, useEffect, useState } from "react"; -import { HelperText, HelperTextItem, Skeleton } from "@patternfly/react-core"; +import { HelperText, HelperTextItem } from "@patternfly/react-core"; import { partition, useCancellablePromise } from "~/utils"; -import { If, Page, Section } from "~/components/core"; +import { If, Page, Section, SectionSkeleton } from "~/components/core"; import { Icon } from "~/components/layout"; import { useInstallerClient } from "~/context/installer"; import { useNotification } from "~/context/notification"; import { _ } from "~/i18n"; /** - * Renders an issue + * Item representing an issue. * @component * * @param {object} props @@ -58,54 +58,68 @@ const IssueItem = ({ issue }) => { }; /** - * Generates a specific section with issues + * Generates issue items sorted by severity. * @component * * @param {object} props * @param {import ("~/client/mixins").Issue[]} props.issues - * @param {object} props.props */ -const IssuesSection = ({ issues, ...props }) => { - if (issues.length === 0) return null; - +const IssueItems = ({ issues = [] }) => { const sortedIssues = partition(issues, i => i.severity === "error").flat(); - const issueItems = sortedIssues.map((issue, index) => { + return sortedIssues.map((issue, index) => { return ; }); - - return ( -
- {issueItems} -
- ); }; /** - * Generates the sections with issues + * Generates the sections with issues. * @component * * @param {object} props * @param {import ("~/client/issues").ClientsIssues} props.issues */ const IssuesSections = ({ issues }) => { + const productIssues = issues.product || []; + const storageIssues = issues.storage || []; + const softwareIssues = issues.software || []; + return ( - + <> + 0} + then={ +
+ +
+ } + /> + 0} + then={ +
+ +
+ } + /> + 0} + then={ +
+ +
+ } + /> + ); }; /** - * Generates the content for each section with issues. If there are no issues, then a success - * message is shown. + * Generates sections with issues. If there are no issues, then a success message is shown. * @component * * @param {object} props - * @param {import ("~/client/issues").ClientsIssues} props.issues + * @param {import ("~/client").Issues} props.issues */ const IssuesContent = ({ issues }) => { const NoIssues = () => { @@ -130,28 +144,32 @@ const IssuesContent = ({ issues }) => { }; /** - * Page to show all issues per section + * Page to show all issues. * @component */ export default function IssuesPage() { const [isLoading, setIsLoading] = useState(true); - const [issues, setIssues] = useState({}); - const { issues: client } = useInstallerClient(); + const [issues, setIssues] = useState(); + const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); - const [, updateNotification] = useNotification(); + const [notification, updateNotification] = useNotification(); - const loadIssues = useCallback(async () => { + const load = useCallback(async () => { setIsLoading(true); - const allIssues = await cancellablePromise(client.getAll()); - setIssues(allIssues); + const issues = await cancellablePromise(client.issues()); setIsLoading(false); - updateNotification({ issues: false }); - }, [client, cancellablePromise, setIssues, setIsLoading, updateNotification]); + return issues; + }, [client, cancellablePromise, setIsLoading]); + + const update = useCallback((issues) => { + setIssues(current => ({ ...current, ...issues })); + if (notification.issues) updateNotification({ issues: false }); + }, [notification, setIssues, updateNotification]); useEffect(() => { - loadIssues(); - return client.onIssuesChange(loadIssues); - }, [client, loadIssues]); + load().then(update); + return client.onIssuesChange(update); + }, [client, load, update]); return ( } + then={} else={} /> diff --git a/web/src/components/core/IssuesPage.test.jsx b/web/src/components/core/IssuesPage.test.jsx index 061357e68c..97badf900d 100644 --- a/web/src/components/core/IssuesPage.test.jsx +++ b/web/src/components/core/IssuesPage.test.jsx @@ -20,37 +20,43 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; -import { installerRender, withNotificationProvider } from "~/test-utils"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import { installerRender, createCallbackMock, withNotificationProvider } from "~/test-utils"; import { createClient } from "~/client"; import { IssuesPage } from "~/components/core"; jest.mock("~/client"); jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - return { - ...original, + ...jest.requireActual("@patternfly/react-core"), Skeleton: () =>
PFSkeleton
}; }); -let issues = { +const issues = { + product: [], storage: [ - { description: "Issue 1", details: "Details 1", source: "system", severity: "warn" }, - { description: "Issue 2", details: "Details 2", source: "config", severity: "error" } + { description: "storage issue 1", details: "Details 1", source: "system", severity: "warn" }, + { description: "storage issue 2", details: "Details 2", source: "config", severity: "error" } + ], + software: [ + { description: "software issue 1", details: "Details 1", source: "system", severity: "warn" } ] }; +let mockIssues; + +let mockOnIssuesChange; + beforeEach(() => { + mockIssues = { ...issues }; + mockOnIssuesChange = jest.fn(); + createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(true), - getAll: () => Promise.resolve(issues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: mockOnIssuesChange }; }); }); @@ -59,20 +65,25 @@ it("loads the issues", async () => { installerRender(withNotificationProvider()); screen.getAllByText(/PFSkeleton/); - await screen.findByText(/Issue 1/); + await screen.findByText(/storage issue 1/); }); it("renders sections with issues", async () => { installerRender(withNotificationProvider()); - const section = await screen.findByRole("region", { name: "Storage" }); - within(section).findByText(/Issue 1/); - within(section).findByText(/Issue 2/); + await waitFor(() => expect(screen.queryByText("Product")).not.toBeInTheDocument()); + + const storageSection = await screen.findByText(/Storage/); + within(storageSection).findByText(/storage issue 1/); + within(storageSection).findByText(/storage issue 2/); + + const softwareSection = await screen.findByText(/Software/); + within(softwareSection).findByText(/software issue 1/); }); describe("if there are not issues", () => { beforeEach(() => { - issues = { storage: [] }; + mockIssues = { product: [], storage: [], software: [] }; }); it("renders a success message", async () => { @@ -81,3 +92,21 @@ describe("if there are not issues", () => { await screen.findByText(/No issues found/); }); }); + +describe("if the issues change", () => { + it("shows the new issues", async () => { + const [mockFunction, callbacks] = createCallbackMock(); + mockOnIssuesChange = mockFunction; + + installerRender(withNotificationProvider()); + + await screen.findByText("Storage"); + + mockIssues.storage = []; + act(() => callbacks.forEach(c => c({ storage: mockIssues.storage }))); + + await waitFor(() => expect(screen.queryByText("Storage")).not.toBeInTheDocument()); + const softwareSection = await screen.findByText(/Software/); + within(softwareSection).findByText(/software issue 1/); + }); +}); diff --git a/web/src/components/core/SectionSkeleton.jsx b/web/src/components/core/SectionSkeleton.jsx index b8e0fa8d70..e34da05402 100644 --- a/web/src/components/core/SectionSkeleton.jsx +++ b/web/src/components/core/SectionSkeleton.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -23,19 +23,27 @@ import React from "react"; import { Skeleton } from "@patternfly/react-core"; import { _ } from "~/i18n"; -const SectionSkeleton = () => ( - <> +const WaitingSkeleton = ({ width }) => { + return ( - - -); + ); +}; + +const SectionSkeleton = ({ numRows = 2 }) => { + return ( + <> + { + Array.from({ length: numRows }, (_, i) => { + const width = i % 2 === 0 ? "50%" : "25%"; + return ; + }) + } + + ); +}; export default SectionSkeleton; diff --git a/web/src/components/core/Sidebar.test.jsx b/web/src/components/core/Sidebar.test.jsx index cf20df357d..dce362f2fd 100644 --- a/web/src/components/core/Sidebar.test.jsx +++ b/web/src/components/core/Sidebar.test.jsx @@ -27,19 +27,18 @@ import { createClient } from "~/client"; // Mock some components using contexts and not relevant for below tests jest.mock("~/components/core/LogsButton", () => () =>
LogsButton Mock
); -jest.mock("~/components/software/ChangeProductLink", () => () =>
ChangeProductLink Mock
); -let hasIssues = false; +let mockIssues; jest.mock("~/client"); beforeEach(() => { + mockIssues = []; + createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(hasIssues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: jest.fn() }; }); }); @@ -137,7 +136,13 @@ describe("onClick bubbling", () => { describe("if there are issues", () => { beforeEach(() => { - hasIssues = true; + mockIssues = { + software: [ + { + description: "software issue 1", details: "Details 1", source: "system", severity: "warn" + } + ] + }; }); it("includes a notification mark", async () => { @@ -149,7 +154,7 @@ describe("if there are issues", () => { describe("if there are not issues", () => { beforeEach(() => { - hasIssues = false; + mockIssues = []; }); it("does not include a notification mark", async () => { diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index d457f49889..73593f96ed 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -30,6 +30,7 @@ export { default as FormLabel } from "./FormLabel"; export { default as FormValidationError } from "./FormValidationError"; export { default as Fieldset } from "./Fieldset"; export { default as Em } from "./Em"; +export { default as EmailInput } from "./EmailInput"; export { default as If } from "./If"; export { default as Installation } from "./Installation"; export { default as InstallationFinished } from "./InstallationFinished"; diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index f3866cccf3..42c32748af 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -44,6 +44,7 @@ import HomeStorage from "@icons/home_storage.svg?component"; import Info from "@icons/info.svg?component"; import Inventory from "@icons/inventory_2.svg?component"; import Lan from "@icons/lan.svg?component"; +import ListAlt from "@icons/list_alt.svg?component"; import Lock from "@icons/lock.svg?component"; import ManageAccounts from "@icons/manage_accounts.svg?component"; import Menu from "@icons/menu.svg?component"; @@ -97,6 +98,7 @@ const icons = { inventory_2: Inventory, lan: Lan, loading: Loading, + list_alt: ListAlt, lock: Lock, manage_accounts: ManageAccounts, menu: Menu, diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index ce8c47c111..1429b7f1a8 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -61,7 +61,7 @@ export default function L10nSection({ showErrors }) { const SectionContent = () => { const { busy, languages, language } = state; - if (busy) return ; + if (busy) return ; const selected = languages.find(lang => lang.id === language); diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index ed13813746..eebce05b48 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -20,20 +20,21 @@ */ import React, { useState } from "react"; -import { useSoftware } from "~/context/software"; +import { useProduct } from "~/context/product"; import { Navigate } from "react-router-dom"; - import { Page, InstallButton } from "~/components/core"; import { L10nSection, NetworkSection, + ProductSection, SoftwareSection, StorageSection, UsersSection } from "~/components/overview"; +import { _ } from "~/i18n"; function Overview() { - const { selectedProduct } = useSoftware(); + const { selectedProduct } = useProduct(); const [showErrors, setShowErrors] = useState(false); if (selectedProduct === null) { @@ -42,10 +43,12 @@ function Overview() { return ( setShowErrors(true)} />} > + diff --git a/web/src/components/overview/Overview.test.jsx b/web/src/components/overview/Overview.test.jsx index 05c12e0f9e..dcb0733bd5 100644 --- a/web/src/components/overview/Overview.test.jsx +++ b/web/src/components/overview/Overview.test.jsx @@ -34,9 +34,9 @@ const startInstallationFn = jest.fn(); jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, selectedProduct: mockProduct @@ -44,6 +44,7 @@ jest.mock("~/context/software", () => ({ } })); +jest.mock("~/components/overview/ProductSection", () => () =>
Product Section
); jest.mock("~/components/overview/L10nSection", () => () =>
Localization Section
); jest.mock("~/components/overview/StorageSection", () => () =>
Storage Section
); jest.mock("~/components/overview/NetworkSection", () => () =>
Network Section
); @@ -68,9 +69,10 @@ beforeEach(() => { describe("when product is selected", () => { it("renders the Overview and the Install button", async () => { installerRender(); - const title = screen.getByText(/openSUSE Tumbleweed/i); + const title = screen.getByText(/installation summary/i); expect(title).toBeInTheDocument(); + await screen.findByText("Product Section"); await screen.findByText("Localization Section"); await screen.findByText("Network Section"); await screen.findByText("Storage Section"); diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx new file mode 100644 index 0000000000..83121fab21 --- /dev/null +++ b/web/src/components/overview/ProductSection.jsx @@ -0,0 +1,80 @@ +/* + * 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, { useEffect, useState } from "react"; +import { Text } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + +import { toValidationError, useCancellablePromise } from "~/utils"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; +import { Section, SectionSkeleton } from "~/components/core"; +import { _ } from "~/i18n"; + +const errorsFrom = (issues) => { + const errors = issues.filter(i => i.severity === "error"); + return errors.map(toValidationError); +}; + +const Content = ({ isLoading = false }) => { + const { registration, selectedProduct } = useProduct(); + + if (isLoading) return ; + + const isRegistered = registration?.code !== null; + const productName = selectedProduct?.name; + + return ( + + {/* TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) */} + {isRegistered ? sprintf(_("%s (registered)"), productName) : productName} + + ); +}; + +export default function ProductSection() { + const { software } = useInstallerClient(); + const [issues, setIssues] = useState([]); + const { selectedProduct } = useProduct(); + const { cancellablePromise } = useCancellablePromise(); + + useEffect(() => { + cancellablePromise(software.product.getIssues()).then(setIssues); + return software.product.onIssuesChange(setIssues); + }, [cancellablePromise, setIssues, software]); + + const isLoading = !selectedProduct; + const errors = isLoading ? [] : errorsFrom(issues); + + return ( +
+ +
+ ); +} diff --git a/web/src/components/overview/ProductSection.test.jsx b/web/src/components/overview/ProductSection.test.jsx new file mode 100644 index 0000000000..17edd0b62f --- /dev/null +++ b/web/src/components/overview/ProductSection.test.jsx @@ -0,0 +1,106 @@ +/* + * 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, waitFor } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { createClient } from "~/client"; +import { ProductSection } from "~/components/overview"; + +let mockRegistration; +let mockSelectedProduct; + +const mockIssue = { severity: "error", description: "Fake issue" }; + +jest.mock("~/client"); + +jest.mock("~/components/core/SectionSkeleton", () => () =>
Loading
); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ + registration: mockRegistration, + selectedProduct: mockSelectedProduct + }) +})); + +beforeEach(() => { + const issues = [mockIssue]; + mockRegistration = {}; + mockSelectedProduct = { name: "Test Product" }; + + createClient.mockImplementation(() => { + return { + software: { + product: { + getIssues: jest.fn().mockResolvedValue(issues), + onIssuesChange: jest.fn() + } + } + }; + }); +}); + +it("shows the product name", async () => { + installerRender(); + + await screen.findByText(/Test Product/); + await waitFor(() => expect(screen.queryByText("registered")).not.toBeInTheDocument()); +}); + +it("indicates whether the product is registered", async () => { + mockRegistration = { code: "111222" }; + installerRender(); + + await screen.findByText(/Test Product \(registered\)/); +}); + +it("shows the error", async () => { + installerRender(); + + await screen.findByText("Fake issue"); +}); + +it("does not show warnings", async () => { + mockIssue.severity = "warning"; + + installerRender(); + + await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument()); +}); + +describe("when no product is selected", () => { + beforeEach(() => { + mockSelectedProduct = undefined; + }); + + it("shows the skeleton", async () => { + installerRender(); + + await screen.findByText("Loading"); + }); + + it("does not show errors", async () => { + installerRender(); + + await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument()); + }); +}); diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.jsx index fd9867e5e1..fbcd81cee8 100644 --- a/web/src/components/overview/SoftwareSection.jsx +++ b/web/src/components/overview/SoftwareSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -20,13 +20,13 @@ */ import React, { useReducer, useEffect } from "react"; +import { BUSY } from "~/client/status"; import { Button } from "@patternfly/react-core"; -import { ProgressText, Section } from "~/components/core"; import { Icon } from "~/components/layout"; +import { ProgressText, Section } from "~/components/core"; +import { toValidationError, useCancellablePromise } from "~/utils"; import { UsedSize } from "~/components/software"; -import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; -import { BUSY } from "~/client/status"; import { _ } from "~/i18n"; const initialState = { @@ -78,10 +78,6 @@ export default function SoftwareSection({ showErrors }) { return client.onStatusChange(updateStatus); }, [client, cancellablePromise]); - useEffect(() => { - cancellablePromise(client.getStatus()).then(updateStatus); - }, [client, cancellablePromise]); - useEffect(() => { const updateProposal = async () => { const errors = await cancellablePromise(client.getIssues()); @@ -145,7 +141,7 @@ export default function SoftwareSection({ showErrors }) { title={_("Software")} icon="apps" loading={state.busy} - errors={errors.map(e => ({ message: e.description }))} + errors={errors.map(toValidationError)} path="/software" > diff --git a/web/src/components/overview/index.js b/web/src/components/overview/index.js index bfab42832b..c46f96127a 100644 --- a/web/src/components/overview/index.js +++ b/web/src/components/overview/index.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -22,6 +22,7 @@ export { default as Overview } from "./Overview"; export { default as L10nSection } from "./L10nSection"; export { default as NetworkSection } from "./NetworkSection"; +export { default as ProductSection } from "./ProductSection"; export { default as SoftwareSection } from "./SoftwareSection"; export { default as StorageSection } from "./StorageSection"; export { default as UsersSection } from "./UsersSection"; diff --git a/web/src/components/product/ProductPage.jsx b/web/src/components/product/ProductPage.jsx new file mode 100644 index 0000000000..77c671f855 --- /dev/null +++ b/web/src/components/product/ProductPage.jsx @@ -0,0 +1,439 @@ +/* + * 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. + */ + +// cspell:ignore Deregistration + +import React, { useEffect, useState } from "react"; +import { Alert, Button, Form } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + +import { _ } from "~/i18n"; +import { BUSY } from "~/client/status"; +import { If, Page, Popup, Section } from "~/components/core"; +import { noop, useCancellablePromise } from "~/utils"; +import { ProductRegistrationForm, ProductSelector } from "~/components/product"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; + +/** + * Popup for selecting a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly selected. + * @param {function} props.onCancel - Callback to be called when the product selection is canceled. + */ +const ChangeProductPopup = ({ isOpen = false, onFinish = noop, onCancel = noop }) => { + const { manager, software } = useInstallerClient(); + const { products, selectedProduct } = useProduct(); + const [newProductId, setNewProductId] = useState(selectedProduct?.id); + + const onSubmit = async (e) => { + e.preventDefault(); + + if (newProductId !== selectedProduct?.id) { + await software.product.select(newProductId); + manager.startProbing(); + } + + onFinish(); + }; + + return ( + +
+ + + + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup for registering a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly + * registered. + * @param {function} props.onCancel - Callback to be called when the product registration is + * canceled. + */ +const RegisterProductPopup = ({ + isOpen = false, + onFinish = noop, + onCancel: onCancelProp = noop +}) => { + const { software } = useInstallerClient(); + const { selectedProduct } = useProduct(); + const [isLoading, setIsLoading] = useState(false); + const [isFormValid, setIsFormValid] = useState(true); + const [error, setError] = useState(); + + const onSubmit = async ({ code, email }) => { + setIsLoading(true); + const result = await software.product.register(code, email); + setIsLoading(false); + if (result.success) { + software.probe(); + onFinish(); + } else { + setError(result.message); + } + }; + + const onCancel = () => { + setError(null); + onCancelProp(); + }; + + const isDisabled = isLoading || !isFormValid; + + return ( + + +

{error}

+ + } + /> + + + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup to deregister a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly + * deregistered. + * @param {function} props.onCancel - Callback to be called when the product de-registration is + * canceled. + */ +const DeregisterProductPopup = ({ + isOpen = false, + onFinish = noop, + onCancel: onCancelProp = noop +}) => { + const { software } = useInstallerClient(); + const { selectedProduct } = useProduct(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + + const onAccept = async () => { + setIsLoading(true); + const result = await software.product.deregister(); + setIsLoading(false); + if (result.success) { + software.probe(); + onFinish(); + } else { + setError(result.message); + } + }; + + const onCancel = () => { + setError(null); + onCancelProp(); + }; + + return ( + + +

{error}

+ + } + /> +

+ { + // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) + sprintf(_("Do you want to deregister %s?"), selectedProduct.name) + } +

+ + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup to show a warning when there is a registered product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onAccept - Callback to be called when the warning is accepted. + */ +const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => { + const { selectedProduct } = useProduct(); + + return ( + +

+ { + sprintf( + // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) + _("The product %s must be deregistered before selecting a new product."), + selectedProduct.name + ) + } +

+ + + {_("Accept")} + + +
+ ); +}; + +const ChangeProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const { registration } = useProduct(); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + const isRegistered = registration.code !== null; + + return ( + <> + + + } + else={ + + } + /> + + ); +}; + +/** + * Buttons for a product that is not registered yet. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const RegisterProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + ); +}; + +/** + * Buttons for a product that is not registered yet. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const DeregisterProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + ); +}; + +const ProductSection = ({ isLoading = false }) => { + const { products, selectedProduct } = useProduct(); + + return ( +
+

{selectedProduct?.description}

+ 1} + then={} + /> +
+ ); +}; + +const RegistrationContent = ({ isLoading = false }) => { + const { registration } = useProduct(); + + const mask = (v) => v.replace(v.slice(0, -4), "*".repeat(Math.max(v.length - 4, 0))); + + return ( + <> +
+ {_("Code:")} + {mask(registration.code)} +
+
+ {_("Email:")} + {registration.email} +
+ + + ); +}; + +const RegistrationSection = ({ isLoading = false }) => { + const { registration } = useProduct(); + + const isRequired = registration?.requirement !== "not-required"; + const isRegistered = registration?.code !== null; + + return ( + // TRANSLATORS: section title. +
+ } + else={ + <> +

{_("This product requires registration.")}

+ + + } + /> + } + else={

{_("This product does not require registration.")}

} + /> +
+ ); +}; + +/** + * Page for configuring a product. + * @component + */ +export default function ProductPage() { + const [managerStatus, setManagerStatus] = useState(); + const [softwareStatus, setSoftwareStatus] = useState(); + const { cancellablePromise } = useCancellablePromise(); + const { manager, software } = useInstallerClient(); + + useEffect(() => { + cancellablePromise(manager.getStatus()).then(setManagerStatus); + return manager.onStatusChange(setManagerStatus); + }, [cancellablePromise, manager]); + + useEffect(() => { + cancellablePromise(software.getStatus()).then(setSoftwareStatus); + return software.onStatusChange(setSoftwareStatus); + }, [cancellablePromise, software]); + + const isLoading = managerStatus === BUSY || softwareStatus === BUSY; + + return ( + + + + + ); +} diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx new file mode 100644 index 0000000000..af2531340a --- /dev/null +++ b/web/src/components/product/ProductPage.test.jsx @@ -0,0 +1,388 @@ +/* + * 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 { BUSY } from "~/client/status"; +import { installerRender } from "~/test-utils"; +import { ProductPage } from "~/components/product"; +import { createClient } from "~/client"; + +let mockManager; +let mockSoftware; +let mockProducts; +let mockRegistration; + +const products = [ + { + id: "Test-Product1", + name: "Test Product1", + description: "Test Product1 description" + }, + { + id: "Test-Product2", + name: "Test Product2", + description: "Test Product2 description" + } +]; + +const selectedProduct = { + id: "Test-Product1", + name: "Test Product1", + description: "Test Product1 description" +}; + +jest.mock("~/client"); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ products: mockProducts, selectedProduct, registration: mockRegistration }) +})); + +beforeEach(() => { + mockManager = { + startProbing: jest.fn(), + getStatus: jest.fn().mockResolvedValue(), + onStatusChange: jest.fn() + }; + + mockSoftware = { + probe: jest.fn(), + getStatus: jest.fn().mockResolvedValue(), + onStatusChange: jest.fn(), + product: { + getSelected: selectedProduct.id, + select: jest.fn().mockResolvedValue(), + onChange: jest.fn() + } + }; + + mockProducts = products; + + mockRegistration = { + requirement: "not-required", + code: null, + email: null + }; + + createClient.mockImplementation(() => ( + { + manager: mockManager, + software: mockSoftware + } + )); +}); + +it("renders the product name and description", async () => { + installerRender(); + await screen.findByText("Test Product1"); + await screen.findByText("Test Product1 description"); +}); + +it("shows a button to change the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Change product" }); +}); + +describe("if there is only a product", () => { + beforeEach(() => { + mockProducts = [products[0]]; + }); + + it("does not show a button to change the product", async () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Change product" })).not.toBeInTheDocument(); + }); +}); + +describe("if the product is already registered", () => { + beforeEach(() => { + mockRegistration = { + requirement: "mandatory", + code: "111222", + email: "test@test.com" + }; + }); + + it("shows the information about the registration", async () => { + installerRender(); + await screen.findByText("**1222"); + await screen.findByText("test@test.com"); + }); +}); + +describe("if the product does not require registration", () => { + beforeEach(() => { + mockRegistration.requirement = "not-required"; + }); + + it("does not show a button to register the product", async () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Register" })).not.toBeInTheDocument(); + }); +}); + +describe("if the product requires registration", () => { + beforeEach(() => { + mockRegistration.requirement = "required"; + }); + + describe("and the product is not registered yet", () => { + beforeEach(() => { + mockRegistration.code = null; + }); + + it("shows a button to register the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Register" }); + }); + }); + + describe("and the product is already registered", () => { + beforeEach(() => { + mockRegistration.code = "11112222"; + }); + + it("shows a button to deregister the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Deregister product" }); + }); + }); +}); + +describe("when the services are busy", () => { + beforeEach(() => { + mockRegistration.requirement = "required"; + mockRegistration.code = null; + mockSoftware.getStatus = jest.fn().mockResolvedValue(BUSY); + }); + + it("shows disabled buttons", async () => { + installerRender(); + + const selectButton = await screen.findByRole("button", { name: "Change product" }); + const registerButton = screen.getByRole("button", { name: "Register" }); + + expect(selectButton).toHaveAttribute("disabled"); + expect(registerButton).toHaveAttribute("disabled"); + }); +}); + +describe("when the button for changing the product is clicked", () => { + describe("and the product is not registered", () => { + beforeEach(() => { + mockRegistration.code = null; + }); + + it("opens a popup for selecting a new product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Choose a product"); + within(popup).getByRole("radio", { name: "Test Product1" }); + const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + + await user.click(radio); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.select).toHaveBeenCalledWith("Test-Product2"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without selecting a new product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + + await user.click(radio); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.select).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); + + describe("and the product is registered", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = "111222"; + }); + + it("shows a warning", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText(/must be deregistered/); + + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); + +describe("when the button for registering the product is clicked", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = null; + mockSoftware.product.register = jest.fn().mockResolvedValue({ success: true }); + }); + + it("opens a popup for registering the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Register Test Product1"); + const codeInput = within(popup).getByLabelText(/Registration code/); + const emailInput = within(popup).getByLabelText("Email"); + + await user.type(codeInput, "111222"); + await user.type(emailInput, "test@test.com"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.register).toHaveBeenCalledWith("111222", "test@test.com"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without registering the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.register).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if there is an error registering the product", () => { + beforeEach(() => { + mockSoftware.product.register = jest.fn().mockResolvedValue({ + success: false, + message: "Error registering product" + }); + }); + + it("does not close the popup and shows the error", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Register Test Product1"); + const codeInput = within(popup).getByLabelText(/Registration code/); + + await user.type(codeInput, "111222"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + within(popup).getByText("Error registering product"); + }); + }); +}); + +describe("when the button to perform product de-registration is clicked", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = "111222"; + mockSoftware.product.deregister = jest.fn().mockResolvedValue({ success: true }); + }); + + it("opens a popup to deregister the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Deregister Test Product1"); + + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.deregister).toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without performing product de-registration", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.deregister).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if there is an error performing the product de-registration", () => { + beforeEach(() => { + mockSoftware.product.deregister = jest.fn().mockResolvedValue({ + success: false, + message: "Product cannot be deregistered" + }); + }); + + it("does not close the popup and shows the error", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + within(popup).getByText("Product cannot be deregistered"); + }); + }); +}); diff --git a/web/src/components/product/ProductRegistrationForm.jsx b/web/src/components/product/ProductRegistrationForm.jsx new file mode 100644 index 0000000000..510f0c6c20 --- /dev/null +++ b/web/src/components/product/ProductRegistrationForm.jsx @@ -0,0 +1,76 @@ +/* + * 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, { useEffect, useState } from "react"; +import { Form, FormGroup } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { EmailInput, PasswordInput } from "~/components/core"; +import { noop } from "~/utils"; + +/** + * Form for registering a product. + * @component + * + * @param {object} props + * @param {boolean} props.id - Form id. + * @param {function} props.onSubmit - Callback to be called when the form is submitted. + * @param {(isValid: boolean) => void} props.onValidate - Callback to be called when the form is + * validated. + */ +export default function ProductRegistrationForm({ + id, + onSubmit: onSubmitProp = noop, + onValidate = noop +}) { + const [code, setCode] = useState(""); + const [email, setEmail] = useState(""); + const [isValidEmail, setIsValidEmail] = useState(true); + + const onSubmit = (e) => { + e.preventDefault(); + onSubmitProp({ code, email }); + }; + + useEffect(() => { + const validate = () => { + return code.length > 0 && isValidEmail; + }; + + onValidate(validate()); + }, [code, isValidEmail, onValidate]); + + return ( +
+ + setCode(v)} /> + + + setEmail(v)} + /> + +
+ ); +} diff --git a/web/src/components/product/ProductRegistrationForm.test.jsx b/web/src/components/product/ProductRegistrationForm.test.jsx new file mode 100644 index 0000000000..14a9d18c4a --- /dev/null +++ b/web/src/components/product/ProductRegistrationForm.test.jsx @@ -0,0 +1,93 @@ +/* + * 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, { useState } from "react"; +import { Button } from "@patternfly/react-core"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProductRegistrationForm } from "~/components/product"; + +it("renders a field for entering the registration code", async() => { + plainRender(); + await screen.findByLabelText(/Registration code/); +}); + +it("renders a field for entering an email", async() => { + plainRender(); + await screen.findByLabelText("Email"); +}); + +const ProductRegistrationFormTest = () => { + const [isSubmitted, setIsSubmitted] = useState(false); + const [isValid, setIsValid] = useState(true); + + return ( + <> + + + {isSubmitted &&

Form is submitted!

} + {isValid === false &&

Form is not valid!

} + + ); +}; + +it("triggers the onSubmit callback", async () => { + const { user } = plainRender(); + + expect(screen.queryByText("Form is submitted!")).toBeNull(); + + const button = screen.getByRole("button", { name: "Accept" }); + await user.click(button); + await screen.findByText("Form is submitted!"); +}); + +it("sets the form as invalid if there is no code", async () => { + plainRender(); + await screen.findByText("Form is not valid!"); +}); + +it("sets the form as invalid if there is a code and a wrong email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + const emailInput = await screen.findByLabelText("Email"); + await user.type(codeInput, "111222"); + await user.type(emailInput, "foo"); + + await screen.findByText("Form is not valid!"); +}); + +it("does not set the form as invalid if there is a code and no email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + await user.type(codeInput, "111222"); + + expect(screen.queryByText("Form is not valid!")).toBeNull(); +}); + +it("does not set the form as invalid if there is a code and a correct email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + const emailInput = await screen.findByLabelText("Email"); + await user.type(codeInput, "111222"); + await user.type(emailInput, "test@test.com"); + + expect(screen.queryByText("Form is not valid!")).toBeNull(); +}); diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx similarity index 58% rename from web/src/components/software/ProductSelectionPage.jsx rename to web/src/components/product/ProductSelectionPage.jsx index 86b8d622ef..585747a9db 100644 --- a/web/src/components/software/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.jsx @@ -21,47 +21,36 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useInstallerClient } from "~/context/installer"; -import { useSoftware } from "~/context/software"; -import { _ } from "~/i18n"; - -import { - Button, - Card, - CardBody, - Form, - FormGroup, - Radio -} from "@patternfly/react-core"; +import { Button, Form, FormGroup } from "@patternfly/react-core"; +import { _ } from "~/i18n"; import { Icon, Loading } from "~/components/layout"; +import { ProductSelector } from "~/components/product"; import { Title, PageIcon, MainActions } from "~/components/layout/Layout"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; function ProductSelectionPage() { - const client = useInstallerClient(); + const { manager, software } = useInstallerClient(); const navigate = useNavigate(); - const { products, selectedProduct } = useSoftware(); - const previous = selectedProduct?.id; - const [selected, setSelected] = useState(selectedProduct?.id); + const { products, selectedProduct } = useProduct(); + const [newProductId, setNewProductId] = useState(selectedProduct?.id); useEffect(() => { // TODO: display a notification in the UI to emphasizes that // selected product has changed - return client.software.onProductChange(() => navigate("/")); - }, [client.software, navigate]); - - const isSelected = p => p.id === selected; + return software.product.onChange(() => navigate("/")); + }, [software, navigate]); - const accept = async (e) => { + const onSubmit = async (e) => { e.preventDefault(); - if (selected === previous) { - navigate("/"); - return; + + if (newProductId !== selectedProduct?.id) { + // TODO: handle errors + await software.product.select(newProductId); + manager.startProbing(); } - // TODO: handle errors - await client.software.selectProduct(selected); - client.manager.startProbing(); navigate("/"); }; @@ -69,40 +58,20 @@ function ProductSelectionPage() { ); - const buildOptions = () => { - const options = products.map((p) => ( - - - setSelected(p.id)} - /> - - - )); - - return options; - }; - return ( <> {/* TRANSLATORS: page header */} {_("Product selection")} - - -
+ - {buildOptions()} +
diff --git a/web/src/components/software/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx similarity index 83% rename from web/src/components/software/ProductSelectionPage.test.jsx rename to web/src/components/product/ProductSelectionPage.test.jsx index 197e33cb27..89a943a16e 100644 --- a/web/src/components/software/ProductSelectionPage.test.jsx +++ b/web/src/components/product/ProductSelectionPage.test.jsx @@ -22,7 +22,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockNavigateFn } from "~/test-utils"; -import { ProductSelectionPage } from "~/components/software"; +import { ProductSelectionPage } from "~/components/product"; import { createClient } from "~/client"; const products = [ @@ -39,9 +39,9 @@ const products = [ ]; jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products, selectedProduct: products[0] @@ -54,10 +54,12 @@ const managerMock = { }; const softwareMock = { - getProducts: () => Promise.resolve(products), - getSelectedProduct: jest.fn(() => Promise.resolve(products[0])), - selectProduct: jest.fn().mockResolvedValue(), - onProductChange: jest.fn() + product: { + getAll: () => Promise.resolve(products), + getSelected: jest.fn(() => Promise.resolve(products[0])), + select: jest.fn().mockResolvedValue(), + onChange: jest.fn() + } }; beforeEach(() => { @@ -76,7 +78,7 @@ describe("when the user chooses a product", () => { await user.click(radio); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(softwareMock.selectProduct).toHaveBeenCalledWith("MicroOS"); + expect(softwareMock.product.select).toHaveBeenCalledWith("MicroOS"); expect(managerMock.startProbing).toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); @@ -88,7 +90,7 @@ describe("when the user chooses does not change the product", () => { await screen.findByText("openSUSE Tumbleweed"); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(softwareMock.selectProduct).not.toHaveBeenCalled(); + expect(softwareMock.product.select).not.toHaveBeenCalled(); expect(managerMock.startProbing).not.toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); diff --git a/web/src/components/product/ProductSelector.jsx b/web/src/components/product/ProductSelector.jsx new file mode 100644 index 0000000000..311a6a7e89 --- /dev/null +++ b/web/src/components/product/ProductSelector.jsx @@ -0,0 +1,49 @@ +/* + * 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 { Card, CardBody, Radio } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { noop } from "~/utils"; + +export default function ProductSelector({ value, products = [], onChange = noop }) { + if (products.length === 0) return

{_("No products available for selection")}

; + + const isSelected = (product) => product.id === value; + + return ( + products.map((p) => ( + + + onChange(p.id)} + /> + + + )) + ); +} diff --git a/web/src/components/product/ProductSelector.test.jsx b/web/src/components/product/ProductSelector.test.jsx new file mode 100644 index 0000000000..b40af415a0 --- /dev/null +++ b/web/src/components/product/ProductSelector.test.jsx @@ -0,0 +1,75 @@ +/* + * 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 } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { ProductSelector } from "~/components/product"; +import { createClient } from "~/client"; + +jest.mock("~/client"); + +const products = [ + { + id: "ALP-Dolomite", + name: "ALP Dolomite", + description: "ALP Dolomite description" + }, + { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + description: "Tumbleweed description..." + }, + { + id: "MicroOS", + name: "openSUSE MicroOS", + description: "MicroOS description" + } +]; + +beforeEach(() => { + createClient.mockImplementation(() => ({})); +}); + +it("shows an option for each product", async () => { + installerRender(); + await screen.findByRole("radio", { name: "ALP Dolomite" }); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); + await screen.findByRole("radio", { name: "openSUSE MicroOS" }); +}); + +it("selects the given value", async () => { + installerRender(); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true }); +}); + +it("calls onChange if a new option is clicked", async () => { + const onChangeFn = jest.fn(); + const { user } = installerRender(); + const radio = await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); + await user.click(radio); + expect(onChangeFn).toHaveBeenCalledWith("Tumbleweed"); +}); + +it("shows a message if there is no product for selection", async () => { + installerRender(); + await screen.findByText(/no products available/i); +}); diff --git a/web/src/components/software/ChangeProductLink.jsx b/web/src/components/product/index.js similarity index 60% rename from web/src/components/software/ChangeProductLink.jsx rename to web/src/components/product/index.js index 8dc6f07001..be115a18c8 100644 --- a/web/src/components/software/ChangeProductLink.jsx +++ b/web/src/components/product/index.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2023] SUSE LLC * * All Rights Reserved. * @@ -19,21 +19,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { Link } from "react-router-dom"; -import { useSoftware } from "~/context/software"; -import { Icon } from "~/components/layout"; -import { _ } from "~/i18n"; - -export default function ChangeProductLink() { - const { products } = useSoftware(); - - if (products?.length === 1) return null; - - return ( - - - {_("Change product")} - - ); -} +export { default as ProductPage } from "./ProductPage"; +export { default as ProductRegistrationForm } from "./ProductRegistrationForm"; +export { default as ProductSelectionPage } from "./ProductSelectionPage"; +export { default as ProductSelector } from "./ProductSelector"; diff --git a/web/src/components/software/ChangeProductLink.test.jsx b/web/src/components/software/ChangeProductLink.test.jsx deleted file mode 100644 index c89a62222a..0000000000 --- a/web/src/components/software/ChangeProductLink.test.jsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) [2022-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, waitFor } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; -import { ChangeProductLink } from "~/components/software"; - -let mockProducts; - -jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { - return { - products: mockProducts, - }; - } -})); - -beforeEach(() => { - createClient.mockImplementation(() => { - return { - software: { - onProductChange: jest.fn() - }, - }; - }); -}); - -describe("ChangeProductLink", () => { - describe("when there is only a single product", () => { - beforeEach(() => { - mockProducts = [ - { id: "openSUSE", name: "openSUSE Tumbleweed" } - ]; - }); - - it("renders nothing", async () => { - installerRender(); - - const main = await screen.findByRole("main"); - await waitFor(() => expect(main).toBeEmptyDOMElement()); - }); - }); - - describe("when there is more than one product", () => { - beforeEach(() => { - mockProducts = [ - { id: "openSUSE", name: "openSUSE Tumbleweed" }, - { id: "Leap Micro", name: "openSUSE Micro" } - ]; - }); - - it("renders a link for navigating to the selection product page", async () => { - installerRender(); - const link = await screen.findByRole("link", { name: "Change product" }); - - expect(link).toHaveAttribute("href", "/products"); - }); - }); -}); diff --git a/web/src/components/software/PatternSelector.jsx b/web/src/components/software/PatternSelector.jsx index 4a64cb551d..30b550a947 100644 --- a/web/src/components/software/PatternSelector.jsx +++ b/web/src/components/software/PatternSelector.jsx @@ -27,6 +27,7 @@ import { useInstallerClient } from "~/context/installer"; import { Section, ValidationErrors } from "~/components/core"; import PatternGroup from "./PatternGroup"; import PatternItem from "./PatternItem"; +import { toValidationError } from "~/utils"; import UsedSize from "./UsedSize"; import { _ } from "~/i18n"; @@ -207,7 +208,7 @@ function PatternSelector() { // FIXME: ValidationErrors should be replaced by an equivalent component to show issues. // Note that only the Users client uses the old Validation D-Bus interface. - const validationErrors = errors.map(e => ({ message: e.description })); + const validationErrors = errors.map(toValidationError); return ( <> diff --git a/web/src/components/software/index.js b/web/src/components/software/index.js index b2c6fef467..af42a2eb9d 100644 --- a/web/src/components/software/index.js +++ b/web/src/components/software/index.js @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -export { default as ProductSelectionPage } from "./ProductSelectionPage"; -export { default as ChangeProductLink } from "./ChangeProductLink"; export { default as PatternSelector } from "./PatternSelector"; export { default as UsedSize } from "./UsedSize"; export { default as SoftwarePage } from "./SoftwarePage"; diff --git a/web/src/context/agama.jsx b/web/src/context/agama.jsx index 6306c1ba8c..0aa16e811b 100644 --- a/web/src/context/agama.jsx +++ b/web/src/context/agama.jsx @@ -24,7 +24,7 @@ import React from "react"; import { InstallerClientProvider } from "./installer"; import { L10nProvider } from "./l10n"; -import { SoftwareProvider } from "./software"; +import { ProductProvider } from "./product"; import { NotificationProvider } from "./notification"; /** @@ -37,11 +37,11 @@ function AgamaProviders({ children }) { return ( - + {children} - + ); diff --git a/web/src/context/notification.jsx b/web/src/context/notification.jsx index 38446dada1..d04df31486 100644 --- a/web/src/context/notification.jsx +++ b/web/src/context/notification.jsx @@ -37,7 +37,8 @@ function NotificationProvider({ children }) { const load = useCallback(async () => { if (!client) return; - const hasIssues = await cancellablePromise(client.issues.any()); + const issues = await cancellablePromise(client.issues()); + const hasIssues = Object.values(issues).flat().length > 0; update({ issues: hasIssues }); }, [client, cancellablePromise, update]); @@ -45,7 +46,7 @@ function NotificationProvider({ children }) { if (!client) return; load(); - return client.issues.onIssuesChange(load); + return client.onIssuesChange(load); }, [client, load]); const value = [state, update]; diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx new file mode 100644 index 0000000000..4f476f710a --- /dev/null +++ b/web/src/context/product.jsx @@ -0,0 +1,80 @@ +/* + * 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, { useContext, useEffect, useState } from "react"; +import { useCancellablePromise } from "~/utils"; +import { useInstallerClient } from "./installer"; + +const ProductContext = React.createContext([]); + +function ProductProvider({ children }) { + const client = useInstallerClient(); + const { cancellablePromise } = useCancellablePromise(); + const [products, setProducts] = useState(undefined); + const [selectedId, setSelectedId] = useState(undefined); + const [registration, setRegistration] = useState(undefined); + + useEffect(() => { + const load = async () => { + const productManager = client.software.product; + const available = await cancellablePromise(productManager.getAll()); + const selected = await cancellablePromise(productManager.getSelected()); + const registration = await cancellablePromise(productManager.getRegistration()); + setProducts(available); + setSelectedId(selected?.id || null); + setRegistration(registration); + }; + + if (client) { + load().catch(console.error); + } + }, [client, setProducts, setSelectedId, setRegistration, cancellablePromise]); + + useEffect(() => { + if (!client) return; + + return client.software.product.onChange(setSelectedId); + }, [client, setSelectedId]); + + useEffect(() => { + if (!client) return; + + return client.software.product.onRegistrationChange(setRegistration); + }, [client, setRegistration]); + + const value = { products, selectedId, registration }; + return {children}; +} + +function useProduct() { + const context = useContext(ProductContext); + + if (!context) { + throw new Error("useProduct must be used within a ProductProvider"); + } + + const { products = [], selectedId } = context; + const selectedProduct = products.find(p => p.id === selectedId) || null; + + return { ...context, selectedProduct }; +} + +export { ProductProvider, useProduct }; diff --git a/web/src/context/software.jsx b/web/src/context/software.jsx deleted file mode 100644 index 8e53f91405..0000000000 --- a/web/src/context/software.jsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) [2022] 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, { useEffect } from "react"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "./installer"; - -const SoftwareContext = React.createContext([]); - -function SoftwareProvider({ children }) { - const client = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [products, setProducts] = React.useState(undefined); - const [selectedId, setSelectedId] = React.useState(undefined); - - useEffect(() => { - const loadProducts = async () => { - const available = await cancellablePromise(client.software.getProducts()); - const selected = await cancellablePromise(client.software.getSelectedProduct()); - setProducts(available); - setSelectedId(selected?.id || null); - }; - - if (client) { - loadProducts().catch(console.error); - } - }, [client, setProducts, setSelectedId, cancellablePromise]); - - useEffect(() => { - if (!client) return; - - return client.software.onProductChange(setSelectedId); - }, [client, setSelectedId]); - - const value = [products, selectedId]; - return {children}; -} - -function useSoftware() { - const context = React.useContext(SoftwareContext); - - if (!context) { - throw new Error("useSoftware must be used within a SoftwareProvider"); - } - - const [products, selectedId] = context; - - let selectedProduct = selectedId; - if (selectedId && products) { - selectedProduct = products.find(p => p.id === selectedId) || null; - } - - return { - products, - selectedProduct - }; -} - -export { SoftwareProvider, useSoftware }; diff --git a/web/src/index.js b/web/src/index.js index 3b7b6815f3..53d58610a3 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -37,7 +37,8 @@ import App from "~/App"; import Main from "~/Main"; import DevServerWrapper from "~/DevServerWrapper"; import { Overview } from "~/components/overview"; -import { ProductSelectionPage, SoftwarePage } from "~/components/software"; +import { ProductPage, ProductSelectionPage } from "~/components/product"; +import { SoftwarePage } from "~/components/software"; import { ProposalPage as StoragePage, ISCSIPage, DASDPage, ZFCPPage } from "~/components/storage"; import { UsersPage } from "~/components/users"; import { L10nPage } from "~/components/l10n"; @@ -76,6 +77,7 @@ root.render( }> } /> } /> + } /> } /> } /> } />