diff --git a/web/package-lock.json b/web/package-lock.json index a2deca342b..2a2c00d278 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,7 +33,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@svgr/plugin-jsx": "^8.1.0", "@svgr/webpack": "^8.1.0", - "@testing-library/jest-dom": "^6.1.4", + "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.1", "@types/jest": "^29.5.12", diff --git a/web/package.json b/web/package.json index b830def6a1..4dae56cd87 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@svgr/plugin-jsx": "^8.1.0", "@svgr/webpack": "^8.1.0", - "@testing-library/jest-dom": "^6.1.4", + "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.1", "@types/jest": "^29.5.12", diff --git a/web/src/client/software.js b/web/src/client/software.js index 9e48bcfeb8..a754d8588c 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -47,20 +47,6 @@ const SelectedBy = Object.freeze({ * @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} RegistrationFailure - * @property {Number} id - ID of error. - * @property {string} message - Failure message. - */ - /** * @typedef {object} ActionResult * @property {boolean} success - Whether the action was successfully done. @@ -115,43 +101,6 @@ class SoftwareBaseClient { return this.client.post("/software/probe", {}); } - /** - * Returns how much space installation takes on disk - * - * @return {Promise} - */ - async getProposal() { - const response = await this.client.get("/software/proposal"); - if (!response.ok) { - console.log("Failed to get software proposal: ", response); - } - - return response.json(); - } - - /** - * Returns available patterns - * - * @return {Promise} - */ - async getPatterns() { - const response = await this.client.get("/software/patterns"); - if (!response.ok) { - console.log("Failed to get software patterns: ", response); - return []; - } - /** @type Array<{ name: string, category: string, summary: string, description: string, order: string, icon: string }> */ - const patterns = await response.json(); - return patterns.map((pattern) => ({ - name: pattern.name, - category: pattern.category, - summary: pattern.summary, - description: pattern.description, - order: parseInt(pattern.order), - icon: pattern.icon, - })); - } - /** * @return {Promise} */ @@ -251,7 +200,7 @@ class ProductClient { /** * Returns the registration of the selected product. * - * @return {Promise} + * @return {Promise} */ async getRegistration() { const response = await this.client.get("/software/registration"); @@ -280,7 +229,7 @@ class ProductClient { async register(code, email = "") { const response = await this.client.post("/software/registration", { key: code, email }); if (response.status === 422) { - /** @type RegistrationFailure */ + /** @type import('~/types/registration').RegistrationFailure */ const body = await response.json(); return { success: false, @@ -303,7 +252,7 @@ class ProductClient { const response = await this.client.delete("/software/registration"); if (response.status === 422) { - /** @type RegistrationFailure */ + /** @type import('~/types/registration').RegistrationFailure */ const body = await response.json(); return { success: false, @@ -320,7 +269,7 @@ class ProductClient { /** * Registers a callback to run when the registration changes. * - * @param {(registration: Registration) => void} handler - Callback function. + * @param {(registration: import('~/types/registration').Registration) => void} handler - Callback function. */ onRegistrationChange(handler) { return this.client.ws().onEvent((event) => { diff --git a/web/src/components/overview/SoftwareSection.test.jsx b/web/src/components/overview/SoftwareSection.test.jsx deleted file mode 100644 index 5c9d779f1f..0000000000 --- a/web/src/components/overview/SoftwareSection.test.jsx +++ /dev/null @@ -1,63 +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. - */ - -import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { noop } from "~/utils"; -import { createClient } from "~/client"; -import SoftwareSection from "~/components/overview/SoftwareSection"; - -jest.mock("~/client"); - -const gnomePattern = { - name: "gnome", - category: "Graphical Environments", - icon: "./pattern-gnome", - summary: "GNOME Desktop Environment (Wayland)", - order: 1120, -}; - -const kdePattern = { - name: "kde", - category: "Graphical Environments", - icon: "./pattern-kde", - summary: "KDE Applications and Plasma Desktop", - order: 1110, -}; - -beforeEach(() => { - createClient.mockImplementation(() => { - return { - software: { - onSelectedPatternsChanged: noop, - getProposal: jest.fn().mockResolvedValue({ size: "500 MiB", patterns: { kde: 1 } }), - getPatterns: jest.fn().mockResolvedValue([gnomePattern, kdePattern]), - }, - }; - }); -}); - -it.only("renders the required space and the selected patterns", async () => { - installerRender(); - await screen.findByText("500 MiB"); - await screen.findByText(kdePattern.summary); -}); diff --git a/web/src/components/overview/SoftwareSection.test.tsx b/web/src/components/overview/SoftwareSection.test.tsx new file mode 100644 index 0000000000..eb02e06e78 --- /dev/null +++ b/web/src/components/overview/SoftwareSection.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { act, screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import mockTestingPatterns from "~/components/software/patterns.test.json"; +import testingProposal from "~/components/software/proposal.test.json"; +import SoftwareSection from "~/components/overview/SoftwareSection"; +import { SoftwareProposal } from "~/types/software"; + +let mockTestingProposal: SoftwareProposal; + +jest.mock("~/queries/software", () => ({ + usePatterns: () => mockTestingPatterns, + useProposal: () => mockTestingProposal, + useProposalChanges: jest.fn(), +})); + +describe("SoftwareSection", () => { + describe("when the proposal does not have patterns to select", () => { + beforeEach(() => { + mockTestingProposal = { patterns: {}, size: "" }; + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when the proposal has patterns to select", () => { + beforeEach(() => { + mockTestingProposal = testingProposal; + }); + + it("renders the required space and the selected patterns", () => { + installerRender(); + screen.getByText("4.6 GiB"); + screen.getAllByText(/GNOME/); + screen.getByText("YaST Base Utilities"); + screen.getByText("YaST Desktop Utilities"); + screen.getByText("Multimedia"); + screen.getAllByText(/Office Software/); + expect(screen.queryByText("KDE")).toBeNull(); + expect(screen.queryByText("XFCE")).toBeNull(); + expect(screen.queryByText("YaST Server Utilities")).toBeNull(); + }); + }); +}); diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.tsx similarity index 59% rename from web/src/components/overview/SoftwareSection.jsx rename to web/src/components/overview/SoftwareSection.tsx index 3070acd1d2..e96d4544e7 100644 --- a/web/src/components/overview/SoftwareSection.jsx +++ b/web/src/components/overview/SoftwareSection.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -19,40 +19,21 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; -import { _ } from "~/i18n"; -import { useInstallerClient } from "~/context/installer"; +import React from "react"; import { List, ListItem, Text, TextContent, TextVariants } from "@patternfly/react-core"; import { Em } from "~/components/core"; +import { SelectedBy } from "~/types/software"; +import { usePatterns, useProposal, useProposalChanges } from "~/queries/software"; +import { isObjectEmpty } from "~/utils"; +import { _ } from "~/i18n"; -export default function SoftwareSection() { - const [proposal, setProposal] = useState({}); - const [patterns, setPatterns] = useState([]); - const [selectedPatterns, setSelectedPatterns] = useState(undefined); - const client = useInstallerClient(); - - useEffect(() => { - client.software.getProposal().then(setProposal); - client.software.getPatterns().then(setPatterns); - }, [client]); - - useEffect(() => { - return client.software.onSelectedPatternsChanged(() => { - client.software.getProposal().then(setProposal); - }); - }, [client, setProposal]); - - useEffect(() => { - if (proposal.patterns === undefined) return; +export default function SoftwareSection(): React.ReactNode { + const proposal = useProposal(); + const patterns = usePatterns(); - const ids = Object.keys(proposal.patterns); - const selected = patterns.filter((p) => ids.includes(p.name)).sort((a, b) => a.order - b.order); - setSelectedPatterns(selected); - }, [client, proposal, patterns]); + useProposalChanges(); - if (selectedPatterns === undefined) { - return; - } + if (isObjectEmpty(proposal.patterns)) return; const TextWithoutList = () => { return ( @@ -65,6 +46,8 @@ export default function SoftwareSection() { const TextWithList = () => { // TRANSLATORS: %s will be replaced with the installation size, example: "5GiB". const [msg1, msg2] = _("The installation will take %s including:").split("%s"); + const selectedPatterns = patterns.filter((p) => p.selectedBy !== SelectedBy.NONE); + return ( <> @@ -84,7 +67,7 @@ export default function SoftwareSection() { return ( {_("Software")} - {selectedPatterns.length ? : } + {patterns.length ? : } ); } diff --git a/web/src/components/software/SoftwarePage.test.jsx b/web/src/components/software/SoftwarePage.test.jsx deleted file mode 100644 index fa920c204a..0000000000 --- a/web/src/components/software/SoftwarePage.test.jsx +++ /dev/null @@ -1,88 +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. - */ - -import React from "react"; - -import { act, screen, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { BUSY, IDLE } from "~/client/status"; -import { createClient } from "~/client"; -import test_patterns from "./SoftwarePatternsSelection.test.json"; -import SoftwarePage from "./SoftwarePage"; - -jest.mock("~/client"); - -const getStatusFn = jest.fn(); -const onStatusChangeFn = jest.fn(); -const onSelectedPatternsChangedFn = jest.fn(); -const selectPatternsFn = jest.fn(); -const proposal = { - patterns: { yast2_basis: 1 }, - size: "1.8 GiB", -}; - -beforeEach(() => { - createClient.mockImplementation(() => { - return { - software: { - getStatus: getStatusFn, - onStatusChange: onStatusChangeFn, - onSelectedPatternsChanged: onSelectedPatternsChangedFn, - getPatterns: jest.fn().mockResolvedValue(test_patterns), - getProposal: jest.fn().mockResolvedValue(proposal), - selectPatterns: selectPatternsFn, - }, - }; - }); -}); - -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () => Skeleton Mock, - }; -}); - -describe.skip("SoftwarePage", () => { - it("displays a progress when the backend in busy", async () => { - getStatusFn.mockResolvedValue(BUSY); - await act(async () => installerRender()); - screen.getAllByText("Skeleton Mock"); - }); - - it("clicking in a pattern's checkbox selects the pattern", async () => { - getStatusFn.mockResolvedValue(IDLE); - - const { user } = installerRender(); - const button = await screen.findByRole("button", { name: "Change selection" }); - await user.click(button); - - const basePatterns = await screen.findByRole("region", { - name: "Base Technologies", - }); - const row = await within(basePatterns).findByRole("row", { name: /YaST Base/ }); - const checkbox = await within(row).findByRole("checkbox"); - - expect(checkbox).toBeChecked(); - }); -}); diff --git a/web/src/components/software/SoftwarePage.test.tsx b/web/src/components/software/SoftwarePage.test.tsx new file mode 100644 index 0000000000..5e8810cb7a --- /dev/null +++ b/web/src/components/software/SoftwarePage.test.tsx @@ -0,0 +1,62 @@ +/* + * 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 testingPatterns from "./patterns.test.json"; +import testingProposal from "./proposal.test.json"; +import SoftwarePage from "./SoftwarePage"; + +jest.mock("~/queries/issues", () => ({ + useIssues: () => [], +})); + +jest.mock("~/queries/software", () => ({ + usePatterns: () => testingPatterns, + useProposal: () => testingProposal, + useProposalChanges: jest.fn(), +})); + +describe("SoftwarePage", () => { + it("renders a list of selected patterns", () => { + installerRender(); + screen.getAllByText(/GNOME/); + screen.getByText("YaST Base Utilities"); + screen.getByText("YaST Desktop Utilities"); + screen.getByText("Multimedia"); + screen.getAllByText(/Office software/); + expect(screen.queryByText("KDE")).toBeNull(); + expect(screen.queryByText("XFCE")).toBeNull(); + expect(screen.queryByText("YaST Server Utilities")).toBeNull(); + }); + + it("renders amount of size selected product and patterns will need", () => { + installerRender(); + screen.getByText("Installation will take 4.6 GiB."); + }); + + it("renders a button for navigating to patterns selection", () => { + installerRender(); + screen.getByRole("link", { name: "Change selection" }); + }); +}); diff --git a/web/src/components/software/SoftwarePage.jsx b/web/src/components/software/SoftwarePage.tsx similarity index 50% rename from web/src/components/software/SoftwarePage.jsx rename to web/src/components/software/SoftwarePage.tsx index 001daf5927..fefe2ea381 100644 --- a/web/src/components/software/SoftwarePage.jsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -19,18 +19,7 @@ * find current contact information at www.suse.com. */ -// @ts-check - -import React, { useEffect, useState } from "react"; - -import { useInstallerClient } from "~/context/installer"; -import { useCancellablePromise } from "~/utils"; -import { useIssues } from "~/queries/issues"; -import { BUSY } from "~/client/status"; -import { _ } from "~/i18n"; -import { ButtonLink, CardField, IssuesHint, Page, SectionSkeleton } from "~/components/core"; -import UsedSize from "./UsedSize"; -import { SelectedBy } from "~/client/software"; +import React from "react"; import { CardBody, DescriptionList, @@ -41,44 +30,17 @@ import { GridItem, Stack, } from "@patternfly/react-core"; - -/** - * @typedef {Object} Pattern - * @property {string} name - pattern name (internal ID) - * @property {string} category - pattern category - * @property {string} summary - pattern name (user visible) - * @property {string} description - long description of the pattern - * @property {number} order - display order (string!) - * @property {number} selectedBy - who selected the pattern - */ - -/** - * Builds a list of patterns include its selection status - * - * @param {import("~/client/software").Pattern[]} patterns - Patterns from the HTTP API - * @param {Object.} selection - Patterns selection - * @return {Pattern[]} List of patterns including its selection status - */ -function buildPatterns(patterns, selection) { - return patterns - .map((pattern) => { - const selectedBy = selection[pattern.name] !== undefined ? selection[pattern.name] : 2; - return { - ...pattern, - selectedBy, - }; - }) - .sort((a, b) => a.order - b.order); -} +import { ButtonLink, CardField, IssuesHint, Page } from "~/components/core"; +import UsedSize from "./UsedSize"; +import { useIssues } from "~/queries/issues"; +import { usePatterns, useProposal, useProposalChanges } from "~/queries/software"; +import { Pattern, SelectedBy } from "~/types/software"; +import { _ } from "~/i18n"; /** * List of selected patterns. - * @component - * @param {object} props - * @param {Pattern[]} props.patterns - List of patterns, including selected and unselected ones. - * @return {JSX.Element} */ -const SelectedPatternsList = ({ patterns }) => { +const SelectedPatternsList = ({ patterns }: { patterns: Pattern[] }): React.ReactNode => { const selected = patterns.filter((p) => p.selectedBy !== SelectedBy.NONE); if (selected.length === 0) { @@ -100,7 +62,7 @@ const SelectedPatternsList = ({ patterns }) => { ); }; -const SelectedPatterns = ({ patterns }) => ( +const SelectedPatterns = ({ patterns }): React.ReactNode => ( ( ); -const NoPatterns = () => ( +const NoPatterns = (): React.ReactNode => (

@@ -126,54 +88,16 @@ const NoPatterns = () => ( ); -// FIXME: move build patterns to utils /** * Software page component - * @component - * @returns {JSX.Element} */ -function SoftwarePage() { +function SoftwarePage(): React.ReactNode { const issues = useIssues("software"); - const [status, setStatus] = useState(BUSY); - const [patterns, setPatterns] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [proposal, setProposal] = useState({ patterns: {}, size: "" }); - const client = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - - useEffect(() => { - cancellablePromise(client.software.getStatus().then(setStatus)); - - return client.software.onStatusChange(setStatus); - }, [client, cancellablePromise]); + const proposal = useProposal(); + const patterns = usePatterns(); - useEffect(() => { - if (!patterns) return; - - return client.software.onSelectedPatternsChanged((selection) => { - client.software.getProposal().then((proposal) => setProposal(proposal)); - setPatterns(buildPatterns(patterns, selection)); - }); - }, [client.software, patterns]); - - useEffect(() => { - if (!isLoading) return; - - const loadPatterns = async () => { - const patterns = await cancellablePromise(client.software.getPatterns()); - const proposal = await cancellablePromise(client.software.getProposal()); - setPatterns(buildPatterns(patterns, proposal.patterns)); - setProposal(proposal); - setIsLoading(false); - }; - - loadPatterns(); - }, [client.software, patterns, cancellablePromise, isLoading]); - - if (status === BUSY || isLoading) { - ; - } + useProposalChanges(); return ( <> diff --git a/web/src/components/software/SoftwarePatternsSelection.jsx b/web/src/components/software/SoftwarePatternsSelection.jsx deleted file mode 100644 index 5f0acdf647..0000000000 --- a/web/src/components/software/SoftwarePatternsSelection.jsx +++ /dev/null @@ -1,274 +0,0 @@ -/* - * Copyright (c) [2023-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, { useCallback, useEffect, useState } from "react"; -import { - Card, - CardBody, - Label, - DataList, - DataListCell, - DataListCheck, - DataListItem, - DataListItemCells, - DataListItemRow, - SearchInput, - Stack, -} from "@patternfly/react-core"; - -import { Section, Page } from "~/components/core"; -import { _ } from "~/i18n"; -import { SelectedBy } from "~/client/software"; -import { useInstallerClient } from "~/context/installer"; -import { useCancellablePromise } from "~/utils"; - -/** - * @typedef {Object} Pattern - * @property {string} name pattern name (internal ID) - * @property {string} group pattern group - * @property {string} summary pattern name (user visible) - * @property {string} description long description of the pattern - * @property {string} order display order - * @property {string} icon icon name (not path or file name!) - * @property {number} selected who selected the pattern, undefined - * means it is not selected to install - */ - -/** - * @typedef {Object.} PatternGroups mapping "group name" => - * list of patterns - */ - -/** - * Group the patterns with the same group name - * @param {Array} patterns input - * @return {PatternGroups} - */ -function groupPatterns(patterns) { - const groups = {}; - - patterns.forEach((pattern) => { - if (groups[pattern.category]) { - groups[pattern.category].push(pattern); - } else { - groups[pattern.category] = [pattern]; - } - }); - - // sort patterns by the "order" value - Object.keys(groups).forEach((group) => { - groups[group].sort((p1, p2) => { - if (p1.order === p2.order) { - // there should be no patterns with the same name - return p1.name < p2.name ? -1 : 1; - } else { - return p1.order - p2.order; - } - }); - }); - - return groups; -} - -/** - * Sort pattern group names - * @param {PatternGroups} groups input - * @returns {Array} sorted pattern group names - */ -function sortGroups(groups) { - return Object.keys(groups).sort((g1, g2) => { - const order1 = groups[g1][0].order; - const order2 = groups[g2][0].order; - return order1 - order2; - }); -} - -/** - * Builds a list of patterns include its selection status - * - * @param {import("~/client/software").Pattern[]} patterns - Patterns from the HTTP API - * @param {Object.} selection - Patterns selection - * @return {Pattern[]} List of patterns including its selection status - */ -function buildPatterns(patterns, selection) { - return patterns - .map((pattern) => { - const selectedBy = selection[pattern.name] !== undefined ? selection[pattern.name] : 2; - return { - ...pattern, - selectedBy, - }; - }) - .sort((a, b) => a.order - b.order); -} - -/** - * Pattern selector component - */ -function SoftwarePatternsSelection() { - const client = useInstallerClient(); - const [patterns, setPatterns] = useState([]); - const [proposal, setProposal] = useState({ patterns: {}, size: "" }); - const [isLoading, setIsLoading] = useState(true); - const [visiblePatterns, setVisiblePatterns] = useState(patterns); - const [searchValue, setSearchValue] = useState(""); - const { cancellablePromise } = useCancellablePromise(); - - useEffect(() => { - if (patterns.length !== 0) return; - - const loadPatterns = async () => { - const patterns = await cancellablePromise(client.software.getPatterns()); - const proposal = await cancellablePromise(client.software.getProposal()); - setPatterns(buildPatterns(patterns, proposal.patterns)); - setProposal(proposal); - setIsLoading(false); - }; - - loadPatterns(); - }, [client.software, patterns, cancellablePromise]); - - useEffect(() => { - if (!patterns) return; - - // filtering - search the required text in the name and pattern description - if (searchValue !== "") { - // case insensitive search - const searchData = searchValue.toUpperCase(); - const filtered = patterns.filter( - (p) => - p.name.toUpperCase().indexOf(searchData) !== -1 || - p.description.toUpperCase().indexOf(searchData) !== -1, - ); - setVisiblePatterns(filtered); - } else { - setVisiblePatterns(patterns); - } - - return client.software.onSelectedPatternsChanged((selection) => { - client.software.getProposal().then((proposal) => setProposal(proposal)); - setPatterns(buildPatterns(patterns, selection)); - }); - }, [patterns, searchValue, client.software]); - - const onToggle = useCallback( - (name) => { - const selected = patterns - .filter((p) => p.selectedBy === SelectedBy.USER) - .reduce((all, p) => { - all[p.name] = true; - return all; - }, {}); - const pattern = patterns.find((p) => p.name === name); - selected[name] = pattern.selectedBy === SelectedBy.NONE; - - client.software.selectPatterns(selected); - }, - [patterns, client.software], - ); - - // FIXME: use loading indicator when busy, we cannot know if it will be - // quickly or not in advance. - - // initial empty screen, the patterns are loaded very quickly, no need for any progress - if (visiblePatterns.length === 0 && searchValue === "") return null; - - const groups = groupPatterns(visiblePatterns); - - // FIXME: use a switch instead of a checkbox since these patterns are going to - // be selected/deselected immediately. - // TODO: extract to a DataListSelector component or so. - let selector = sortGroups(groups).map((groupName) => { - const selectedIds = groups[groupName] - .filter((p) => p.selectedBy !== SelectedBy.NONE) - .map((p) => p.name); - return ( -

- - {groups[groupName].map((option) => ( - - - onToggle(option.name)} - aria-labelledby="check-action-item1" - name="check-action-check1" - isChecked={selectedIds.includes(option.name)} - /> - - -
- {option.summary}{" "} - {option.selectedBy === SelectedBy.AUTO && ( - - )} -
-
{option.description}
-
- , - ]} - /> -
-
- ))} -
-
- ); - }); - - if (selector.length === 0) { - selector = {_("None of the patterns match the filter.")}; - } - - return ( - <> - - -

{_("Software selection")}

- setSearchValue(value)} - onClear={() => setSearchValue("")} - resultsCount={visiblePatterns.length} - /> -
-
- - - - {selector} - - - - - {_("Close")} - - - ); -} - -export default SoftwarePatternsSelection; diff --git a/web/src/components/software/SoftwarePatternsSelection.test.jsx b/web/src/components/software/SoftwarePatternsSelection.test.jsx deleted file mode 100644 index 1faf1225da..0000000000 --- a/web/src/components/software/SoftwarePatternsSelection.test.jsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (c) [2023-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, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; - -import test_patterns from "./SoftwarePatternsSelection.test.json"; -import SoftwarePatternsSelection from "./SoftwarePatternsSelection"; -import { SelectedBy } from "~/client/software"; - -const patterns = test_patterns.map((p) => ({ ...p, selectedBy: SelectedBy.NONE })); - -describe.skip("SoftwarePatternsSelection", () => { - it("displays the pattern groups in the correct order", () => { - plainRender(); - const headings = screen.getAllByRole("heading", { level: 2 }); - const headingsText = headings.map((node) => node.textContent); - expect(headingsText).toEqual([ - "Graphical Environments", - "Base Technologies", - "Desktop Functions", - ]); - }); - - it("displays the patterns in a group in correct order", async () => { - plainRender(); - - // the "Base Technologies" pattern group - const baseGroup = await screen.findByRole("region", { name: "Base Technologies" }); - - // the pattern names - const rows = within(baseGroup).getAllByRole("row"); - - expect(rows[0]).toHaveTextContent(/YaST Base Utilities/); - expect(rows[1]).toHaveTextContent(/YaST Desktop Utilities/); - expect(rows[2]).toHaveTextContent(/YaST Server Utilities/); - }); - - it("displays only the matching patterns when using the search filter", async () => { - const { user } = plainRender(); - - // enter "multimedia" into the search filter - const searchFilter = await screen.findByRole("textbox", { name: "Search" }); - await user.type(searchFilter, "multimedia"); - - const headings = screen.getAllByRole("heading", { level: 2 }); - const headingsText = headings.map((node) => node.textContent); - expect(headingsText).toEqual(["Desktop Functions"]); - - const desktopGroup = screen.getByRole("region", { name: "Desktop Functions" }); - expect(within(desktopGroup).queryByRole("row", { name: /Multimedia/ })).toBeInTheDocument(); - expect( - within(desktopGroup).queryByRole("row", { name: /Office Software/ }), - ).not.toBeInTheDocument(); - }); - - it("displays the checkbox depending whether the patter is selected", async () => { - const pattern = patterns.find((p) => p.name === "yast2_basis"); - pattern.selectedBy = SelectedBy.USER; - - plainRender(); - - // the "Base Technologies" pattern group - const baseGroup = await screen.findByRole("region", { name: "Base Technologies" }); - - const rowBasis = within(baseGroup).getByRole("row", { name: /YaST Base/ }); - const checkboxBasis = await within(rowBasis).findByRole("checkbox"); - expect(checkboxBasis).toBeChecked(); - - const rowDesktop = within(baseGroup).getByRole("row", { name: /YaST Desktop/ }); - const checkboxDesktop = await within(rowDesktop).findByRole("checkbox"); - expect(checkboxDesktop).not.toBeChecked(); - }); -}); diff --git a/web/src/components/software/SoftwarePatternsSelection.test.tsx b/web/src/components/software/SoftwarePatternsSelection.test.tsx new file mode 100644 index 0000000000..002e3a053e --- /dev/null +++ b/web/src/components/software/SoftwarePatternsSelection.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright (c) [2023-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, within } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import testingPatterns from "./patterns.test.json"; +import SoftwarePatternsSelection from "./SoftwarePatternsSelection"; + +const onConfigMutationMock = { mutate: jest.fn() }; + +jest.mock("~/queries/software", () => ({ + usePatterns: () => testingPatterns, + useConfigMutation: () => onConfigMutationMock, +})); + +describe("SoftwarePatternsSelection", () => { + it("displays the pattern in the correct order", async () => { + installerRender(); + const headings = screen.getAllByRole("heading", { level: 3 }); + const headingsText = headings.map((node) => node.textContent); + expect(headingsText).toEqual([ + "Graphical Environments", + "Base Technologies", + "Desktop Functions", + ]); + + // the "Base Technologies" pattern group + const baseGroup = await screen.findByRole("list", { name: "Base Technologies" }); + // the "Base Technologies" pattern items + const items = within(baseGroup).getAllByRole("listitem"); + expect(items[0]).toHaveTextContent(/YaST Base Utilities/); + expect(items[1]).toHaveTextContent(/YaST Desktop Utilities/); + expect(items[2]).toHaveTextContent(/YaST Server Utilities/); + }); + + it("displays only the matching patterns when filtering", async () => { + const { user } = installerRender(); + + // enter "multimedia" into the search filter + const searchFilter = await screen.findByRole("textbox", { name: /Filter/ }); + await user.type(searchFilter, "multimedia"); + + const headings = screen.getAllByRole("heading", { level: 3 }); + const headingsText = headings.map((node) => node.textContent); + expect(headingsText).toEqual(["Desktop Functions"]); + + const desktopGroup = screen.getByRole("list", { name: "Desktop Functions" }); + expect( + within(desktopGroup).queryByRole("listitem", { name: /Multimedia/ }), + ).toBeInTheDocument(); + expect( + within(desktopGroup).queryByRole("listitem", { name: /Office Software/ }), + ).not.toBeInTheDocument(); + }); + + it("displays the checkbox reflecting the current pattern selection status", async () => { + installerRender(); + + // the "Base Technologies" pattern group + const baseGroup = await screen.findByRole("list", { name: "Base Technologies" }); + + const basisItem = within(baseGroup).getByRole("listitem", { name: /YaST Base/ }); + const basisCheckbox = await within(basisItem).findByRole("checkbox"); + expect(basisCheckbox).toBeChecked(); + + const serverItem = within(baseGroup).getByRole("listitem", { name: /YaST Server/ }); + const serverCheckbox = await within(serverItem).findByRole("checkbox"); + expect(serverCheckbox).not.toBeChecked(); + }); + + it("allows changing the selection", async () => { + const { user } = installerRender(); + const y2BasisPattern = testingPatterns.find((p) => p.name === "yast2_basis"); + + const basisItem = screen.getByRole("listitem", { name: y2BasisPattern.summary }); + const basisCheckbox = await within(basisItem).findByRole("checkbox"); + expect(basisCheckbox).toBeChecked(); + + await user.click(basisCheckbox); + expect(onConfigMutationMock.mutate).toHaveBeenCalledWith({ + patterns: expect.objectContaining({ yast2_basis: false }), + }); + }); +}); diff --git a/web/src/components/software/SoftwarePatternsSelection.tsx b/web/src/components/software/SoftwarePatternsSelection.tsx new file mode 100644 index 0000000000..4b7648d748 --- /dev/null +++ b/web/src/components/software/SoftwarePatternsSelection.tsx @@ -0,0 +1,215 @@ +/* + * Copyright (c) [2023-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, { useState } from "react"; +import { + Card, + CardBody, + Label, + DataList, + DataListCell, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + SearchInput, + Stack, +} from "@patternfly/react-core"; +import { Page } from "~/components/core"; +import { useConfigMutation, usePatterns } from "~/queries/software"; +import { Pattern, SelectedBy } from "~/types/software"; +import { _ } from "~/i18n"; +import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; + +/** + * PatternGroups mapping "group name" => list of patterns + */ +type PatternsGroups = { [key: string]: Pattern[] }; + +/** + * Group the patterns with the same group name + */ +function groupPatterns(patterns: Pattern[]): PatternsGroups { + const groups = {}; + + patterns.forEach((pattern) => { + if (groups[pattern.category]) { + groups[pattern.category].push(pattern); + } else { + groups[pattern.category] = [pattern]; + } + }); + + // sort patterns by the "order" value + Object.keys(groups).forEach((group) => { + groups[group].sort((p1, p2) => { + if (p1.order === p2.order) { + // there should be no patterns with the same name + return p1.name < p2.name ? -1 : 1; + } else { + return p1.order - p2.order; + } + }); + }); + + return groups; +} + +/** + * Sort pattern group names + */ +function sortGroups(groups: PatternsGroups): string[] { + return Object.keys(groups).sort((g1, g2) => { + const order1 = groups[g1][0].order; + const order2 = groups[g2][0].order; + return order1 - order2; + }); +} + +const filterPatterns = (patterns: Pattern[] = [], searchValue = ""): Pattern[] => { + if (searchValue.trim() === "") return patterns; + + // case insensitive search + const searchData = searchValue.toUpperCase(); + return patterns.filter( + (p) => + p.name.toUpperCase().indexOf(searchData) !== -1 || + p.description.toUpperCase().indexOf(searchData) !== -1, + ); +}; + +const NoMatches = (): React.ReactNode => {_("None of the patterns match the filter.")}; + +/** + * Pattern selector component + */ +function SoftwarePatternsSelection(): React.ReactNode { + const patterns = usePatterns(); + const config = useConfigMutation(); + const [searchValue, setSearchValue] = useState(""); + + const onToggle = (name: string) => { + const selected = patterns + .filter((p) => p.selectedBy === SelectedBy.USER) + .reduce((all, p) => { + all[p.name] = true; + return all; + }, {}); + const pattern = patterns.find((p) => p.name === name); + selected[name] = pattern.selectedBy === SelectedBy.NONE; + + config.mutate({ patterns: selected }); + }; + + // FIXME: use loading indicator when busy, we cannot know if it will be + // quickly or not in advance. + + // initial empty screen, the patterns are loaded very quickly, no need for any progress + const visiblePatterns = filterPatterns(patterns, searchValue); + if (visiblePatterns.length === 0 && searchValue === "") return null; + + const groups = groupPatterns(visiblePatterns); + + // FIXME: use a switch instead of a checkbox since these patterns are going to + // be selected/deselected immediately. + // TODO: extract to a DataListSelector component or so. + const selector = sortGroups(groups).map((groupName) => { + const selectedIds = groups[groupName] + .filter((p) => p.selectedBy !== SelectedBy.NONE) + .map((p) => p.name); + return ( +
+

{groupName}

+ + {groups[groupName].map((option) => { + const titleId = `${option.name}-title`; + const descId = `${option.name}-desc`; + const selected = selectedIds.includes(option.name); + const nextActionId = `${option.name}-next-action`; + + return ( + + + onToggle(option.name)} + aria-labelledby={[nextActionId, titleId].join(" ")} + isChecked={selected} + /> + + +
+ {option.summary}{" "} + {option.selectedBy === SelectedBy.AUTO && ( + + )} + + {selected ? _("Unselect") : _("Select")} + +
+
{option.description}
+
+ , + ]} + /> +
+
+ ); + })} +
+
+ ); + }); + + return ( + <> + + +

{_("Software selection")}

+ setSearchValue(value)} + onClear={() => setSearchValue("")} + resultsCount={visiblePatterns.length} + /> +
+
+ + + + {selector.length > 0 ? selector : } + + + + + {_("Close")} + + + ); +} + +export default SoftwarePatternsSelection; diff --git a/web/src/components/software/UsedSize.test.jsx b/web/src/components/software/UsedSize.test.tsx similarity index 99% rename from web/src/components/software/UsedSize.test.jsx rename to web/src/components/software/UsedSize.test.tsx index fae1607f28..12610ec3cd 100644 --- a/web/src/components/software/UsedSize.test.jsx +++ b/web/src/components/software/UsedSize.test.tsx @@ -22,7 +22,6 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; - import UsedSize from "./UsedSize"; describe("UsedSize", () => { diff --git a/web/src/components/software/UsedSize.jsx b/web/src/components/software/UsedSize.tsx similarity index 95% rename from web/src/components/software/UsedSize.jsx rename to web/src/components/software/UsedSize.tsx index 490d8a9684..e4e2d9b40f 100644 --- a/web/src/components/software/UsedSize.jsx +++ b/web/src/components/software/UsedSize.tsx @@ -20,12 +20,11 @@ */ import React from "react"; - import { EmptyState } from "~/components/core"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -export default function UsedSize({ size }) { +export default function UsedSize({ size }: { size?: string }) { if (size === undefined || size === "" || size === "0 B") return null; // TRANSLATORS: %s will be replaced by the estimated installation size, diff --git a/web/src/components/software/SoftwarePatternsSelection.test.json b/web/src/components/software/patterns.test.json similarity index 83% rename from web/src/components/software/SoftwarePatternsSelection.test.json rename to web/src/components/software/patterns.test.json index 58c301ba47..b5c10a3c50 100644 --- a/web/src/components/software/SoftwarePatternsSelection.test.json +++ b/web/src/components/software/patterns.test.json @@ -1,51 +1,57 @@ [ { - "name": "xfce", + "name": "gnome", "category": "Graphical Environments", - "icon": "./pattern-xfce", - "description": "Xfce is a lightweight desktop environment for various *NIX systems.", - "summary": "XFCE Desktop Environment", - "order": "1310" + "icon": "./pattern-gnome-wayland", + "description": "The GNOME desktop environment is an intuitive and attractive desktop for users.\nThis pattern installs components for GNOME to run with Wayland and X11 technologies.", + "summary": "GNOME Desktop Environment (Wayland)", + "order": "1010", + "selectedBy": 0 }, { - "name": "basic_desktop", + "name": "kde", "category": "Graphical Environments", - "icon": "./pattern-x11", - "description": "This pattern installs a rather basic desktop (icewm)", - "summary": "A very basic desktop (previously part of x11 pattern)", - "order": "1802" + "icon": "./pattern-kde", + "description": "Packages providing the Plasma desktop environment and applications from KDE.", + "summary": "KDE Applications and Plasma 5 Desktop", + "order": "1110", + "selectedBy": 2 }, { - "name": "yast2_server", + "name": "yast2_basis", "category": "Base Technologies", "icon": "./yast", - "description": "YaST tools for server system administration.", - "summary": "YaST Server Utilities", - "order": "1224" + "description": "YaST tools for basic system administration.", + "summary": "YaST Base Utilities", + "order": "1220", + "selectedBy": 1 }, { - "name": "office", - "category": "Desktop Functions", - "icon": "./pattern-office", - "description": "Office software for your desktop environment including LibreOffice.", - "summary": "Office Software", - "order": "1640" + "name": "yast2_desktop", + "category": "Base Technologies", + "icon": "./yast", + "description": "YaST tools for desktop system administration.", + "summary": "YaST Desktop Utilities", + "order": "1222", + "selectedBy": 1 }, { - "name": "gnome", - "category": "Graphical Environments", - "icon": "./pattern-gnome-wayland", - "description": "The GNOME desktop environment is an intuitive and attractive desktop for users.\nThis pattern installs components for GNOME to run with Wayland and X11 technologies.", - "summary": "GNOME Desktop Environment (Wayland)", - "order": "1010" + "name": "yast2_server", + "category": "Base Technologies", + "icon": "./yast", + "description": "YaST tools for server system administration.", + "summary": "YaST Server Utilities", + "order": "1224", + "selectedBy": 2 }, { - "name": "kde", + "name": "xfce", "category": "Graphical Environments", - "icon": "./pattern-kde", - "description": "Packages providing the Plasma desktop environment and applications from KDE.", - "summary": "KDE Applications and Plasma Desktop", - "order": "1110" + "icon": "./pattern-xfce", + "description": "Xfce is a lightweight desktop environment for various *NIX systems.", + "summary": "XFCE Desktop Environment", + "order": "1310", + "selectedBy": 2 }, { "name": "multimedia", @@ -53,22 +59,25 @@ "icon": "./pattern-multimedia", "description": "Multimedia players, sound editing tools, video and image manipulation applications.", "summary": "Multimedia", - "order": "1580" + "order": "1580", + "selectedBy": 1 }, { - "name": "yast2_basis", - "category": "Base Technologies", - "icon": "./yast", - "description": "YaST tools for basic system administration.", - "summary": "YaST Base Utilities", - "order": "1220" + "name": "office", + "category": "Desktop Functions", + "icon": "./pattern-office", + "description": "Office software for your desktop environment including LibreOffice.", + "summary": "Office Software", + "order": "1640", + "selectedBy": 1 }, { - "name": "yast2_desktop", - "category": "Base Technologies", - "icon": "./yast", - "description": "YaST tools for desktop system administration.", - "summary": "YaST Desktop Utilities", - "order": "1222" + "name": "basic_desktop", + "category": "Graphical Environments", + "icon": "./pattern-x11", + "description": "This pattern installs a rather basic desktop (icewm)", + "summary": "A very basic desktop (previously part of x11 pattern)", + "order": "1802", + "selectedBy": 2 } ] diff --git a/web/src/components/software/proposal.test.json b/web/src/components/software/proposal.test.json new file mode 100644 index 0000000000..e2166a658d --- /dev/null +++ b/web/src/components/software/proposal.test.json @@ -0,0 +1,35 @@ +{ + "size": "4.6 GiB", + "patterns": { + "fonts": 1, + "gnome_basis_opt": 1, + "minimal_base": 1, + "gnome_imaging": 1, + "fonts_opt": 1, + "x86_64_v3": 1, + "gnome_office": 1, + "x11_yast": 1, + "gnome": 0, + "base": 1, + "sw_management": 1, + "x11": 1, + "gnome_utilities": 1, + "enhanced_base": 1, + "sw_management_gnome": 1, + "gnome_basic": 1, + "x11_enhanced": 1, + "gnome_x11": 1, + "office": 1, + "yast2_desktop": 1, + "gnome_basis": 1, + "basesystem": 1, + "multimedia": 1, + "apparmor": 1, + "yast2_basis": 1, + "gnome_games": 1, + "imaging": 1, + "gnome_multimedia": 1, + "gnome_yast": 1, + "gnome_internet": 1 + } +} diff --git a/web/src/queries/software.js b/web/src/queries/software.js deleted file mode 100644 index 8aa17911b1..0000000000 --- a/web/src/queries/software.js +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { - QueryClient, - useMutation, - useQueryClient, - useSuspenseQueries, -} from "@tanstack/react-query"; -import { useInstallerClient } from "~/context/installer"; - -const configQuery = () => ({ - queryKey: ["software/config"], - queryFn: () => fetch("/api/software/config").then((res) => res.json()), -}); - -const selectedProductQuery = () => ({ - queryKey: ["software/product"], - queryFn: async () => { - const response = await fetch("/api/software/config"); - const { product } = await response.json(); - return product; - }, -}); - -const productsQuery = () => ({ - queryKey: ["software/products"], - queryFn: () => fetch("/api/software/products").then((res) => res.json()), - staleTime: Infinity, -}); - -/** - * Hook that builds a mutation to update the software configuration - * - * It does not require to call `useMutation`. - */ -const useConfigMutation = () => { - const queryClient = useQueryClient(); - const client = useInstallerClient(); - - const query = { - mutationFn: (newConfig) => - fetch("/api/software/config", { - // FIXME: use "PATCH" instead - method: "PUT", - body: JSON.stringify(newConfig), - headers: { - "Content-Type": "application/json", - }, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["software/config"] }); - queryClient.invalidateQueries({ queryKey: ["software/product"] }); - client.manager.startProbing(); - }, - }; - return useMutation(query); -}; - -/** - * Hook that returns a useEffect to listen for software events - * - * When the configuration changes, it invalidates the config query and forces the router to - * revalidate its data (executing the loaders again). - */ -const useProductChanges = () => { - const client = useInstallerClient(); - - React.useEffect(() => { - if (!client) return; - const queryClient = new QueryClient(); - - return client.ws().onEvent((event) => { - if (event.type === "ProductChanged") { - queryClient.invalidateQueries({ queryKey: ["software/config"] }); - } - }); - }, [client]); -}; - -const useProduct = () => { - const [{ data: selected }, { data: products }] = useSuspenseQueries({ - queries: [selectedProductQuery(), productsQuery()], - }); - - const selectedProduct = products.find((p) => p.id === selected); - return { - products, - selectedProduct, - }; -}; - -export { - configQuery, - selectedProductQuery, - productsQuery, - useConfigMutation, - useProduct, - useProductChanges, -}; diff --git a/web/src/queries/software.ts b/web/src/queries/software.ts new file mode 100644 index 0000000000..9c29d8c73f --- /dev/null +++ b/web/src/queries/software.ts @@ -0,0 +1,217 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { + useMutation, + useQueryClient, + useSuspenseQueries, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { + Pattern, + PatternsSelection, + Product, + SelectedBy, + SoftwareConfig, + SoftwareProposal, +} from "~/types/software"; + +/** + * Query to retrieve software configuration + */ +const configQuery = () => ({ + queryKey: ["software/config"], + queryFn: () => fetch("/api/software/config").then((res) => res.json()), +}); + +/** + * Query to retrieve current software proposal + */ +const proposalQuery = () => ({ + queryKey: ["software/proposal"], + queryFn: () => fetch("/api/software/proposal").then((res) => res.json()), +}); + +/** + * Query to retrieve available products + */ +const productsQuery = () => ({ + queryKey: ["software/products"], + queryFn: () => fetch("/api/software/products").then((res) => res.json()), + staleTime: Infinity, +}); + +/** + * Query to retrieve selected product + */ +const selectedProductQuery = () => ({ + queryKey: ["software/product"], + queryFn: async () => { + const response = await fetch("/api/software/config"); + const { product } = await response.json(); + return product; + }, +}); + +/** + * Query to retrieve available patterns + */ +const patternsQuery = () => ({ + queryKey: ["software/patterns"], + queryFn: () => fetch("/api/software/patterns").then((res) => res.json()), +}); + +/** + * Hook that builds a mutation to update the software configuration + * + * @note it would trigger a general probing as a side-effect when mutation + * includes a product. + */ +const useConfigMutation = () => { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + const query = { + mutationFn: (newConfig: SoftwareConfig) => + fetch("/api/software/config", { + // FIXME: use "PATCH" instead + method: "PUT", + body: JSON.stringify(newConfig), + headers: { + "Content-Type": "application/json", + }, + }), + onSuccess: (_, config: SoftwareConfig) => { + queryClient.invalidateQueries({ queryKey: ["software/config"] }); + queryClient.invalidateQueries({ queryKey: ["software/proposal"] }); + if (config.product) { + queryClient.invalidateQueries({ queryKey: ["software/product"] }); + client.manager.startProbing(); + } + }, + }; + return useMutation(query); +}; + +/** + * Returns available products and selected one, if any + */ +const useProduct = (): { products: Product[]; selectedProduct: Product | undefined } => { + const [{ data: selected }, { data: products }] = useSuspenseQueries({ + queries: [selectedProductQuery(), productsQuery()], + }); + + const selectedProduct = products.find((p: Product) => p.id === selected); + return { + products, + selectedProduct, + }; +}; + +/** + * Returns a list of patterns with their selectedBy property properly set based on current proposal. + */ +const usePatterns = (): Pattern[] => { + const [{ data: proposal }, { data: patterns }] = useSuspenseQueries({ + queries: [proposalQuery(), patternsQuery()], + }); + + const selection: PatternsSelection = proposal.patterns; + + return patterns + .map((pattern: Pattern): Pattern => { + let selectedBy: SelectedBy; + switch (selection[pattern.name]) { + case 0: + selectedBy = SelectedBy.USER; + break; + case 1: + selectedBy = SelectedBy.AUTO; + break; + default: + selectedBy = SelectedBy.NONE; + } + return { ...pattern, selectedBy }; + }) + .sort((a: Pattern, b: Pattern) => a.order - b.order); +}; + +/** + * Returns current software proposal + */ +const useProposal = (): SoftwareProposal => { + const { data: proposal } = useSuspenseQuery(proposalQuery()); + return proposal; +}; + +/** + * Hook that returns a useEffect to listen for software proposal events + * + * When the configuration changes, it invalidates the config query. + */ +const useProductChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + if (event.type === "") { + queryClient.invalidateQueries({ queryKey: ["software/config"] }); + } + }); + }, [client, queryClient]); +}; + +/** + * Hook that returns a useEffect to listen for software proposal changes + * + * When the selected patterns change, it invalidates the proposal query. + */ +const useProposalChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + if (event.type === "SoftwareProposalChanged") { + queryClient.invalidateQueries({ queryKey: ["software/proposal"] }); + } + }); + }, [client, queryClient]); +}; + +export { + configQuery, + productsQuery, + selectedProductQuery, + useConfigMutation, + usePatterns, + useProduct, + useProductChanges, + useProposal, + useProposalChanges, +}; diff --git a/web/src/types/registration.ts b/web/src/types/registration.ts new file mode 100644 index 0000000000..6d244326a2 --- /dev/null +++ b/web/src/types/registration.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +type Registration = { + /** Registration requirement (i.e., "not-required", "optional", "mandatory") */ + requirement: string; + /** Registration code, if any */ + code?: string; + /** Registration email, if any */ + email?: string; +}; + +type RegistrationFailure = { + /** @property {Number} id - ID of error */ + id: number; + /** Failure message */ + message: string; +}; + +export type { Registration, RegistrationFailure }; diff --git a/web/src/types/software.ts b/web/src/types/software.ts new file mode 100644 index 0000000000..42a9d8edc9 --- /dev/null +++ b/web/src/types/software.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +/** + * Enum for the reasons to select a pattern + */ +enum SelectedBy { + /** Selected by the user */ + USER = 0, + /** Automatically selected as a dependency of another package */ + AUTO = 1, + /** No selected */ + NONE = 2, +} + +type Product = { + /** Product ID (e.g., "Leap") */ + id: string; + /** Product name (e.g., "openSUSE Leap 15.4") */ + name: string; + /** Product description */ + description: string; +}; + +type PatternsSelection = { [key: string]: SelectedBy }; + +type SoftwareProposal = { + /** Used space in human-readable form */ + size: string; + /** Selected patterns and the reason */ + patterns: PatternsSelection; +}; + +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 }; +}; + +type Pattern = { + /** Pattern name (internal ID) */ + name: string; + /** Pattern category */ + category: string; + /** User visible pattern name */ + summary: string; + /** Long description of the pattern */ + description: string; + /** {number} order - Display order (string!) */ + order: number; + /** Icon name (not path or file name!) */ + icon: string; + /** Whether the pattern if selected and by whom */ + selectedBy?: SelectedBy; +}; + +export { SelectedBy }; +export type { Pattern, PatternsSelection, Product, SoftwareConfig, SoftwareProposal }; diff --git a/web/src/utils.js b/web/src/utils.js index dd90e6cbd3..81975e5c69 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -39,6 +39,16 @@ const isObject = (value) => !(value instanceof Set) && !(value instanceof Map); +/** + * Whether given object is empty or not + * + * @param {object} value - the value to be checked + * @return {boolean} true when given value is an empty object; false otherwise + */ +const isObjectEmpty = (value) => { + return Object.keys(value).length === 0; +}; + /** * Returns an empty function useful to be used as a default callback. * @@ -378,6 +388,7 @@ export { noop, identity, isObject, + isObjectEmpty, partition, compact, uniq, diff --git a/web/tsconfig.json b/web/tsconfig.json index c8a6d2fa16..0696f7cbbd 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -5,9 +5,11 @@ "target": "esnext", "moduleResolution": "node", "resolveJsonModule": true, + "esModuleInterop": true, "allowJs": true, "jsx": "react", "allowSyntheticDefaultImports": true, + "types": ["node", "jest", "@testing-library/jest-dom"], "paths": { "~/*": ["src/*"], "~/client": ["src/client/index.js"],