diff --git a/web/src/App.jsx b/web/src/App.jsx
index d65379b109..81a7a8d03b 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/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 };
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;