=> get("/api/software/conflicts");
+
/**
* Updates the software configuration
*
@@ -110,9 +117,15 @@ const register = ({ key, email }: { key: string; email?: string }) =>
const registerAddon = (addon: RegisteredAddonInfo) =>
post("/api/software/registration/addons/register", addon);
+/**
+ * Request for solving a conflict by applying given solution
+ */
+const solveConflict = (solution: ConflictSolution) => patch("/api/software/conflicts", [solution]);
+
export {
fetchAddons,
fetchConfig,
+ fetchConflicts,
fetchLicense,
fetchLicenses,
fetchPatterns,
@@ -124,5 +137,6 @@ export {
probe,
register,
registerAddon,
+ solveConflict,
updateConfig,
};
diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss
index c70db24dbf..57ad1574b5 100644
--- a/web/src/assets/styles/index.scss
+++ b/web/src/assets/styles/index.scss
@@ -355,6 +355,10 @@
.pf-v6-c-button {
--pf-v6-c-button--BorderRadius: var(--pf-t--global--border--radius--small);
+
+ &:disabled {
+ fill: currentcolor;
+ }
}
.pf-v6-c-menu-toggle.pf-m-split-button > :first-child {
diff --git a/web/src/components/core/IssuesAlert.test.tsx b/web/src/components/core/IssuesAlert.test.tsx
index 0e9337fff2..9df795e754 100644
--- a/web/src/components/core/IssuesAlert.test.tsx
+++ b/web/src/components/core/IssuesAlert.test.tsx
@@ -24,13 +24,30 @@ import React from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import { IssuesAlert } from "~/components/core";
+import { Issue, IssueSeverity, IssueSource } from "~/types/issues";
+import { SOFTWARE } from "~/routes/paths";
-it("renders a list of issues", () => {
- const issue = {
- description: "You need to create a user",
- source: "config",
- severity: "error",
- };
- plainRender();
- expect(screen.getByText(issue.description)).toBeInTheDocument();
+describe("IssueAlert", () => {
+ it("renders a list of issues", () => {
+ const issue: Issue = {
+ description: "A generic issue",
+ source: IssueSource.Config,
+ severity: IssueSeverity.Error,
+ kind: "generic",
+ };
+ plainRender();
+ expect(screen.getByText(issue.description)).toBeInTheDocument();
+ });
+
+ it("renders a link to conflict resolution when there is a 'solver' issue", () => {
+ const issue: Issue = {
+ description: "Conflicts found",
+ source: IssueSource.Config,
+ severity: IssueSeverity.Error,
+ kind: "solver",
+ };
+ plainRender();
+ const link = screen.getByRole("link", { name: "Review and fix" });
+ expect(link).toHaveAttribute("href", SOFTWARE.conflicts);
+ });
});
diff --git a/web/src/components/core/IssuesAlert.tsx b/web/src/components/core/IssuesAlert.tsx
index 35663eced9..18c635aefd 100644
--- a/web/src/components/core/IssuesAlert.tsx
+++ b/web/src/components/core/IssuesAlert.tsx
@@ -24,6 +24,8 @@ import React from "react";
import { Alert, List, ListItem } from "@patternfly/react-core";
import { _ } from "~/i18n";
import { Issue } from "~/types/issues";
+import Link from "./Link";
+import { PATHS } from "~/routes/software";
export default function IssuesAlert({ issues }) {
if (issues === undefined || issues.length === 0) return;
@@ -35,7 +37,17 @@ export default function IssuesAlert({ issues }) {
>
{issues.map((i: Issue, idx: number) => (
- {i.description}
+
+ {i.description}{" "}
+ {i.kind === "solver" && (
+
+ {
+ // TRANSLATORS: Clickable link to show and resolve package dependency conflicts
+ _("Review and fix")
+ }
+
+ )}
+
))}
diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx
index 6b8fe9adbb..9c6e5edf0d 100644
--- a/web/src/components/layout/Icon.tsx
+++ b/web/src/components/layout/Icon.tsx
@@ -28,6 +28,8 @@ import Apps from "@icons/apps.svg?component";
import AppRegistration from "@icons/app_registration.svg?component";
import Backspace from "@icons/backspace.svg?component";
import CheckCircle from "@icons/check_circle.svg?component";
+import ChevronLeft from "@icons/chevron_left.svg?component";
+import ChevronRight from "@icons/chevron_right.svg?component";
import Delete from "@icons/delete.svg?component";
import EditSquare from "@icons/edit_square.svg?component";
import Error from "@icons/error.svg?component";
@@ -50,6 +52,8 @@ import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component";
import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component";
import Translate from "@icons/translate.svg?component";
import SettingsEthernet from "@icons/settings_ethernet.svg?component";
+import UnfoldLess from "@icons/unfold_less.svg?component";
+import UnfoldMore from "@icons/unfold_more.svg?component";
import Warning from "@icons/warning.svg?component";
import Visibility from "@icons/visibility.svg?component";
import VisibilityOff from "@icons/visibility_off.svg?component";
@@ -61,6 +65,8 @@ const icons = {
app_registration: AppRegistration,
backspace: Backspace,
check_circle: CheckCircle,
+ chevron_left: ChevronLeft,
+ chevron_right: ChevronRight,
delete: Delete,
edit_square: EditSquare,
error: Error,
@@ -81,10 +87,12 @@ const icons = {
network_wifi: NetworkWifi,
network_wifi_1_bar: NetworkWifi1Bar,
network_wifi_3_bar: NetworkWifi3Bar,
+ settings_ethernet: SettingsEthernet,
translate: Translate,
+ unfold_less: UnfoldLess,
+ unfold_more: UnfoldMore,
visibility: Visibility,
visibility_off: VisibilityOff,
- settings_ethernet: SettingsEthernet,
warning: Warning,
wifi: Wifi,
wifi_off: WifiOff,
diff --git a/web/src/components/software/SoftwareConflicts.test.tsx b/web/src/components/software/SoftwareConflicts.test.tsx
new file mode 100644
index 0000000000..94f194741d
--- /dev/null
+++ b/web/src/components/software/SoftwareConflicts.test.tsx
@@ -0,0 +1,318 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen, within } from "@testing-library/react";
+import { installerRender } from "~/test-utils";
+import { Conflict } from "~/types/software";
+import SoftwareConflicts from "./SoftwareConflicts";
+
+const conflicts = [
+ {
+ id: 0,
+ description:
+ "the to be installed busybox-gawk-1.37.0-33.4.noarch conflicts with 'gawk' provided by the to be installed gawk-5.3.2-1.1.x86_64",
+ details: null,
+ solutions: [
+ {
+ id: 0,
+ description: "Following actions will be done:",
+ details:
+ "do not install gawk-5.3.2-1.1.x86_64\ndo not install kernel-default-6.14.4-1.1.x86_64\ndo not install pattern:selinux-20241218-9.1.x86_64",
+ },
+ {
+ id: 1,
+ description: "do not install busybox-gawk-1.37.0-33.4.noarch",
+ details: null,
+ },
+ ],
+ },
+ {
+ id: 1,
+ description:
+ "the to be installed tuned-2.25.1.0+git.889387b-3.1.noarch conflicts with 'tlp' provided by the to be installed tlp-1.8.0-1.1.noarch",
+ details: null,
+ solutions: [
+ {
+ id: 0,
+ description: "do not install tuned-2.25.1.0+git.889387b-3.1.noarch",
+ details: null,
+ },
+ {
+ id: 1,
+ description: "do not install tlp-1.8.0-1.1.noarch",
+ details: null,
+ },
+ ],
+ },
+ {
+ id: 2,
+ description:
+ "the to be installed pattern:microos_ra_verifier-5.0-98.1.x86_64 requires 'patterns-microos-ra_verifier', but this requirement cannot be provided",
+ details:
+ "not installable providers: patterns-microos-ra_verifier-5.0-98.1.x86_64[https-download.opensuse.org-6594e038]",
+ solutions: [
+ {
+ id: 0,
+ description: "do not install pattern:microos_ra_verifier-5.0-98.1.x86_64",
+ details: null,
+ },
+ {
+ id: 1,
+ description: "do not install pattern:microos_ra_agent-5.0-98.1.x86_64",
+ details: null,
+ },
+ {
+ id: 2,
+ description:
+ "break pattern:microos_ra_verifier-5.0-98.1.x86_64 by ignoring some of its dependencies",
+ details: null,
+ },
+ ],
+ },
+];
+
+let mockConflicts: Conflict[];
+const mockSolveConflict = jest.fn();
+
+jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
+ ProductRegistrationAlert Mock
+));
+
+jest.mock("~/queries/software", () => ({
+ ...jest.requireActual("~/queries/software"),
+ useConflicts: () => mockConflicts,
+ useConflictsMutation: () => ({ mutate: mockSolveConflict }),
+}));
+
+describe("SofwareConflicts", () => {
+ beforeEach(() => {
+ mockConflicts = [{ ...conflicts[0] }];
+ });
+
+ it("does not render the conflicts toolbar", () => {
+ installerRender();
+ expect(screen.queryByText(/Multiple conflicts found/)).toBeNull();
+ expect(screen.queryByText(/any order/)).toBeNull();
+ expect(screen.queryByText(/resolve others/)).toBeNull();
+ expect(screen.queryByText("1 of 3")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Skip to previous" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Skip to next" })).toBeNull();
+ });
+
+ it("allows applying the selected solution", async () => {
+ const { user } = installerRender();
+ const applyButton = screen.getByRole("button", { name: "Apply selected solution" });
+ const secondOption = screen.getByRole("radio", {
+ name: conflicts[0].solutions[1].description,
+ });
+ await user.click(secondOption);
+ await user.click(applyButton);
+ expect(mockSolveConflict).toHaveBeenCalledWith({ conflictId: 0, solutionId: 1 });
+ });
+
+ it("displays an error if no solution is selected before submission", async () => {
+ const { user } = installerRender();
+ const applyButton = screen.getByRole("button", { name: "Apply selected solution" });
+ const firstSolution = screen.getAllByRole("radio")[0];
+ await user.click(applyButton);
+ screen.getByText("Warning alert:");
+ screen.getByText("Select a solution to continue");
+ await user.click(firstSolution);
+ await user.click(applyButton);
+ expect(screen.queryByText("Warning alert:")).toBeNull();
+ expect(screen.queryByText("Select a solution to continue")).toBeNull();
+ });
+
+ describe("when a conflict solution has details", () => {
+ beforeEach(() => {
+ mockConflicts = [
+ {
+ id: 0,
+ description: "Fake conflict",
+ details: null,
+ solutions: [
+ {
+ id: 0,
+ description: `Fake solution with details`,
+ details: "Action 1\nAction 2",
+ },
+ ],
+ },
+ ];
+ });
+
+ it("renders details in a list, splitting by newline", () => {
+ installerRender();
+ const details = screen.getByRole("list");
+ within(details).getByText("Action 1");
+ within(details).getByText("Action 2");
+ });
+
+ describe("and the number of details is within the visible limit", () => {
+ it("does not render a toggle to show/hide more", () => {
+ installerRender();
+ expect(screen.queryByRole("button", { name: /^Show.*actions$"/ })).toBeNull();
+ });
+ });
+
+ describe("but the number of details exceeds the visible limit", () => {
+ beforeEach(() => {
+ mockConflicts = [
+ {
+ id: 0,
+ description: "Fake conflict",
+ details: null,
+ solutions: [
+ {
+ id: 0,
+ description: `Fake solution with details`,
+ details: "Action 1\nAction 2\nAction 3\nAction 4",
+ },
+ ],
+ },
+ ];
+ });
+
+ it("renders a toggle to show/hide all actions", async () => {
+ const { user } = installerRender();
+ const actionsToggle = screen.getByRole("button", { name: /^Show.*actions$/ });
+ const details = screen.getByRole("list");
+ within(details).getByText("Action 1");
+ within(details).getByText("Action 2");
+ within(details).getByText("Action 3");
+ expect(within(details).queryByText("Action 4")).toBeNull();
+ await user.click(actionsToggle);
+ within(details).getByText("Action 4");
+ expect(actionsToggle).toHaveTextContent("Show less actions");
+ await user.click(actionsToggle);
+ expect(within(details).queryByText("Action 4")).toBeNull();
+ });
+ });
+ });
+
+ describe("when there is more than one conflict", () => {
+ beforeEach(() => {
+ mockConflicts = conflicts;
+ });
+
+ it("renders the conflicts toolbar with information and links", () => {
+ installerRender();
+ screen.getByText(/Multiple conflicts found/);
+ screen.getByText(/any order/);
+ screen.getByText(/resolve others/);
+ screen.getByText("1 of 3");
+ screen.getByRole("button", { name: "Skip to previous" });
+ screen.getByRole("button", { name: "Skip to next" });
+ });
+
+ it("allows navigating between conflicts without exceeding bounds", async () => {
+ const { user } = installerRender();
+ screen.getByText("1 of 3");
+ const skipToPrevious = screen.getByRole("button", { name: "Skip to previous" });
+ const skipToNext = screen.getByRole("button", { name: "Skip to next" });
+
+ expect(skipToPrevious).toBeDisabled();
+ expect(skipToNext).not.toBeDisabled();
+
+ await user.click(skipToPrevious);
+ screen.getByText("1 of 3");
+ screen.getByText(conflicts[0].description);
+ await user.click(skipToNext);
+ expect(skipToPrevious).not.toBeDisabled();
+ expect(skipToNext).not.toBeDisabled();
+ screen.getByText("2 of 3");
+ expect(screen.queryByText(conflicts[0].description)).toBeNull();
+ screen.getByText(conflicts[1].description);
+ await user.click(skipToNext);
+ screen.getByText("3 of 3");
+ expect(screen.queryByText(conflicts[1].description)).toBeNull();
+ screen.getByText(conflicts[2].description);
+ expect(skipToPrevious).not.toBeDisabled();
+ expect(skipToNext).toBeDisabled();
+ await user.click(skipToNext);
+ screen.getByText("3 of 3");
+ });
+
+ it("does not preserve the selected option after navigating", async () => {
+ const { user } = installerRender();
+ screen.getByText("1 of 3");
+ const skipToPrevious = screen.getByRole("button", { name: "Skip to previous" });
+ const skipToNext = screen.getByRole("button", { name: "Skip to next" });
+
+ screen.getByText("1 of 3");
+ screen.getByText(conflicts[0].description);
+ let options = screen.getAllByRole("radio", { checked: false });
+ expect(options.length).toBe(conflicts[0].solutions.length);
+
+ await user.click(options[0]);
+ expect(options[0]).toBeChecked();
+
+ await user.click(skipToNext);
+ screen.getByText("2 of 3");
+ screen.getByText(conflicts[1].description);
+ options = screen.getAllByRole("radio", { checked: false });
+ expect(options.length).toBe(conflicts[1].solutions.length);
+ expect(options[0]).not.toBeChecked();
+
+ await user.click(options[0]);
+ expect(options[0]).toBeChecked();
+
+ await user.click(skipToPrevious);
+ options = screen.getAllByRole("radio", { checked: false });
+ expect(options.length).toBe(conflicts[0].solutions.length);
+ expect(options[0]).not.toBeChecked();
+ });
+
+ it("allows applying the selected solution for the current conflict", async () => {
+ const { user } = installerRender();
+ const skipToNext = screen.getByRole("button", { name: "Skip to next" });
+
+ await user.click(skipToNext);
+ const applyButton = screen.getByRole("button", { name: "Apply selected solution" });
+ const secondOption = screen.getByRole("radio", {
+ name: conflicts[1].solutions[1].description,
+ });
+ await user.click(secondOption);
+ await user.click(applyButton);
+ expect(mockSolveConflict).toHaveBeenCalledWith({ conflictId: 1, solutionId: 1 });
+ });
+ });
+
+ describe("when there are no conflicts", () => {
+ beforeEach(() => {
+ mockConflicts = [];
+ });
+
+ it("does not render the solution selection form", () => {
+ installerRender();
+ expect(screen.queryAllByRole("radio").length).toBe(0);
+ expect(screen.queryByRole("button", { name: "Apply selected solution" })).toBeNull();
+ });
+
+ it("renders a message indicating there are no conflicts to address", () => {
+ installerRender();
+ screen.queryByRole("heading", { name: "No conflicts to address" });
+ screen.getByText(/All conflicts have been resolved, or none were detected/);
+ });
+ });
+});
diff --git a/web/src/components/software/SoftwareConflicts.tsx b/web/src/components/software/SoftwareConflicts.tsx
new file mode 100644
index 0000000000..e076129958
--- /dev/null
+++ b/web/src/components/software/SoftwareConflicts.tsx
@@ -0,0 +1,300 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React, { useState } from "react";
+import {
+ ActionGroup,
+ Alert,
+ Button,
+ ButtonProps,
+ Content,
+ Divider,
+ Flex,
+ Form,
+ FormGroup,
+ List,
+ ListItem,
+ Radio,
+ RadioProps,
+ Title,
+ Toolbar,
+ ToolbarContent,
+ ToolbarGroup,
+ ToolbarItem,
+} from "@patternfly/react-core";
+import { Icon } from "~/components/layout";
+import { Page, SubtleContent } from "~/components/core";
+import { ConflictSolutionOption } from "~/types/software";
+import { useConflicts, useConflictsChanges, useConflictsMutation } from "~/queries/software";
+import { isEmpty } from "~/utils";
+import { sprintf } from "sprintf-js";
+import { _ } from "~/i18n";
+
+/**
+ * Renders a list of conflict details as a bullet list.
+ * Used to display all actions associated with a conflict solution.
+ *
+ * @param props - Component props.
+ * @param props.items - An array of strings representing the detail items to display.
+ */
+const DetailsList = ({ items }: { items: string[] }) => (
+
+ {items.map((d, i) => (
+
+ {d}
+
+ ))}
+
+);
+
+type ConflictSolutionRadioProps = {
+ /** Newline-separated string of solution actions */
+ details?: ConflictSolutionOption["details"];
+ /** Max number of visible detail lines before enabling toggle behavior */
+ maxVisibleDetails?: number;
+} & Omit;
+
+/**
+ * A custom wrapper around PatternFly's Radio component for presenting a
+ * conflict solution option. Optionally displays additional details or actions
+ * with an expandable/collapsible list.
+ *
+ * Behavior:
+ * - If no details are provided, a plain radio button is rendered.
+ * - If a small number of details exist, they are shown directly.
+ * - If there are more than `maxVisibleDetails` (default 3), the list is
+ * collapsible.
+ */
+const ConflictSolutionRadio = ({
+ details: rawDetails,
+ maxVisibleDetails = 3,
+ ...props
+}: ConflictSolutionRadioProps) => {
+ const [expanded, setExpanded] = useState(false);
+ const details = rawDetails ? rawDetails?.split("\n") : [];
+
+ if (details.length === 0) return ;
+ if (details.length <= maxVisibleDetails)
+ return } />;
+
+ const visibleDetails = expanded ? details : details.slice(0, maxVisibleDetails);
+ const toggleText = expanded ? _("Show less actions") : _("Show more actions");
+ const toggleIcon = expanded ? "unfold_less" : "unfold_more";
+ const toggleVisibility = () => setExpanded(!expanded);
+
+ return (
+
+
+
+
+ }
+ {...props}
+ />
+ );
+};
+
+/**
+ * Internal component responsible of rendering the form to allow users choose
+ * and eventually apply a solution for given conflict
+ */
+const ConflictsForm = ({ conflict }): React.ReactNode => {
+ const { mutate: solve } = useConflictsMutation();
+ const [error, setError] = useState(null);
+ const [chosenSolution, setChosenSolution] = useState();
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!isEmpty(chosenSolution)) {
+ setError(null);
+ solve({ conflictId: conflict.id, solutionId: chosenSolution });
+ } else {
+ setError(_("Select a solution to continue"));
+ }
+ };
+
+ return (
+
+ );
+};
+
+type ConflictsToolbarProps = {
+ current: number;
+ total: number;
+ onNext: ButtonProps["onClick"];
+ onBack: ButtonProps["onClick"];
+};
+const ConflictsToolbar = ({
+ current,
+ total,
+ onNext,
+ onBack,
+}: ConflictsToolbarProps): React.ReactNode => (
+
+
+
+
+ {_(
+ "Multiple conflicts found. You can address them in any order, and resolving one may resolve others.",
+ )}
+
+
+
+
+
+
+
+
+ {
+ // TRANSLATORS: This is a short status message like "1 of 3". It
+ // indicates the position of the current item out of a total. The
+ // first %d will be replaced with the current item number, the
+ // second %d with the total number of items.
+ sprintf(_("%d of %d"), current, total)
+ }
+
+
+
+
+
+
+
+);
+
+/**
+ * Displays content when there are no conflicts to resolve.
+ * Typically shown when user lands on this page with no actionable items.
+ */
+const NoConflictsContent = () => (
+ <>
+ {_("No conflicts to address")}
+
+ {_(
+ "All conflicts have been resolved, or none were detected. You can safely continue with your setup.",
+ )}
+
+ >
+);
+
+/**
+ * Main content component to display and navigate between multiple conflicts.
+ *
+ * Renders a toolbar (if more than one conflict), and the conflict resolution form.
+ *
+ * It uses a `key` prop for forcing an state reset when navigating back and
+ * forward.
+ *
+ * See https://react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key
+ */
+const ConflictsContent = ({ conflicts }) => {
+ const [currentConflictIndex, setCurrentConflictIndex] = useState(0);
+ const totalConflicts = conflicts.length;
+ const lastConflictIndex = totalConflicts - 1;
+
+ const onNext = async () => {
+ currentConflictIndex < lastConflictIndex && setCurrentConflictIndex(currentConflictIndex + 1);
+ };
+ const onBack = async () => {
+ currentConflictIndex > 0 && setCurrentConflictIndex(currentConflictIndex - 1);
+ };
+
+ const currentConflict = conflicts[currentConflictIndex];
+
+ return (
+ <>
+ {conflicts.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+};
+
+/**
+ * Top-level page for handling software conflicts resolution.
+ *
+ * Displays either a resolution form or a message when no conflicts are present.
+ */
+function SoftwareConflicts(): React.ReactNode {
+ useConflictsChanges();
+ const conflicts = useConflicts();
+
+ return (
+
+
+ {_("Software conflicts resolution")}
+
+
+
+ {conflicts.length > 0 ? : }
+
+
+
+ {_("Close")}
+
+
+ );
+}
+
+export default SoftwareConflicts;
diff --git a/web/src/queries/software.ts b/web/src/queries/software.ts
index d4a5410e64..837fb73275 100644
--- a/web/src/queries/software.ts
+++ b/web/src/queries/software.ts
@@ -32,6 +32,7 @@ import {
import { useInstallerClient } from "~/context/installer";
import {
AddonInfo,
+ Conflict,
License,
Pattern,
PatternsSelection,
@@ -46,6 +47,7 @@ import {
import {
fetchAddons,
fetchConfig,
+ fetchConflicts,
fetchLicenses,
fetchPatterns,
fetchProducts,
@@ -56,6 +58,7 @@ import {
probe,
register,
registerAddon,
+ solveConflict,
updateConfig,
} from "~/api/software";
import { QueryHookOptions } from "~/types/queries";
@@ -143,6 +146,14 @@ const repositoriesQuery = () => ({
queryFn: fetchRepositories,
});
+/**
+ * Query to retrieve conflicts
+ */
+const conflictsQuery = () => ({
+ queryKey: ["software", "conflicts"],
+ queryFn: fetchConflicts,
+});
+
/**
* Hook that builds a mutation to update the software configuration
*
@@ -323,6 +334,29 @@ const useRepositories = (): Repository[] => {
return repositories;
};
+/**
+ * Returns conclifts info
+ */
+const useConflicts = (): Conflict[] => {
+ const { data: conflicts } = useSuspenseQuery(conflictsQuery());
+ return conflicts;
+};
+
+/**
+ * Hook that builds a mutation for solving a conflict
+ */
+const useConflictsMutation = () => {
+ const queryClient = useQueryClient();
+
+ const query = {
+ mutationFn: solveConflict,
+ onSuccess: async () => {
+ queryClient.invalidateQueries({ queryKey: conflictsQuery().queryKey });
+ },
+ };
+ return useMutation(query);
+};
+
/**
* Hook that returns a useEffect to listen for software proposal events
*
@@ -367,12 +401,34 @@ const useProposalChanges = () => {
}, [client, queryClient]);
};
+/**
+ * Hook that registers a useEffect to listen for conflicts changes
+ *
+ */
+const useConflictsChanges = () => {
+ const client = useInstallerClient();
+ const queryClient = useQueryClient();
+ React.useEffect(() => {
+ if (!client) return;
+
+ return client.onEvent((event) => {
+ if (event.type === "ConflictsChanged") {
+ const { conflicts } = event;
+ queryClient.setQueryData([conflictsQuery().queryKey], conflicts);
+ }
+ });
+ });
+};
+
export {
configQuery,
productsQuery,
selectedProductQuery,
useAddons,
useConfigMutation,
+ useConflicts,
+ useConflictsMutation,
+ useConflictsChanges,
useLicenses,
usePatterns,
useProduct,
diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts
index ed44abd311..1c135c13fc 100644
--- a/web/src/routes/paths.ts
+++ b/web/src/routes/paths.ts
@@ -68,6 +68,7 @@ const USER = {
const SOFTWARE = {
root: "/software",
patternsSelection: "/software/patterns/select",
+ conflicts: "/software/conflicts",
};
const STORAGE = {
diff --git a/web/src/routes/software.tsx b/web/src/routes/software.tsx
index 2795a0c38f..e35800bce5 100644
--- a/web/src/routes/software.tsx
+++ b/web/src/routes/software.tsx
@@ -26,6 +26,7 @@ import SoftwarePatternsSelection from "~/components/software/SoftwarePatternsSel
import { Route } from "~/types/routes";
import { SOFTWARE as PATHS } from "~/routes/paths";
import { N_ } from "~/i18n";
+import SoftwareConflicts from "~/components/software/SoftwareConflicts";
const routes = (): Route => ({
path: PATHS.root,
@@ -42,6 +43,10 @@ const routes = (): Route => ({
path: PATHS.patternsSelection,
element: ,
},
+ {
+ path: PATHS.conflicts,
+ element: ,
+ },
],
});
diff --git a/web/src/types/issues.ts b/web/src/types/issues.ts
index 891780c392..827ad256bc 100644
--- a/web/src/types/issues.ts
+++ b/web/src/types/issues.ts
@@ -59,8 +59,8 @@ type Issue = {
description: string;
/** Issue kind **/
kind: string;
- /** Issue details. It is not mandatory. */
- details: string | undefined;
+ /** Issue details */
+ details?: string;
/** Where the issue comes from */
source: IssueSource;
/** How severe is the issue */
diff --git a/web/src/types/software.ts b/web/src/types/software.ts
index 2cf757fc63..8d33e05628 100644
--- a/web/src/types/software.ts
+++ b/web/src/types/software.ts
@@ -129,9 +129,30 @@ type RegisteredAddonInfo = {
registrationCode: string;
};
+type ConflictSolutionOption = {
+ id: number;
+ description: string;
+ details: string | null;
+};
+
+type Conflict = {
+ id: number;
+ description: string;
+ details: string | null;
+ solutions: ConflictSolutionOption[];
+};
+
+type ConflictSolution = {
+ conflictId: Conflict["id"];
+ solutionId: ConflictSolutionOption["id"];
+};
+
export { SelectedBy };
export type {
AddonInfo,
+ Conflict,
+ ConflictSolution,
+ ConflictSolutionOption,
License,
LicenseContent,
Pattern,