diff --git a/web/src/App.jsx b/web/src/App.jsx index 4524a290ac..26773c8111 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -30,6 +30,7 @@ import { useProduct, useProductChanges } from "./queries/software"; import { CONFIG, INSTALL, STARTUP } from "~/client/phase"; import { BUSY } from "~/client/status"; import { useL10nConfigChanges } from "~/queries/l10n"; +import { useIssues, useIssuesChanges } from "./queries/issues"; /** * Main application component. @@ -45,6 +46,7 @@ function App() { const { language } = useInstallerL10n(); useL10nConfigChanges(); useProductChanges(); + useIssuesChanges(); const Content = () => { if (error) return ; diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 74f65dbf48..a4a4b0d96d 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -29,6 +29,7 @@ import { STARTUP, CONFIG, INSTALL } from "~/client/phase"; import { IDLE, BUSY } from "~/client/status"; import { useL10nConfigChanges } from "./queries/l10n"; import { useProductChanges } from "./queries/software"; +import { useIssuesChanges } from "./queries/issues"; jest.mock("~/client"); @@ -52,6 +53,11 @@ jest.mock("~/queries/l10n", () => ({ useL10nConfigChanges: () => jest.fn(), })); +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: () => jest.fn(), +})); + const mockClientStatus = { connected: true, error: false, diff --git a/web/src/client/index.js b/web/src/client/index.js index b7131840a0..24282045ad 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -44,9 +44,6 @@ import { HTTPClient, WSClient } from "./http"; * @property {UsersClient} users - users client. * @property {QuestionsClient} questions - questions client. * @property {() => WSClient} ws - Agama WebSocket client. - * @property {() => Promise} issues - issues from all contexts. - * @property {(handler: IssuesHandler) => (() => void)} onIssuesChange - registers a handler to run - * when issues from any context change. It returns a function to deregister the handler. * @property {() => boolean} isConnected - determines whether the client is connected * @property {() => boolean} isRecoverable - determines whether the client is recoverable after disconnected * @property {(handler: () => void) => (() => void)} onConnect - registers a handler to run @@ -55,25 +52,6 @@ import { HTTPClient, WSClient } from "./http"; * handler. */ -/** - * @typedef {import ("~/client/mixins").Issue} Issue - * - * @typedef {object} Issues - * @property {Issue[]} [product] - Issues from product. - * @property {Issue[]} [storage] - Issues from storage. - * @property {Issue[]} [software] - Issues from software. - * @property {Issue[]} [users] - Issues from users. - * @property {boolean} [isEmpty] - Whether the list is empty - * - * @typedef {(issues: Issues) => void} IssuesHandler - */ - -const createIssuesList = (product = [], software = [], storage = [], users = []) => { - const list = { product, storage, software, users }; - list.isEmpty = !Object.values(list).some((v) => v.length > 0); - return list; -}; - /** * Creates the Agama client * @@ -93,41 +71,6 @@ const createClient = (url) => { const users = new UsersClient(client); const questions = new QuestionsClient(client); - /** - * Gets all issues, grouping them by context. - * - * TODO: issues are requested by several components (e.g., overview sections, notifications - * provider, issues page, storage page, etc). There should be an issues provider. - * - * @returns {Promise} - */ - const issues = async () => { - const productIssues = await product.getIssues(); - const storageIssues = await storage.getIssues(); - const softwareIssues = await software.getIssues(); - const usersIssues = await users.getIssues(); - return createIssuesList(productIssues, softwareIssues, storageIssues, usersIssues); - }; - - /** - * Registers a callback to be executed when issues change. - * - * @param {IssuesHandler} handler - Callback function. - * @return {() => void} - Function to deregister the callback. - */ - const onIssuesChange = (handler) => { - const unsubscribeCallbacks = []; - - unsubscribeCallbacks.push(product.onIssuesChange((i) => handler({ product: i }))); - unsubscribeCallbacks.push(storage.onIssuesChange((i) => handler({ storage: i }))); - unsubscribeCallbacks.push(software.onIssuesChange((i) => handler({ software: i }))); - unsubscribeCallbacks.push(users.onIssuesChange((i) => handler({ users: i }))); - - return () => { - unsubscribeCallbacks.forEach((cb) => cb()); - }; - }; - const isConnected = () => client.ws().isConnected() || false; const isRecoverable = () => !!client.ws().isRecoverable(); @@ -141,8 +84,6 @@ const createClient = (url) => { storage, users, questions, - issues, - onIssuesChange, isConnected, isRecoverable, onConnect: (handler) => client.ws().onOpen(handler), @@ -157,4 +98,4 @@ const createDefaultClient = async () => { return createClient(httpUrl); }; -export { createClient, createDefaultClient, phase, createIssuesList }; +export { createClient, createDefaultClient, phase }; diff --git a/web/src/client/mixins.js b/web/src/client/mixins.js index 5da50e515b..18acdab3ab 100644 --- a/web/src/client/mixins.js +++ b/web/src/client/mixins.js @@ -72,57 +72,6 @@ const buildIssue = ({ description, details, source, severity }) => { }; }; -/** - * Extends the given class with methods to get the issues over D-Bus - * - * @template {!WithHTTPClient} T - * @param {T} superclass - superclass to extend - * @param {string} issues_path - validation resource path (e.g., "/manager/issues"). - * @param {string} service_name - service name (e.g., "org.opensuse.Agama.Manager1"). - */ -const WithIssues = (superclass, issues_path, service_name) => - class extends superclass { - /** - * Returns the issues - * - * @return {Promise} - */ - async getIssues() { - const response = await this.client.get(issues_path); - if (!response.ok) { - console.log("get issues failed with:", response); - return []; - } else { - const issues = await response.json(); - return issues.map(buildIssue); - } - } - - /** - * Gets all issues with error severity - * - * @return {Promise} - */ - async getErrors() { - const issues = await this.getIssues(); - return issues.filter((i) => i.severity === "error"); - } - - /** - * Registers a callback to run when the issues change - * - * @param {IssuesHandler} handler - callback function - * @return {import ("./http").RemoveFn} function to disable the callback - */ - onIssuesChange(handler) { - return this.client.onEvent("IssuesChanged", ({ service, issues }) => { - if (service === service_name) { - handler(issues.map(buildIssue)); - } - }); - } - }; - /** * Extends the given class with methods to get and track the service status * @@ -265,4 +214,4 @@ const createError = (message) => { return { message }; }; -export { WithIssues, WithProgress, WithStatus }; +export { WithProgress, WithStatus }; diff --git a/web/src/client/software.js b/web/src/client/software.js index 0ecc03b073..9e48bcfeb8 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -21,10 +21,9 @@ // @ts-check -import { WithIssues, WithProgress, WithStatus } from "./mixins"; +import { WithProgress, WithStatus } from "./mixins"; const SOFTWARE_SERVICE = "org.opensuse.Agama.Software1"; -const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; /** * Enum for the reasons to select a pattern @@ -185,17 +184,13 @@ class SoftwareBaseClient { /** * Manages software and product configuration. */ -class SoftwareClient extends WithIssues( - WithProgress( - WithStatus(SoftwareBaseClient, "/software/status", SOFTWARE_SERVICE), - "/software/progress", - SOFTWARE_SERVICE, - ), - "/software/issues/software", - "/org/opensuse/Agama/Software1", +class SoftwareClient extends WithProgress( + WithStatus(SoftwareBaseClient, "/software/status", SOFTWARE_SERVICE), + "/software/progress", + SOFTWARE_SERVICE, ) {} -class ProductBaseClient { +class ProductClient { /** * @param {import("./http").HTTPClient} client - HTTP client. */ @@ -336,10 +331,4 @@ class ProductBaseClient { } } -class ProductClient extends WithIssues( - ProductBaseClient, - "/software/issues/product", - PRODUCT_PATH, -) {} - export { ProductClient, SelectedBy, SoftwareClient }; diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 16f20b3b71..2734dbd896 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -23,7 +23,7 @@ // cspell:ignore ptable import { compact, hex, uniq } from "~/utils"; -import { WithIssues, WithProgress, WithStatus } from "./mixins"; +import { WithProgress, WithStatus } from "./mixins"; import { HTTPClient } from "./http"; const SERVICE_NAME = "org.opensuse.Agama.Storage1"; @@ -1652,13 +1652,9 @@ class StorageBaseClient { /** * Allows interacting with the storage settings */ -class StorageClient extends WithIssues( - WithProgress( - WithStatus(StorageBaseClient, "/storage/status", SERVICE_NAME), - "/storage/progress", - SERVICE_NAME, - ), - "/storage/issues", +class StorageClient extends WithProgress( + WithStatus(StorageBaseClient, "/storage/status", SERVICE_NAME), + "/storage/progress", SERVICE_NAME, ) {} diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index f481599a25..6b9d214d0a 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -1346,78 +1346,6 @@ describe.skip("#onDeprecate", () => { }); }); -describe("#getIssues", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("if there are no issues", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const issues = await client.getIssues(); - expect(issues).toEqual([]); - }); - }); - - describe("if there are issues", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withIssues()); - }); - - it("returns the list of issues", async () => { - const issues = await client.getIssues(); - expect(issues).toEqual( - expect.arrayContaining([ - { description: "Issue 1", details: "", source: "system", severity: "error" }, - { description: "Issue 2", details: "", source: "system", severity: "warn" }, - { description: "Issue 3", details: "", source: "config", severity: "error" }, - ]), - ); - }); - }); -}); - -describe("#getErrors", () => { - beforeEach(() => { - client = new StorageClient(http); - mockJsonFn.mockResolvedValue(contexts.withIssues()); - }); - - it("returns the issues with error severity", async () => { - const errors = await client.getErrors(); - expect(errors.map((e) => e.description)).toEqual( - expect.arrayContaining(["Issue 1", "Issue 3"]), - ); - }); -}); - -// @fixme See note at the test of onDeprecate about mocking signals -describe.skip("#onIssuesChange", () => { - it("runs the handler when the issues change", async () => { - client = new StorageClient(); - - const handler = jest.fn(); - client.onIssuesChange(handler); - - emitSignal("/org/opensuse/Agama/Storage1", "org.opensuse.Agama1.Issues", { - All: { - v: [ - ["Issue 1", "", 1, 0], - ["Issue 2", "", 2, 1], - ], - }, - }); - - expect(handler).toHaveBeenCalledWith([ - { description: "Issue 1", details: "", source: "system", severity: "warn" }, - { description: "Issue 2", details: "", source: "config", severity: "error" }, - ]); - }); -}); - describe("#system", () => { describe("#getDevices", () => { beforeEach(() => { diff --git a/web/src/client/users.js b/web/src/client/users.js index 1e1bc2c768..304c7eec78 100644 --- a/web/src/client/users.js +++ b/web/src/client/users.js @@ -21,8 +21,6 @@ // @ts-check -import { WithIssues } from "./mixins"; - const SERVICE_NAME = "org.opensuse.Agama.Manager1"; /** @@ -48,11 +46,11 @@ const SERVICE_NAME = "org.opensuse.Agama.Manager1"; */ /** - * Users client + * Client to interact with the Agama users service * * @ignore */ -class UsersBaseClient { +class UsersClient { /** * @param {import("./http").HTTPClient} client - HTTP client. */ @@ -183,9 +181,4 @@ class UsersBaseClient { } } -/** - * Client to interact with the Agama users service - */ -class UsersClient extends WithIssues(UsersBaseClient, "/users/issues", SERVICE_NAME) {} - export { UsersClient }; diff --git a/web/src/components/overview/OverviewPage.jsx b/web/src/components/overview/OverviewPage.jsx index ebc4532ec4..97dcd14c18 100644 --- a/web/src/components/overview/OverviewPage.jsx +++ b/web/src/components/overview/OverviewPage.jsx @@ -42,6 +42,8 @@ import L10nSection from "./L10nSection"; import StorageSection from "./StorageSection"; import SoftwareSection from "./SoftwareSection"; import { _ } from "~/i18n"; +import { useAllIssues } from "~/queries/issues"; +import { IssueSeverity } from "~/types/issues"; const SCOPE_HEADERS = { users: _("Users"), @@ -59,11 +61,11 @@ const ReadyForInstallation = () => ( // FIXME: improve const IssuesList = ({ issues }) => { - const { isEmpty, ...scopes } = issues; + const { isEmpty, issues: issuesByScope } = issues; const list = []; - Object.entries(scopes).forEach(([scope, issues], idx) => { + Object.entries(issuesByScope).forEach(([scope, issues], idx) => { issues.forEach((issue, subIdx) => { - const variant = issue.severity === "error" ? "warning" : "info"; + const variant = issue.severity === IssueSeverity.Error ? "warning" : "info"; const link = ( @@ -91,12 +93,8 @@ const IssuesList = ({ issues }) => { }; export default function OverviewPage() { - const [issues, setIssues] = useState([]); const client = useInstallerClient(); - - useEffect(() => { - client.issues().then(setIssues); - }, [client]); + const issues = useAllIssues(); const resultSectionProps = issues.isEmpty ? {} diff --git a/web/src/components/overview/OverviewPage.test.jsx b/web/src/components/overview/OverviewPage.test.jsx index a5024b9284..c4463bd3df 100644 --- a/web/src/components/overview/OverviewPage.test.jsx +++ b/web/src/components/overview/OverviewPage.test.jsx @@ -24,9 +24,11 @@ import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { createClient } from "~/client"; import { OverviewPage } from "~/components/overview"; +import { IssuesList } from "~/types/issues"; const startInstallationFn = jest.fn(); let mockSelectedProduct = { id: "Tumbleweed" }; +const mockIssuesList = new IssuesList([], [], [], []); jest.mock("~/client"); jest.mock("~/queries/software", () => ({ @@ -35,6 +37,11 @@ jest.mock("~/queries/software", () => ({ useProductChanges: () => jest.fn(), })); +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: () => jest.fn().mockResolvedValue(mockIssuesList), +})); + jest.mock("~/components/overview/L10nSection", () => () =>
Localization Section
); jest.mock("~/components/overview/StorageSection", () => () =>
Storage Section
); jest.mock("~/components/overview/SoftwareSection", () => () =>
Software Section
); @@ -46,7 +53,6 @@ beforeEach(() => { manager: { startInstallation: startInstallationFn, }, - issues: jest.fn().mockResolvedValue({ isEmpty: true }), }; }); }); @@ -58,9 +64,9 @@ describe("when a product is selected", () => { it("renders the overview page content and the Install button", async () => { installerRender(); - screen.getByText("Localization Section"); - screen.getByText("Storage Section"); - screen.getByText("Software Section"); + screen.findByText("Localization Section"); + screen.findByText("Storage Section"); + screen.findByText("Software Section"); screen.findByText("Install Button"); }); }); diff --git a/web/src/components/software/SoftwarePage.jsx b/web/src/components/software/SoftwarePage.jsx index 49c55ee89e..3ae0c40f6c 100644 --- a/web/src/components/software/SoftwarePage.jsx +++ b/web/src/components/software/SoftwarePage.jsx @@ -25,7 +25,7 @@ import React, { useEffect, useState } from "react"; import { useInstallerClient } from "~/context/installer"; import { useCancellablePromise } from "~/utils"; -import { useIssues } from "~/context/issues"; +import { useIssues } from "~/queries/issues"; import { BUSY } from "~/client/status"; import { _ } from "~/i18n"; import { ButtonLink, CardField, IssuesHint, Page, SectionSkeleton } from "~/components/core"; @@ -108,7 +108,7 @@ const SelectedPatternsList = ({ patterns }) => { * @returns {JSX.Element} */ function SoftwarePage() { - const { software: issues } = useIssues(); + const issues = useIssues("software"); const [status, setStatus] = useState(BUSY); const [patterns, setPatterns] = useState([]); const [isLoading, setIsLoading] = useState(true); diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index 43956c5ca3..4e89fef074 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -32,6 +32,8 @@ import { IDLE } from "~/client/status"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; +import { useIssues } from "~/queries/issues"; +import { IssueSeverity } from "~/types/issues"; /** * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy @@ -49,7 +51,6 @@ const initialState = { system: [], staging: [], actions: [], - errors: [], }; const reducer = (state, action) => { @@ -98,11 +99,6 @@ const reducer = (state, action) => { return { ...state, system, staging }; } - case "UPDATE_ERRORS": { - const { errors } = action.payload; - return { ...state, errors }; - } - default: { return state; } @@ -154,6 +150,10 @@ export default function ProposalPage() { const [state, dispatch] = useReducer(reducer, initialState); const drawerRef = useRef(); + const errors = useIssues("storage") + .filter((s) => s.severity === IssueSeverity.Error) + .map(toValidationError); + const loadAvailableDevices = useCallback(async () => { return await cancellablePromise(client.proposal.getAvailableDevices()); }, [client, cancellablePromise]); @@ -188,11 +188,6 @@ export default function ProposalPage() { return { system, staging }; }, [client, cancellablePromise]); - const loadErrors = useCallback(async () => { - const issues = await cancellablePromise(client.getErrors()); - return issues.map(toValidationError); - }, [client, cancellablePromise]); - const calculateProposal = useCallback( async (settings) => { return await cancellablePromise(client.proposal.calculate(settings)); @@ -228,9 +223,6 @@ export default function ProposalPage() { const devices = await loadDevices(); dispatch({ type: "UPDATE_DEVICES", payload: devices }); - const errors = await loadErrors(); - dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); - if (result !== undefined) dispatch({ type: "STOP_LOADING" }); }, [ calculateProposal, @@ -240,7 +232,6 @@ export default function ProposalPage() { loadVolumeDevices, loadDevices, loadEncryptionMethods, - loadErrors, loadProposalResult, loadVolumeTemplates, ]); @@ -257,12 +248,9 @@ export default function ProposalPage() { const devices = await loadDevices(); dispatch({ type: "UPDATE_DEVICES", payload: devices }); - const errors = await loadErrors(); - dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); - dispatch({ type: "STOP_LOADING" }); }, - [calculateProposal, loadDevices, loadErrors, loadProposalResult], + [calculateProposal, loadDevices, loadProposalResult], ); useEffect(() => { @@ -334,7 +322,7 @@ export default function ProposalPage() { policy={spacePolicy} system={state.system} staging={state.staging} - errors={state.errors} + errors={errors} actions={state.actions} spaceActions={state.settings.spaceActions} devices={state.settings.installationDevices} diff --git a/web/src/components/users/UsersPage.jsx b/web/src/components/users/UsersPage.jsx index 56bef68a8e..5c3a12105a 100644 --- a/web/src/components/users/UsersPage.jsx +++ b/web/src/components/users/UsersPage.jsx @@ -25,10 +25,10 @@ import { _ } from "~/i18n"; import { CardField, IssuesHint, Page } from "~/components/core"; import { FirstUser, RootAuthMethods } from "~/components/users"; import { CardBody, Grid, GridItem } from "@patternfly/react-core"; -import { useIssues } from "~/context/issues"; +import { useIssues } from "~/queries/issues"; export default function UsersPage() { - const { users: issues } = useIssues(); + const issues = useIssues("users"); return ( <> diff --git a/web/src/context/app.jsx b/web/src/context/app.jsx index 121e07e5e8..a0bc2e0583 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 { IssuesProvider } from "./issues"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); @@ -39,9 +38,7 @@ function AppProviders({ children }) { return ( - - {children} - + {children} ); diff --git a/web/src/context/issues.jsx b/web/src/context/issues.jsx deleted file mode 100644 index 13a58ed9cb..0000000000 --- a/web/src/context/issues.jsx +++ /dev/null @@ -1,73 +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, { useContext, useEffect, useState } from "react"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "./installer"; -import { createIssuesList } from "~/client"; - -/** - * @typedef {import ("~/client").Issues} Issues list - */ - -const IssuesContext = React.createContext({}); - -function IssuesProvider({ children }) { - const [issues, setIssues] = useState(createIssuesList()); - const { cancellablePromise } = useCancellablePromise(); - const client = useInstallerClient(); - - useEffect(() => { - const loadIssues = async () => { - const issues = await cancellablePromise(client.issues()); - setIssues(issues); - }; - - if (client) { - loadIssues(); - } - }, [client, cancellablePromise, setIssues]); - - useEffect(() => { - if (!client) return; - - return client.onIssuesChange((updated) => { - setIssues({ ...issues, ...updated }); - }); - }, [client, issues, setIssues]); - - return {children}; -} - -/** - * @return {Issues} - */ -function useIssues() { - const context = useContext(IssuesContext); - - if (!context) { - throw new Error("useIssues must be used within an IssuesProvider"); - } - - return context; -} - -export { IssuesProvider, useIssues }; diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts new file mode 100644 index 0000000000..9692301995 --- /dev/null +++ b/web/src/queries/issues.ts @@ -0,0 +1,108 @@ +/* + * 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 { + useQueries, + useQuery, + useQueryClient, + useSuspenseQueries, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { Issue, IssuesList } from "~/types/issues"; + +type IssuesScope = "product" | "software" | "storage" | "users"; + +const URLS = { + product: "software/issues/product", + software: "software/issues/software", + users: "users/issues", + storage: "storage/issues", +}; + +const scopesFromPath = { + "/org/opensuse/Agama/Software1": "software", + "/org/opensuse/Agama/Software1/Product": "product", + "/org/opensuse/Agama/Storage1": "storage", + "/org/opensuse/Agama/Users1": "users", +}; + +const issuesQuery = (scope: IssuesScope) => { + return { + queryKey: ["issues", scope], + queryFn: () => fetch(`/api/${URLS[scope]}`).then((res) => res.json()), + }; +}; + +/** + * Returns the issues for the given scope. + * + * @param {IssuesScope} scope - Scope to get the issues from. + * @return {Issue[]} + */ +const useIssues = (scope: IssuesScope) => { + const { data } = useSuspenseQuery(issuesQuery(scope)); + return data; +}; + +const useAllIssues = () => { + const queries = [ + issuesQuery("product"), + issuesQuery("software"), + issuesQuery("storage"), + issuesQuery("users"), + ]; + + const [{ data: product }, { data: software }, { data: storage }, { data: users }] = + useSuspenseQueries({ queries }); + const list = { + product: product as Issue[], + software: software as Issue[], + storage: storage as Issue[], + users: users as Issue[], + }; + return new IssuesList(product, software, storage, users); +}; + +const useIssuesChanges = () => { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + if (event.type === "IssuesChanged") { + const path = event.path; + const scope = scopesFromPath[path]; + // TODO: use setQueryData because all the issues are included in the event + if (scope) { + queryClient.invalidateQueries({ queryKey: ["issues", scope] }); + } else { + console.warn(`Unknown scope ${path}`); + } + } + }); + }, [client, queryClient]); +}; + +export { useIssues, useAllIssues, useIssuesChanges }; diff --git a/web/src/types/issues.ts b/web/src/types/issues.ts new file mode 100644 index 0000000000..480f258510 --- /dev/null +++ b/web/src/types/issues.ts @@ -0,0 +1,83 @@ +/* + * 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. + */ + +/** + * Source of the issue + * + * Which is the origin of the issue (the system, the configuration or unknown). + */ +enum IssueSource { + /** Unknown source (it is kind of a fallback value) */ + Unknown = 0, + /** An unexpected situation in the system (e.g., missing device). */ + System = 1, + /** Wrong or incomplete configuration (e.g., an authentication mechanism is not set) */ + Config = 2, +} + +/** + * Issue severity + * + * It indicates how severe the problem is. + */ +enum IssueSeverity { + /** Just a warning, the installation can start */ + Warn = 0, + /** An important problem that makes the installation not possible */ + Error = 1, +} + +/** + * Pre-installation issue + */ +type Issue = { + /** Issue description */ + description: string; + /** Issue details. It is not mandatory. */ + details: string | undefined; + /** Where the issue comes from */ + source: IssueSource; + /** How severe is the issue */ + severity: IssueSeverity; +}; + +/** + * Issues list + */ +class IssuesList { + /** List of issues grouped by scope */ + issues: { [key: string]: Issue[] }; + /** Whether the list is empty */ + isEmpty: boolean; + + constructor(product: Issue[], software: Issue[], storage: Issue[], users: Issue[]) { + this.issues = { + product, + software, + storage, + users, + }; + this.isEmpty = !Object.values(this.issues).some((v) => v.length > 0); + } +} + +export { IssueSource, IssuesList, IssueSeverity }; +export type { Issue };