diff --git a/products.d/agama-products.changes b/products.d/agama-products.changes index 0c99c53aaa..5a5705556b 100644 --- a/products.d/agama-products.changes +++ b/products.d/agama-products.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Sep 5 16:25:00 UTC 2024 - Lubos Kocman + +- Show product logo in product selector (gh#openSUSE/agama#1415). + ------------------------------------------------------------------- Tue Sep 3 10:24:30 UTC 2024 - Lubos Kocman diff --git a/products.d/leap_160.yaml b/products.d/leap_160.yaml index c26c803900..bd780bc9cd 100644 --- a/products.d/leap_160.yaml +++ b/products.d/leap_160.yaml @@ -5,9 +5,10 @@ name: Leap 16.0 Alpha # at the at translations/description key below to avoid using obsolete # translations!! # ------------------------------------------------------------------------------ -description: 'Leap 16.0 is the latest version of a community distribution based +description: 'The latest version of a community distribution based on the latest SUSE Linux Enterprise Server.' # Do not manually change any translations! See README.md for more details. +icon: Leap16.svg translations: description: ca: El Leap 16.0 és la darrera versió d'una distribució comunitària basada en diff --git a/products.d/microos.yaml b/products.d/microos.yaml index f3a74a14c5..4e847af284 100644 --- a/products.d/microos.yaml +++ b/products.d/microos.yaml @@ -9,6 +9,7 @@ description: 'A quick, small distribution designed to host container workloads with automated administration & patching. openSUSE MicroOS provides transactional (atomic) updates upon a read-only btrfs root file system. As rolling release distribution the software is always up-to-date.' +icon: MicroOS.svg # Do not manually change any translations! See README.md for more details. translations: description: diff --git a/products.d/sles_160.yaml b/products.d/sles_160.yaml index e6c072294b..3b14ae23b0 100644 --- a/products.d/sles_160.yaml +++ b/products.d/sles_160.yaml @@ -5,11 +5,12 @@ name: SUSE Linux Enteprise Server 16.0 Alpha # at the at translations/description key below to avoid using obsolete # translations!! # ------------------------------------------------------------------------------ -description: "SUSE Linux Enterprise Server is the open, reliable, compliant, and +description: "An open, reliable, compliant, and future-proof Linux Server choice that ensures the enterprise's business continuity. It is the secure and adaptable OS for long-term supported, innovation-ready infrastructure running business-critical workloads on-premises, in the cloud, and at the edge." +icon: SUSE.svg # Do not manually change any translations! See README.md for more details. translations: description: diff --git a/products.d/tumbleweed.yaml b/products.d/tumbleweed.yaml index 9b921f661f..f8b182f2d7 100644 --- a/products.d/tumbleweed.yaml +++ b/products.d/tumbleweed.yaml @@ -5,10 +5,11 @@ name: openSUSE Tumbleweed # at the at translations/description key below to avoid using obsolete # translations!! # ------------------------------------------------------------------------------ -description: 'The Tumbleweed distribution is a pure rolling release version of +description: 'A pure rolling release version of openSUSE containing the latest "stable" versions of all software instead of relying on rigid periodic release cycles. The project does this for users that want the newest stable software.' +icon: Tumbleweed.svg # Do not manually change any translations! See README.md for more details. translations: description: diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index e81f14d94c..e8dea9610c 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -30,6 +30,7 @@ "id": { "title": "Product identifier", "description": "The id field from a products.d/foo.yaml file", + "icon": "Product Icon path specified in products.d/foo.yaml file", "type": "string" }, "registrationCode": { diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs index a75824e8f1..3283678694 100644 --- a/rust/agama-lib/src/product/client.rs +++ b/rust/agama-lib/src/product/client.rs @@ -16,6 +16,8 @@ pub struct Product { pub name: String, /// Product description pub description: String, + /// Product icon (e.g., "default.svg") + pub icon: String, } #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] @@ -74,10 +76,15 @@ impl<'a> ProductClient<'a> { Some(value) => value.try_into().unwrap(), None => "", }; + let icon = match data.get("icon") { + Some(value) => value.try_into().unwrap(), + None => "default.svg", + }; Product { id, name, description: description.to_string(), + icon: icon.to_string(), } }) .collect(); diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 5b886acf94..b85be02baa 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Sep 5 16:25:00 UTC 2024 - Lubos Kocman + +- Show product logo in product selector (gh#openSUSE/agama#1415). + ------------------------------------------------------------------- Wed Aug 28 12:37:34 UTC 2024 - Imobach Gonzalez Sosa diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 1c85087b8c..53209ff0a3 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -54,7 +54,14 @@ def issues def available_products backend.products.map do |product| - [product.id, product.display_name, { "description" => product.localized_description }] + [ + product.id, + product.display_name, + { + "description" => product.localized_description, + "icon" => product.icon + } + ] end end diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb index 5ea4e1fd69..3fe27489a7 100644 --- a/service/lib/agama/software/product.rb +++ b/service/lib/agama/software/product.rb @@ -48,6 +48,13 @@ class Product # @return [String, nil] E.g., "1.0". attr_accessor :version + # Product icon. Please use specify filename with svg suffix and ensure referenced + # file exists inside agama/web/src/assets/product. + # `default.svg` will be used unless specified otherwise. + # + # @return [String] E.g. "leap.svg" + attr_accessor :icon + # List of repositories. # # @return [Array] Empty if the product requires registration. @@ -99,6 +106,7 @@ class Product # @param id [string] Product id. def initialize(id) @id = id + @icon = "default.svg" @repositories = [] @labels = [] @mandatory_packages = [] diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb index d679f39dba..a9ff1d655b 100644 --- a/service/lib/agama/software/product_builder.rb +++ b/service/lib/agama/software/product_builder.rb @@ -64,6 +64,7 @@ def initialize_product(id, data, attrs) product.description = attrs["description"] product.name = data[:name] product.version = data[:version] + product.icon = attrs["icon"] if attrs["icon"] end end @@ -98,6 +99,7 @@ def product_data_from_config(id) { name: config.products.dig(id, "software", "base_product"), version: config.products.dig(id, "software", "version"), + icon: config.products.dig(id, "software", "icon"), labels: config.arch_elements_from( id, "software", "installation_labels", property: :label ), diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 463f704e91..417b097a01 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Sep 5 16:25:00 UTC 2024 - Lubos Kocman + +- Show product logo in product selector (gh#openSUSE/agama#1415). + ------------------------------------------------------------------- Wed Sep 4 08:55:29 UTC 2024 - José Iván López González diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index bfd54e86d1..678660aa1a 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Sep 5 16:25:00 UTC 2024 - Lubos Kocman + +- Show product logo in product selector (gh#openSUSE/agama#1415). + ------------------------------------------------------------------- Wed Sep 4 21:00:34 UTC 2024 - Knut Anderssen diff --git a/web/src/assets/products/Leap16.svg b/web/src/assets/products/Leap16.svg new file mode 100644 index 0000000000..2ee4a146bd --- /dev/null +++ b/web/src/assets/products/Leap16.svg @@ -0,0 +1,79 @@ + + + + + + image/svg+xml + + + + + + + + + Alpha + diff --git a/web/src/assets/products/MicroOS.svg b/web/src/assets/products/MicroOS.svg new file mode 100644 index 0000000000..a177ef72f8 --- /dev/null +++ b/web/src/assets/products/MicroOS.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/web/src/assets/products/SUSE.svg b/web/src/assets/products/SUSE.svg new file mode 100644 index 0000000000..3cb5d5c64c --- /dev/null +++ b/web/src/assets/products/SUSE.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/products/Tumbleweed.svg b/web/src/assets/products/Tumbleweed.svg new file mode 100644 index 0000000000..27ac560b1d --- /dev/null +++ b/web/src/assets/products/Tumbleweed.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/web/src/assets/products/default.svg b/web/src/assets/products/default.svg new file mode 100644 index 0000000000..26c6d811e8 --- /dev/null +++ b/web/src/assets/products/default.svg @@ -0,0 +1,76 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/web/src/assets/styles/app.scss b/web/src/assets/styles/app.scss index c8dfab146e..3d6e2a4166 100644 --- a/web/src/assets/styles/app.scss +++ b/web/src/assets/styles/app.scss @@ -35,3 +35,33 @@ button.remove-link:hover { position: relative; width: 100%; } + +#productSelectionForm { + .pf-v5-c-radio input { + align-self: center; + width: 20px; + height: 20px; + } + + .pf-v5-c-card { + img { + width: 80px; + } + + label { + cursor: pointer; + } + + label::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 999; + } + } +} diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index 37aad08505..3205abd5a0 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -132,6 +132,10 @@ 100% 16px; } +.cursor-pointer { + cursor: pointer; +} + // FIXME: drop as soon as Tip component gets rethought / refactored .label-tip .pf-v5-c-label__text { display: flex; diff --git a/web/src/components/core/ChangeProductLink.test.tsx b/web/src/components/core/ChangeProductLink.test.tsx index df69181a80..a391cb55b6 100644 --- a/web/src/components/core/ChangeProductLink.test.tsx +++ b/web/src/components/core/ChangeProductLink.test.tsx @@ -26,14 +26,16 @@ import { PATHS } from "~/routes/products"; import { Product } from "~/types/software"; import ChangeProductLink from "./ChangeProductLink"; -const tumbleweedProduct = { +const tumbleweed: Product = { id: "Tumbleweed", name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", description: "Tumbleweed description...", }; -const microosProduct = { +const microos: Product = { id: "MicroOS", name: "openSUSE MicroOS", + icon: "MicroOS.svg", description: "MicroOS description", }; @@ -46,7 +48,7 @@ jest.mock("~/queries/software", () => ({ describe("ChangeProductLink", () => { describe("when there is more than one product available", () => { beforeEach(() => { - mockUseProduct = { products: [tumbleweedProduct, microosProduct] }; + mockUseProduct = { products: [tumbleweed, microos] }; }); it("renders a link for navigating to product selection page", () => { @@ -58,7 +60,7 @@ describe("ChangeProductLink", () => { describe("when there is only one product available", () => { beforeEach(() => { - mockUseProduct = { products: [tumbleweedProduct] }; + mockUseProduct = { products: [tumbleweed] }; }); it("renders nothing", () => { diff --git a/web/src/components/product/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx deleted file mode 100644 index f742352726..0000000000 --- a/web/src/components/product/ProductSelectionPage.test.jsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) [2022-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender, mockNavigateFn } from "~/test-utils"; -import { ProductSelectionPage } from "~/components/product"; -import { createClient } from "~/client"; - -const products = [ - { - id: "Tumbleweed", - name: "openSUSE Tumbleweed", - description: "Tumbleweed description...", - }, - { - id: "MicroOS", - name: "openSUSE MicroOS", - description: "MicroOS description", - }, -]; - -jest.mock("~/client"); -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: () => { - return { - products, - selectedProduct: products[0], - }; - }, - useProductChanges: () => jest.fn(), -})); - -const managerMock = { - startProbing: jest.fn(), -}; - -const productMock = { - getAll: () => Promise.resolve(products), - getSelected: jest.fn(() => Promise.resolve(products[0])), - select: jest.fn().mockResolvedValue(), - onChange: jest.fn(), -}; - -beforeEach(() => { - createClient.mockImplementation(() => { - return { - manager: managerMock, - product: productMock, - }; - }); -}); - -describe.skip("when the user chooses a product", () => { - it("selects the product and redirects to the main page", async () => { - const { user } = installerRender(); - const productOption = screen.getByRole("row", { name: /openSUSE MicroOS/ }); - const selectButton = screen.getByRole("button", { name: "Select" }); - await user.click(productOption); - await user.click(selectButton); - expect(productMock.select).toHaveBeenCalledWith("MicroOS"); - expect(managerMock.startProbing).toHaveBeenCalled(); - expect(mockNavigateFn).toHaveBeenCalledWith("/"); - }); -}); - -describe.skip("when the user chooses does not change the product", () => { - it("redirects to the main page", async () => { - const { user } = installerRender(); - screen.getByText("openSUSE Tumbleweed"); - const selectButton = await screen.findByRole("button", { name: "Select" }); - await user.click(selectButton); - expect(productMock.select).not.toHaveBeenCalled(); - expect(managerMock.startProbing).not.toHaveBeenCalled(); - expect(mockNavigateFn).toHaveBeenCalledWith("/"); - }); -}); diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx new file mode 100644 index 0000000000..704f2b064f --- /dev/null +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2022-2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender, mockNavigateFn } from "~/test-utils"; +import { ProductSelectionPage } from "~/components/product"; +import { Product, } from "~/types/software"; +import { useProduct } from "~/queries/software"; + +const mockConfigMutation = jest.fn(); +const tumbleweed: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", +}; + +const microOs: Product = { + id: "MicroOS", + name: "openSUSE MicroOS", + icon: "microos.svg", + description: "MicroOS description", +}; + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: (): ReturnType => { + return { + products: [tumbleweed, microOs], + selectedProduct: tumbleweed, + }; + }, + useProductChanges: () => jest.fn(), + useConfigMutation: () => ({ mutate: mockConfigMutation }) +})); + +describe("when the user chooses a product and hits the confirmation button", () => { + it("triggers the product selection", async () => { + const { user } = installerRender(); + const productOption = screen.getByRole("radio", { name: microOs.name }); + const selectButton = screen.getByRole("button", { name: "Select" }); + await user.click(productOption); + await user.click(selectButton); + expect(mockConfigMutation).toHaveBeenCalledWith({ product: microOs.id }); + }); +}); + +describe("when the user chooses a product but hits the cancel button", () => { + it("does not trigger the product selection and goes back", async () => { + const { user } = installerRender(); + const productOption = screen.getByRole("radio", { name: microOs.name }); + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await user.click(productOption); + await user.click(cancelButton); + expect(mockConfigMutation).not.toHaveBeenCalled(); + expect(mockNavigateFn).toHaveBeenCalledWith("-1"); + }); +}); diff --git a/web/src/components/product/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.tsx similarity index 55% rename from web/src/components/product/ProductSelectionPage.jsx rename to web/src/components/product/ProductSelectionPage.tsx index a875e4b663..09000b10dc 100644 --- a/web/src/components/product/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -20,24 +20,61 @@ */ import React, { useState } from "react"; -import { Card, CardBody, Flex, Form, Grid, GridItem, Radio } from "@patternfly/react-core"; +import { Card, CardBody, Flex, Form, Grid, GridItem, Radio, List, ListItem, Split, Stack, FormGroup } from "@patternfly/react-core"; import { Page } from "~/components/core"; import { Center } from "~/components/layout"; import { useConfigMutation, useProduct } from "~/queries/software"; -import { _ } from "~/i18n"; import styles from "@patternfly/react-styles/css/utilities/Text/text"; +import { slugify } from "~/utils"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; -const Label = ({ children }) => ( - {children} +const ResponsiveGridItem = ({ children }) => ( + + {children} + ); +const Option = ({ product, isChecked, onChange }) => { + const id = slugify(product.name); + const detailsId = `${id}-details`; + const logoSrc = `assets/logos/${product.icon}`; + // TRANSLATORS: %s will be replaced by a product name. E.g., "openSUSE Tumbleweed" + const logoAltText = sprintf(_("%s logo"), product.name); + + return ( + + + + + + {logoAltText} + + +

{product.description}

+
+
+
+
+
+ ); +}; + function ProductSelectionPage() { - const { products, selectedProduct } = useProduct({ suspense: true }); const setConfig = useConfigMutation(); + const { products, selectedProduct } = useProduct({ suspense: true }); const [nextProduct, setNextProduct] = useState(selectedProduct); const [isLoading, setIsLoading] = useState(false); - const onSubmit = async (e) => { + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (nextProduct) { @@ -46,14 +83,6 @@ function ProductSelectionPage() { } }; - const Item = ({ children }) => { - return ( - - {children} - - ); - }; - const isSelectionDisabled = !nextProduct || nextProduct === selectedProduct; return ( @@ -61,26 +90,23 @@ function ProductSelectionPage() {
- {products.map((product, index) => ( - - - - + + + {products.map((product, index) => ( + - - - ))} - + ))} + + + + - {selectedProduct && !isLoading && } + {selectedProduct && !isLoading && } - +
- + ); } diff --git a/web/src/types/software.ts b/web/src/types/software.ts index 42a9d8edc9..2992e8f7cb 100644 --- a/web/src/types/software.ts +++ b/web/src/types/software.ts @@ -38,6 +38,8 @@ type Product = { name: string; /** Product description */ description: string; + /** Product icon (e.g., "default.svg") */ + icon: string; }; type PatternsSelection = { [key: string]: SelectedBy }; @@ -53,7 +55,7 @@ type SoftwareConfig = { /** Product to install */ product?: string; /** An object where the keys are the pattern names and the values whether to install them or not */ - patterns: { [key: string]: boolean }; + patterns?: { [key: string]: boolean }; }; type Pattern = { diff --git a/web/webpack.config.js b/web/webpack.config.js index fd82d21e93..2b726d2e4e 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -40,6 +40,7 @@ const copy_files = [ "./src/index.html", // TODO: consider using something more complete like https://github.com/jantimon/favicons-webpack-plugin "./src/assets/favicon.svg", + { from: "./src/assets/products/*.svg", to: "assets/logos/[name][ext]" } ]; const plugins = [