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 };