From 98f3843ee7344a7090f2b0aa5ebde1cecc5f289b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 5 Jul 2024 15:57:23 +0100 Subject: [PATCH 1/3] refactor(web): add software queries and a loader --- web/src/App.jsx | 5 +- web/src/App.test.jsx | 11 +- web/src/MainLayout.jsx | 2 +- web/src/SimpleLayout.jsx | 7 +- .../components/overview/OverviewPage.test.jsx | 7 +- .../product/ProductRegistrationPage.jsx | 2 +- .../product/ProductSelectionPage.jsx | 11 +- .../product/ProductSelectionPage.test.jsx | 7 +- .../product/ProductSelectionProgress.jsx | 6 +- .../components/storage/ProposalPage.test.jsx | 7 +- .../storage/ProposalTransactionalInfo.jsx | 2 +- .../ProposalTransactionalInfo.test.jsx | 7 +- web/src/queries/software.js | 102 ++++++++++++++++++ web/src/router.js | 4 +- .../product/routes.js => routes/products.js} | 14 +-- 15 files changed, 150 insertions(+), 44 deletions(-) create mode 100644 web/src/queries/software.js rename web/src/{components/product/routes.js => routes/products.js} (78%) diff --git a/web/src/App.jsx b/web/src/App.jsx index d65379b109..962996a1b1 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -26,7 +26,7 @@ import { Questions } from "~/components/questions"; import { ServerError, Installation } from "~/components/core"; import { useInstallerL10n } from "./context/installerL10n"; import { useInstallerClientStatus } from "~/context/installer"; -import { useProduct } from "./context/product"; +import { useProduct, useProductChanges } from "./queries/software"; import { CONFIG, INSTALL, STARTUP } from "~/client/phase"; import { BUSY } from "~/client/status"; import { useL10nConfigChanges } from "~/queries/l10n"; @@ -44,6 +44,7 @@ function App() { const { selectedProduct, products } = useProduct(); const { language } = useInstallerL10n(); useL10nConfigChanges(); + useProductChanges(); const Content = () => { if (error) return ; @@ -58,7 +59,7 @@ function App() { return ; } - if (selectedProduct === null && location.pathname !== "/products") { + if ((selectedProduct === undefined) & (location.pathname !== "/products")) { return ; } diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index d3fe46be4b..2fd0ac3986 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -28,6 +28,7 @@ import { createClient } from "~/client"; import { STARTUP, CONFIG, INSTALL } from "~/client/phase"; import { IDLE, BUSY } from "~/client/status"; import { useL10nConfigChanges } from "./queries/l10n"; +import { useProductChanges } from "./queries/software"; jest.mock("~/client"); @@ -35,14 +36,15 @@ jest.mock("~/client"); let mockProducts; let mockSelectedProduct; -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), useProduct: () => { return { products: mockProducts, selectedProduct: mockSelectedProduct }; - } + }, + useProductChanges: () => jest.fn() })); jest.mock("~/queries/l10n", () => ({ @@ -78,7 +80,7 @@ describe("App", () => { l10n: { getUIKeymap: jest.fn().mockResolvedValue("en"), getUILocale: jest.fn().mockResolvedValue("en_us"), - setUILocale: jest.fn().mockResolvedValue("en_us"), + setUILocale: jest.fn().mockResolvedValue("en_us") } }; }); @@ -125,6 +127,7 @@ describe("App", () => { describe("if the service is busy", () => { beforeEach(() => { mockClientStatus.status = BUSY; + mockSelectedProduct = { id: "Tumbleweed" }; }); it("redirects to product selection progress", async () => { diff --git a/web/src/MainLayout.jsx b/web/src/MainLayout.jsx index b830c96a84..6ed3d31994 100644 --- a/web/src/MainLayout.jsx +++ b/web/src/MainLayout.jsx @@ -32,7 +32,7 @@ import { Icon, Loading } from "~/components/layout"; import { About, InstallerOptions, LogsButton } from "~/components/core"; import { _ } from "~/i18n"; import { rootRoutes } from "~/router"; -import { useProduct } from "~/context/product"; +import { useProduct } from "./queries/software"; const Header = () => { const { selectedProduct } = useProduct(); diff --git a/web/src/SimpleLayout.jsx b/web/src/SimpleLayout.jsx index edad34d16f..99ea0b3320 100644 --- a/web/src/SimpleLayout.jsx +++ b/web/src/SimpleLayout.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { Suspense } from "react"; import { Outlet } from "react-router-dom"; import { Masthead, MastheadContent, @@ -28,6 +28,7 @@ import { } from "@patternfly/react-core"; import { InstallerOptions } from "./components/core"; import { _ } from "~/i18n"; +import { Loading } from "./components/layout"; /** * Simple layout for displaying content that comes before product configuration @@ -49,7 +50,9 @@ export default function SimpleLayout({ showOutlet = true, showInstallerOptions = - {showOutlet ? : children} + }> + {showOutlet ? : children} + ); } diff --git a/web/src/components/overview/OverviewPage.test.jsx b/web/src/components/overview/OverviewPage.test.jsx index 02592374f7..cb0a26f2b5 100644 --- a/web/src/components/overview/OverviewPage.test.jsx +++ b/web/src/components/overview/OverviewPage.test.jsx @@ -29,9 +29,10 @@ const startInstallationFn = jest.fn(); let mockSelectedProduct = { id: "Tumbleweed" }; jest.mock("~/client"); -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), - useProduct: () => ({ selectedProduct: mockSelectedProduct }) +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: () => ({ selectedProduct: mockSelectedProduct }), + useProductChanges: () => jest.fn() })); jest.mock("~/components/overview/L10nSection", () => () =>
Localization Section
); diff --git a/web/src/components/product/ProductRegistrationPage.jsx b/web/src/components/product/ProductRegistrationPage.jsx index 21e79d9a21..9b74b7beb2 100644 --- a/web/src/components/product/ProductRegistrationPage.jsx +++ b/web/src/components/product/ProductRegistrationPage.jsx @@ -23,7 +23,7 @@ import React, { useState } from "react"; import { Alert, Form, FormGroup } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { EmailInput, Page, PasswordInput } from "~/components/core"; -import { useProduct } from "~/context/product"; +import { useProduct } from "~/queries/software"; import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; diff --git a/web/src/components/product/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx index 454a9e39d7..1ba59c83cd 100644 --- a/web/src/components/product/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.jsx @@ -32,7 +32,7 @@ import styles from '@patternfly/react-styles/css/utilities/Text/text'; import { _ } from "~/i18n"; import { Page } from "~/components/core"; import { Loading, Center } from "~/components/layout"; -import { useProduct } from "~/context/product"; +import { useConfigMutation, useProduct } from "~/queries/software"; const Label = ({ children }) => ( @@ -41,7 +41,8 @@ const Label = ({ children }) => ( ); function ProductSelectionPage() { - const { products, selectedProduct, selectProduct } = useProduct(); + const { products, selectedProduct } = useProduct(); + const setConfig = useConfigMutation(); const [nextProduct, setNextProduct] = useState(selectedProduct); const [isLoading, setIsLoading] = useState(false); @@ -49,15 +50,11 @@ function ProductSelectionPage() { e.preventDefault(); if (nextProduct) { - await selectProduct(nextProduct.id); + setConfig.mutate({ product: nextProduct.id }); setIsLoading(true); } }; - if (!products) return ( - - ); - const Item = ({ children }) => { return ( diff --git a/web/src/components/product/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx index b7b21cb75c..4a9db4db8a 100644 --- a/web/src/components/product/ProductSelectionPage.test.jsx +++ b/web/src/components/product/ProductSelectionPage.test.jsx @@ -39,14 +39,15 @@ const products = [ ]; jest.mock("~/client"); -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), useProduct: () => { return { products, selectedProduct: products[0] }; - } + }, + useProductChanges: () => jest.fn() })); const managerMock = { diff --git a/web/src/components/product/ProductSelectionProgress.jsx b/web/src/components/product/ProductSelectionProgress.jsx index bfda9a3cc9..944ebe3243 100644 --- a/web/src/components/product/ProductSelectionProgress.jsx +++ b/web/src/components/product/ProductSelectionProgress.jsx @@ -22,7 +22,7 @@ import React, { useEffect, useState } from "react"; import { Navigate } from "react-router-dom"; import { _ } from "~/i18n"; -import { useProduct } from "~/context/product"; +import { useProduct } from "~/queries/software"; import { ProgressReport } from "~/components/core"; import { IDLE } from "~/client/status"; import { useInstallerClient } from "~/context/installer"; @@ -42,10 +42,6 @@ function ProductSelectionProgress() { return manager.onStatusChange(setStatus); }, [manager, setStatus]); - if (!selectedProduct) { - return; - } - if (status === IDLE) return ; return ( diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index 801fcf5c3b..8e69b02bb2 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -48,11 +48,12 @@ jest.mock("@patternfly/react-core", () => { }); jest.mock("./DevicesTechMenu", () => () =>
Devices Tech Menu
); -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), useProduct: () => ({ selectedProduct: { name: "Test" } - }) + }), + useProductChanges: () => jest.fn() })); const createClientMock = /** @type {jest.Mock} */(createClient); diff --git a/web/src/components/storage/ProposalTransactionalInfo.jsx b/web/src/components/storage/ProposalTransactionalInfo.jsx index 4a4c6606b3..8e65762dc0 100644 --- a/web/src/components/storage/ProposalTransactionalInfo.jsx +++ b/web/src/components/storage/ProposalTransactionalInfo.jsx @@ -23,7 +23,7 @@ import React from "react"; import { Alert } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { useProduct } from "~/context/product"; +import { useProduct } from "~/queries/software"; import { isTransactionalSystem } from "~/components/storage/utils"; /** diff --git a/web/src/components/storage/ProposalTransactionalInfo.test.jsx b/web/src/components/storage/ProposalTransactionalInfo.test.jsx index e9556107fa..561793d4ba 100644 --- a/web/src/components/storage/ProposalTransactionalInfo.test.jsx +++ b/web/src/components/storage/ProposalTransactionalInfo.test.jsx @@ -24,11 +24,12 @@ import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { ProposalTransactionalInfo } from "~/components/storage"; -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), useProduct: () => ({ selectedProduct : { name: "Test" } - }) + }), + useProductChanges: () => jest.fn() })); let props; diff --git a/web/src/queries/software.js b/web/src/queries/software.js new file mode 100644 index 0000000000..cb2ef253dc --- /dev/null +++ b/web/src/queries/software.js @@ -0,0 +1,102 @@ +/* + * 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 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"] }); + 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: config }, { data: products }] = useSuspenseQueries({ + queries: [configQuery(), productsQuery()] + }); + + const selectedProduct = products.find(p => p.id === config.product); + return { + products, + selectedProduct + }; +}; + +export { configQuery, productsQuery, useConfigMutation, useProduct, useProductChanges }; diff --git a/web/src/router.js b/web/src/router.js index 5b4596ddd5..a4b6732b5f 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -31,7 +31,7 @@ import { _ } from "~/i18n"; import overviewRoutes from "~/components/overview/routes"; import l10nRoutes from "~/routes/l10n"; import networkRoutes from "~/components/network/routes"; -import { productsRoute } from "~/components/product/routes"; +import productsRoutes from "~/routes/products"; import storageRoutes from "~/components/storage/routes"; import softwareRoutes from "~/components/software/routes"; import usersRoutes from "~/components/users/routes"; @@ -62,7 +62,7 @@ const protectedRoutes = [ }, { element: , - children: [productsRoute] + children: [productsRoutes] } ] } diff --git a/web/src/components/product/routes.js b/web/src/routes/products.js similarity index 78% rename from web/src/components/product/routes.js rename to web/src/routes/products.js index 3762d113f7..9ddb6eef94 100644 --- a/web/src/components/product/routes.js +++ b/web/src/routes/products.js @@ -21,11 +21,13 @@ import React from "react"; import { Page } from "~/components/core"; -import ProductSelectionPage from "./ProductSelectionPage"; -import ProductSelectionProgress from "./ProductSelectionProgress"; +import ProductSelectionPage from "~/components/product/ProductSelectionPage"; +import ProductSelectionProgress from "~/components/product/ProductSelectionProgress"; -const productsRoute = { - path: "/products", +const PRODUCTS_PATH = "/products"; + +const productsRoutes = { + path: PRODUCTS_PATH, element: , children: [ { @@ -39,6 +41,4 @@ const productsRoute = { ] }; -export { - productsRoute -}; +export default productsRoutes; From ddd29113aac0f49b6e5efb1ef2731d5a5385bbe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 11 Jul 2024 09:52:06 +0100 Subject: [PATCH 2/3] refactor(web): drop the ProductProvider --- web/src/context/app.jsx | 9 ++-- web/src/context/product.jsx | 103 ------------------------------------ 2 files changed, 3 insertions(+), 109 deletions(-) delete mode 100644 web/src/context/product.jsx diff --git a/web/src/context/app.jsx b/web/src/context/app.jsx index 3b9a83f0bc..704e03339d 100644 --- a/web/src/context/app.jsx +++ b/web/src/context/app.jsx @@ -24,7 +24,6 @@ import React from "react"; import { InstallerClientProvider } from "./installer"; import { InstallerL10nProvider } from "./installerL10n"; -import { ProductProvider } from "./product"; import { IssuesProvider } from "./issues"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -41,11 +40,9 @@ function AppProviders({ children }) { - - - {children} - - + + {children} + diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx deleted file mode 100644 index 3079193e63..0000000000 --- a/web/src/context/product.jsx +++ /dev/null @@ -1,103 +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, { useContext, useEffect, useState } from "react"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "./installer"; - -/** - * @typedef {import ("~/client/software").Product} Product - * @typedef {import ("~/client/software").Registration} Registration - */ - -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 productClient = client.product; - const available = await cancellablePromise(productClient.getAll()); - const selected = await cancellablePromise(productClient.getSelected()); - const registration = await cancellablePromise(productClient.getRegistration()); - setProducts(available); - setSelectedId(selected); - setRegistration(registration); - }; - - if (client) { - load().catch(console.error); - } - }, [client, setProducts, setSelectedId, setRegistration, cancellablePromise]); - - useEffect(() => { - if (!client) return; - - return client.product.onChange(setSelectedId); - }, [client, setSelectedId]); - - useEffect(() => { - if (!client) return; - - return client.product.onRegistrationChange(setRegistration); - }, [client, setRegistration]); - - const selectProduct = async (id) => { - await client.product.select(id); - client.manager.startProbing(); - setSelectedId(id); - }; - - const value = { products, selectedId, registration, selectProduct }; - return {children}; -} - -/** - * Product context. - * @function - * - * @typedef {object} ProductContext - * @property {Product[]} products - * @property {Product|null} selectedProduct - * @property {string} selectedId - * @property {Registration} registration - * - * @returns {ProductContext} - */ -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 }; From d29461af516819d5cf3e2e74158bd441190c8216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 11 Jul 2024 12:35:21 +0100 Subject: [PATCH 3/3] fix(web): update from code review --- web/src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/App.jsx b/web/src/App.jsx index 962996a1b1..81a7a8d03b 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -59,7 +59,7 @@ function App() { return ; } - if ((selectedProduct === undefined) & (location.pathname !== "/products")) { + if ((selectedProduct === undefined) && (location.pathname !== "/products")) { return ; }