Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package agama-web-ui for openSUSE:Factory checked in at 2026-09-22 15:48:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/agama-web-ui (Old) and /work/SRC/openSUSE:Factory/.agama-web-ui.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "agama-web-ui" Tue Sep 22 15:48:40 2026 rev:53 rq:1379493 version:0 Changes: -------- --- /work/SRC/openSUSE:Factory/agama-web-ui/agama-web-ui.changes 2026-09-04 12:36:18.738949599 +0200 +++ /work/SRC/openSUSE:Factory/.agama-web-ui.new.383539/agama-web-ui.changes 2026-09-22 15:48:50.255291327 +0200 @@ -1,0 +2,29 @@ +Fri Sep 18 14:30:33 UTC 2026 - David Diaz <[email protected]> + +- Ask to accept each license of a product requiring several + (jsc#PED-16988, gh#agama-project/agama#3960). + +------------------------------------------------------------------- +Fri Sep 18 14:14:23 UTC 2026 - José Iván López González <[email protected]> + +- Fix progress tracking (related to bsc#1280751). + +------------------------------------------------------------------- +Thu Sep 17 16:00:00 UTC 2026 - David Diaz <[email protected]> + +- Show the name of the package being installed in one piece and stop + the progress text from moving as packages go by (bsc#1279489). + +------------------------------------------------------------------- +Tue Sep 15 16:11:54 UTC 2026 - José Iván López González <[email protected]> + +- Fix formatting of the storage commit errors + (gh#agama-project/agama#3912). + +------------------------------------------------------------------- +Mon Sep 14 12:51:32 UTC 2026 - David Diaz <[email protected]> + +- Remove empty space due to a byte order mark which kills + PatternFly's base reset (bsc#1280220). + +------------------------------------------------------------------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ _service ++++++ --- /var/tmp/diff_new_pack.okt1Fk/_old 2026-09-22 15:49:04.326875183 +0200 +++ /var/tmp/diff_new_pack.okt1Fk/_new 2026-09-22 15:49:04.332875432 +0200 @@ -8,7 +8,7 @@ <param name="scm">git</param> <!-- the revision might be changed to "release" branch or a git tag by the .github/workflows/obs-staging-shared.yml action when submitting to OBS --> - <param name="revision">master</param> + <param name="revision">SLE-16.1</param> <param name="subdir">web</param> <param name="without-version">enable</param> <param name="extract">package-lock.json</param> ++++++ agama.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/package/agama-web-ui.changes new/agama/package/agama-web-ui.changes --- old/agama/package/agama-web-ui.changes 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/package/agama-web-ui.changes 2026-09-21 16:43:04.000000000 +0200 @@ -1,4 +1,33 @@ ------------------------------------------------------------------- +Fri Sep 18 14:30:33 UTC 2026 - David Diaz <[email protected]> + +- Ask to accept each license of a product requiring several + (jsc#PED-16988, gh#agama-project/agama#3960). + +------------------------------------------------------------------- +Fri Sep 18 14:14:23 UTC 2026 - José Iván López González <[email protected]> + +- Fix progress tracking (related to bsc#1280751). + +------------------------------------------------------------------- +Thu Sep 17 16:00:00 UTC 2026 - David Diaz <[email protected]> + +- Show the name of the package being installed in one piece and stop + the progress text from moving as packages go by (bsc#1279489). + +------------------------------------------------------------------- +Tue Sep 15 16:11:54 UTC 2026 - José Iván López González <[email protected]> + +- Fix formatting of the storage commit errors + (gh#agama-project/agama#3912). + +------------------------------------------------------------------- +Mon Sep 14 12:51:32 UTC 2026 - David Diaz <[email protected]> + +- Remove empty space due to a byte order mark which kills + PatternFly's base reset (bsc#1280220). + +------------------------------------------------------------------- Tue Sep 1 15:51:08 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> - Version 24 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/api.ts new/agama/src/api.ts --- old/agama/src/api.ts 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/api.ts 2026-09-21 16:43:04.000000000 +0200 @@ -43,8 +43,7 @@ const getSystem = (): Promise<System | null> => get("/api/system"); -const getLicense = (id: string, lang: string = "en"): Promise<LicenseContent> => - get(`/api/licenses/${id}?lang=${lang}`); +const getLicense = (id: string): Promise<LicenseContent> => get(`/api/licenses/${id}`); const getProposal = (): Promise<Proposal | null> => get("/api/proposal"); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/assets/styles/components/_agama-custom.scss new/agama/src/assets/styles/components/_agama-custom.scss --- old/agama/src/assets/styles/components/_agama-custom.scss 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/assets/styles/components/_agama-custom.scss 2026-09-21 16:43:04.000000000 +0200 @@ -219,6 +219,15 @@ } } +// Opt-in for content that must keep its size while it changes, so it does not +// move the rest of the screen around (see the stretchFirstRowEnd prop). It is +// aligned to the start, having no box of its own to be centered in. It comes +// after the rules above on purpose, to win over the one it shares weight with. +.agm-split-info-layout--stretch-first-row-end > .pf-v6-l-grid__item[style*="Order-on-md: 4"] { + justify-self: stretch; + text-align: start; +} + // Terminal panel docked below the application as a resizable split. The panel // lives above the page outlet, so its session survives navigation; here it only // shares the screen height with the application and provides the resize @@ -321,3 +330,20 @@ white-space: pre; overflow: auto; } + +// PatternFly's Truncate puts the start of the text and its final characters +// side by side. Given more room than the text needs, it pushes them to +// opposite ends, so a shortened name reads as a broken one. Packed together, +// it stays one name. +.pf-v6-c-truncate { + justify-content: start; +} + +// What is being installed right now, under the current step. The name changes +// several times a second and the line it sits on takes the room the screen +// gives it, so nothing moves as one package follows another and a name too +// long for the line is shortened instead of spilling out. The maximum keeps +// it short enough to stay comfortable to read on a wide screen. +.agm-progress-detail { + max-inline-size: 80ch; +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/assets/styles/utilities/_index.scss new/agama/src/assets/styles/utilities/_index.scss --- old/agama/src/assets/styles/utilities/_index.scss 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/assets/styles/utilities/_index.scss 2026-09-21 16:43:04.000000000 +0200 @@ -8,10 +8,16 @@ } // Some utilities not found at PF + .w-14ch { inline-size: 14ch; } +.mh-60dvh { + max-height: 60dvh; + overflow: auto; +} + // Balance multi-line text so wrapped lines are more even. The base reset // already applies this to bare h1-h4; this utility covers other elements // (paragraphs, helper text, PF Title/Text/Content components). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/core/InstallationProgress.tsx new/agama/src/components/core/InstallationProgress.tsx --- old/agama/src/components/core/InstallationProgress.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/core/InstallationProgress.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -36,6 +36,7 @@ <Page noDefaultProgressMonitor> <Page.Content> <SplitInfoLayout + stretchFirstRowEnd icon="deployed_code_update" firstRowStart={ <> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/core/ProgressReport.tsx new/agama/src/components/core/ProgressReport.tsx --- old/agama/src/components/core/ProgressReport.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/core/ProgressReport.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -61,7 +61,11 @@ if (detail && detail.step !== "") { const { step: message, index, size } = detail; properties.description = ( - <Flex direction={{ default: "column" }} rowGap={{ default: "rowGapXs" }}> + <Flex + direction={{ default: "column" }} + rowGap={{ default: "rowGapXs" }} + className="agm-progress-detail" + > <Truncate content={message} trailingNumChars={12} position="middle" /> <Text component="small">{sprintf(_("Step %1$d of %2$d"), index, size)}</Text> </Flex> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/layout/SplitInfoLayout.tsx new/agama/src/components/layout/SplitInfoLayout.tsx --- old/agama/src/components/layout/SplitInfoLayout.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/layout/SplitInfoLayout.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -37,6 +37,16 @@ * Content for right side of second row (typically secondary action button) */ secondRowEnd?: React.ReactNode; + + /** + * Whether `firstRowEnd` takes all the room it is given instead of being + * sized around itself. + * + * Turn it on when that content changes while the screen is shown and its + * size must not follow, so nothing moves as it changes. The content is then + * aligned to the start, having no box of its own to be centered in. + */ + stretchFirstRowEnd?: boolean; }; /** @@ -82,6 +92,9 @@ * NOTE: The prop names "Row" and "Start/End" refer to the viewport over "md" * breakpoint layout, not the small viewport layout. On small viewports, all * content stacks vertically regardless of the "start/end" naming. + * + * Each piece of content is sized around itself, unless `stretchFirstRowEnd` + * is set. */ export default function SplitInfoLayout({ icon, @@ -90,10 +103,18 @@ firstRowEnd, secondRowStart, secondRowEnd, + stretchFirstRowEnd = false, }: SplitInfoLayoutProps) { + const className = [ + "agm-split-info-layout", + stretchFirstRowEnd && "agm-split-info-layout--stretch-first-row-end", + ] + .filter(Boolean) + .join(" "); + return ( <Grid className="agm-split-info-layout-container"> - <Grid hasGutter className="agm-split-info-layout"> + <Grid hasGutter className={className}> {icon && ( <> <GridItem span={12} md={6} order={{ md: "1" }}> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/product/LicenseDialog.test.tsx new/agama/src/components/product/LicenseDialog.test.tsx --- old/agama/src/components/product/LicenseDialog.test.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/product/LicenseDialog.test.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -24,24 +24,15 @@ import { screen, waitFor } from "@testing-library/react"; import { installerRender, mockL10n, loadTranslations } from "~/test-utils"; import { useSystem } from "~/hooks/model/system"; -import { Product } from "~/model/system"; +import { License } from "~/model/system"; import * as api from "~/api"; import { Locale, Keymap } from "~/model/system/l10n"; import LicenseDialog from "./LicenseDialog"; -const sle: Product = { - id: "SLE", - name: "SUSE Linux Enterprise", - modes: [], - icon: "sle.svg", - description: "SLE description", - registration: true, - license: "license.sle", -}; +const license: License = { id: "license.sle", name: "SUSE Linux Enterprise License" }; const mockUILanguage = "de-DE"; let mockLicenseLanguage = "de-DE"; -const product: Product = sle; const onCloseFn = jest.fn(); let mockGetLicense: jest.SpyInstance; @@ -85,10 +76,20 @@ ); }); - it("loads given product license in the interface language", async () => { - installerRender(<LicenseDialog product={product} onClose={onCloseFn} />); + it("uses the license name as title", async () => { + installerRender(<LicenseDialog license={license} onClose={onCloseFn} />); + await screen.findByRole("dialog", { name: license.name }); + }); + + it("uses the given title instead of the license name", async () => { + installerRender(<LicenseDialog license={license} title="SLES" onClose={onCloseFn} />); + await screen.findByRole("dialog", { name: "SLES" }); + }); + + it("loads the given license", async () => { + installerRender(<LicenseDialog license={license} onClose={onCloseFn} />); await waitFor(() => { - expect(mockGetLicense).toHaveBeenCalledWith(sle.license, mockUILanguage); + expect(mockGetLicense).toHaveBeenCalledWith(license.id); screen.getByText("El contenido de la licencia"); }); }); @@ -99,9 +100,9 @@ }); it("it warns the user that the license is not translated", async () => { - installerRender(<LicenseDialog product={product} onClose={onCloseFn} />); + installerRender(<LicenseDialog license={license} onClose={onCloseFn} />); await waitFor(() => { - expect(mockGetLicense).toHaveBeenCalledWith(sle.license, mockUILanguage); + expect(mockGetLicense).toHaveBeenCalledWith(license.id); screen.getByText("El contenido de la licencia"); screen.getByText("Diese Lizenz ist in Deutsch nicht verfügbar."); }); @@ -109,7 +110,7 @@ }); it("triggers given callback on Close click", async () => { - const { user } = installerRender(<LicenseDialog product={product} onClose={onCloseFn} />); + const { user } = installerRender(<LicenseDialog license={license} onClose={onCloseFn} />); const closeButton = await screen.findByRole("button", { name: "Schließen" }); await user.click(closeButton); expect(onCloseFn).toHaveBeenCalled(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/product/LicenseDialog.tsx new/agama/src/components/product/LicenseDialog.tsx --- old/agama/src/components/product/LicenseDialog.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/product/LicenseDialog.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -23,7 +23,7 @@ import React, { useEffect, useState } from "react"; import { Alert, ModalProps, Stack } from "@patternfly/react-core"; import { Popup } from "~/components/core"; -import { Product } from "~/model/system"; +import type { License } from "~/model/system"; import { getLicense } from "~/api"; import { useInstallerL10n } from "~/context/installerL10n"; import { sprintf } from "sprintf-js"; @@ -48,27 +48,34 @@ return lang1 === lang2; }; -function LicenseDialog({ onClose, product }: { onClose: ModalProps["onClose"]; product: Product }) { +type LicenseDialogProps = { + /** The license to display */ + license: License; + /** The dialog title, the license name by default */ + title?: string; + /** Callback fired when the dialog is closed */ + onClose: ModalProps["onClose"]; +}; + +function LicenseDialog({ license, title = license.name, onClose }: LicenseDialogProps) { const { language: uiLanguage } = useInstallerL10n(); - const [language] = useState<string>(uiLanguage); const [licenseLanguage, setLicenseLanguage] = useState<string | null>(undefined); - const [license, setLicense] = useState<string>(); + const [licenseContent, setLicenseContent] = useState<string>(); useEffect(() => { - language && - getLicense(product.license, language).then(({ body, language: foundLanguage }) => { - setLicense(body); - setLicenseLanguage(foundLanguage); - }); - }, [language, product.license]); + getLicense(license.id).then(({ body, language }) => { + setLicenseContent(body); + setLicenseLanguage(language); + }); + }, [license.id]); return ( - <Popup isOpen title={product.name} width="auto"> + <Popup isOpen title={title} width="auto"> <Stack hasGutter> {licenseLanguage && !languagesMatches(uiLanguage, licenseLanguage) && ( <MissingTranslation missing={uiLanguage} /> )} - <pre>{license}</pre> + <pre>{licenseContent}</pre> </Stack> <Popup.Actions> <Popup.Confirm onClick={onClose}>{_("Close")}</Popup.Confirm> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/product/ProductSelectionPage.test.tsx new/agama/src/components/product/ProductSelectionPage.test.tsx --- old/agama/src/components/product/ProductSelectionPage.test.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/product/ProductSelectionPage.test.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -34,7 +34,7 @@ import { useSystem as useSystemSoftware } from "~/hooks/model/system/software"; import { ROOT } from "~/routes/paths"; import ProductSelectionPage from "./ProductSelectionPage"; -import { Product } from "~/model/system"; +import { License, Product } from "~/model/system"; const tumbleweed: Product = { id: "Tumbleweed", @@ -42,6 +42,7 @@ icon: "tumbleweed.svg", description: "Tumbleweed description...", registration: false, + licenses: [], modes: [], }; @@ -51,10 +52,25 @@ icon: "microos.svg", description: "MicroOS description", registration: false, - license: "fake.license", + licenses: ["fake.license"], modes: [], }; +const serverForApps: Product = { + id: "ServerForApps", + name: "Server for Apps", + icon: "server.svg", + description: "Server with application licenses", + registration: false, + licenses: ["license.server", "license.apps"], + modes: [], +}; + +const licenses: License[] = [ + { id: "license.server", name: "Server License" }, + { id: "license.apps", name: "Apps License" }, +]; + const productWithModes: Product = { id: "SLES", name: "SUSE Linux Enterprise Server", @@ -77,7 +93,9 @@ <div>InstallerL10nOptions Mock</div> )); -jest.mock("~/components/product/LicenseDialog", () => () => <div>LicenseDialog Mock</div>); +jest.mock("~/components/product/LicenseDialog", () => ({ title }) => ( + <div>LicenseDialog Mock: {title}</div> +)); jest.mock("~/api", () => ({ ...jest.requireActual("~/api"), @@ -199,6 +217,78 @@ expect(licenseCheckbox).not.toBeChecked(); }); + describe("when the selected product requires several licenses", () => { + beforeEach(() => { + mockProduct(undefined); + mockSystem({ products: [microOs, serverForApps], licenses }); + }); + + it("renders one checkbox per license, named after it", async () => { + const { user } = installerRender(<ProductSelectionPage />); + await user.click(screen.getByRole("radio", { name: serverForApps.name })); + screen.getByRole("checkbox", { name: "I have read and accept the Server License" }); + screen.getByRole("checkbox", { name: "I have read and accept the Apps License" }); + }); + + it("uses the product text for a license the system does not know", async () => { + mockSystem({ + products: [{ ...serverForApps, licenses: ["license.server", "license.unknown"] }], + licenses, + }); + const { user } = installerRender(<ProductSelectionPage />); + await user.click(screen.getByRole("radio", { name: serverForApps.name })); + screen.getByRole("checkbox", { name: "I have read and accept the Server License" }); + screen.getByRole("checkbox", { + name: `I have read and accept the license for ${serverForApps.name}`, + }); + }); + + it("allows the selection only when all the licenses are accepted", async () => { + const { user } = installerRender(<ProductSelectionPage />); + const selectButton = screen.getByRole("button", { name: "Select" }); + await user.click(screen.getByRole("radio", { name: serverForApps.name })); + const serverCheckbox = screen.getByRole("checkbox", { + name: "I have read and accept the Server License", + }); + const appsCheckbox = screen.getByRole("checkbox", { + name: "I have read and accept the Apps License", + }); + + await user.click(serverCheckbox); + expect(serverCheckbox).toBeChecked(); + expect(appsCheckbox).not.toBeChecked(); + expect(selectButton).toBeDisabled(); + screen.getByText("License acceptance is required to continue."); + + await user.click(appsCheckbox); + expect(selectButton).not.toBeDisabled(); + + await user.click(serverCheckbox); + expect(selectButton).toBeDisabled(); + }); + + it("resets the acceptance of all the licenses when switching products", async () => { + const { user } = installerRender(<ProductSelectionPage />); + await user.click(screen.getByRole("radio", { name: serverForApps.name })); + await user.click( + screen.getByRole("checkbox", { name: "I have read and accept the Server License" }), + ); + await user.click( + screen.getByRole("checkbox", { name: "I have read and accept the Apps License" }), + ); + + await user.click(screen.getByRole("radio", { name: microOs.name })); + await user.click(screen.getByRole("radio", { name: serverForApps.name })); + + expect( + screen.getByRole("checkbox", { name: "I have read and accept the Server License" }), + ).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "I have read and accept the Apps License" }), + ).not.toBeChecked(); + }); + }); + it("navigates to root path when product is registered (registration exists)", async () => { mockUseSystemSoftwareFn.mockReturnValue({ addons: [], @@ -845,6 +935,20 @@ within(section).getByRole("button", { name: "View license" }); }); + it("lists the accepted licenses, each named after it, for products with several licenses", () => { + mockProduct(serverForApps); + mockSystem({ products: [tumbleweed, serverForApps], licenses }); + installerRender(<ProductSelectionPage />); + + const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); + const section = sectionHeading.closest("section"); + within(section).getByRole("heading", { level: 3, name: "Accepted licenses" }); + const list = within(section).getByRole("list"); + within(list).getByRole("button", { name: "Server License" }); + within(list).getByRole("button", { name: "Apps License" }); + expect(within(section).queryByRole("button", { name: "View license" })).toBeNull(); + }); + it("does not render view license button for products without license", () => { mockProduct(tumbleweed); installerRender(<ProductSelectionPage />); @@ -865,13 +969,13 @@ }); describe("LicenseButton", () => { - it("opens license dialog", async () => { + it("opens license dialog titled after the product", async () => { mockProduct(microOs); const { user } = installerRender(<ProductSelectionPage />); const viewLicenseButton = screen.getByRole("button", { name: "View license" }); await user.click(viewLicenseButton); - screen.getByText("LicenseDialog Mock"); + screen.getByText(`LicenseDialog Mock: ${microOs.name}`); }); }); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/product/ProductSelectionPage.tsx new/agama/src/components/product/ProductSelectionPage.tsx --- old/agama/src/components/product/ProductSelectionPage.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/product/ProductSelectionPage.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -21,7 +21,7 @@ */ import React, { useDeferredValue, useEffect, useState } from "react"; -import { isEmpty } from "radashi"; +import { isEmpty, toggle } from "radashi"; import { sprintf } from "sprintf-js"; import { Button, @@ -54,17 +54,37 @@ import { Page, SubtleContent } from "~/components/core"; import ProductLogo from "~/components/product/ProductLogo"; import LicenseDialog from "~/components/product/LicenseDialog"; +import Interpolate from "~/components/core/Interpolate"; import Text from "~/components/core/Text"; import { patchConfig, putConfig } from "~/api"; import { useProduct, useProductInfo } from "~/hooks/model/config/product"; import { useSystem } from "~/hooks/model/system"; import { useSystem as useSystemSoftware } from "~/hooks/model/system/software"; import { ROOT } from "~/routes/paths"; -import { Mode, Product } from "~/model/system"; import { n_, _ } from "~/i18n"; import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import type { License, Mode, Product } from "~/model/system"; + +/** + * Returns the licenses the user must accept to install the given product. + * + * Names come from the licenses known by the system. A license the system does + * not know, or reports without a name, is named after the product. + */ +const productLicenses = (product: Product | undefined, licenses: License[] = []): License[] => + (product?.licenses || []).map((id) => { + const name = licenses.find((l) => l.id === id)?.name; + return { id, name: name || product.name }; + }); + +/** + * Whether the system knows the given license by name. + */ +const hasKnownName = (license: License, licenses: License[] = []): boolean => + licenses.some((l) => l.id === license.id && !isEmpty(l.name)); + /** * Props for ProductFormProductOption component */ @@ -130,9 +150,9 @@ } body={ <Stack hasGutter id={detailsId}> - {(product.license || product.modes) && ( + {(!isEmpty(product.licenses) || product.modes) && ( <Split hasGutter> - {product.license && ( + {!isEmpty(product.licenses) && ( <Label variant="outline" isCompact> <Text component="small">{_("License acceptance required")}</Text> </Label> @@ -187,14 +207,16 @@ * Props for LicenseButton component */ type LicenseButtonProps = Omit<ButtonProps, "onClick"> & { - /** The product whose license will be displayed */ - product: Product; + /** The license to display */ + license: License; + /** The dialog title, the license name by default */ + dialogTitle?: string; }; /** * Button that opens a license dialog when clicked. */ -const LicenseButton = ({ product, children, ...props }: LicenseButtonProps) => { +const LicenseButton = ({ license, dialogTitle, children, ...props }: LicenseButtonProps) => { const [showEula, setShowEula] = useState(false); const open = () => setShowEula(true); @@ -205,7 +227,7 @@ <Button {...props} onClick={open}> {children} </Button> - {showEula && <LicenseDialog product={product} onClose={close} />} + {showEula && <LicenseDialog license={license} title={dialogTitle} onClose={close} />} </> ); }; @@ -214,8 +236,16 @@ * Props for EulaCheckbox component */ type EulaCheckboxProps = { - /** The product whose license is being accepted */ + /** The license being accepted */ + license: License; + /** The product the license belongs to */ product: Product; + /** + * Whether the label names the license instead of the product. Meant for + * products with several licenses, where each checkbox must tell which + * license it is about. + */ + namesLicense?: boolean; /** Callback fired when checkbox state changes */ onChange: (accepted: boolean) => void; /** Whether the checkbox is currently checked (i.e., license accepted) */ @@ -223,37 +253,71 @@ }; /** - * Checkbox for accepting a product's license agreement. - * Includes a link to view the full license text. + * Label naming the product, with a link to view its license. */ -const EulaCheckbox = ({ product, onChange, isChecked }: EulaCheckboxProps) => { - const [eulaTextStart, eulaTextLink, eulaTextEnd] = sprintf( +const ProductEulaLabel = ({ license, product }: Pick<EulaCheckboxProps, "license" | "product">) => { + const [textStart, textLink, textEnd] = sprintf( // TRANSLATORS: Text used for the license acceptance checkbox. %s will be // replaced with the product name and the text in the square brackets [] is // used for the link to show the license, please keep the brackets. _("I have read and accept the [license] for %s"), - product?.name, + product.name, ).split(/[[\]]/); return ( <> - <Checkbox - isChecked={isChecked} - onChange={(_, accepted) => onChange(accepted)} - id="license-acceptance" - label={ - <> - {eulaTextStart}{" "} - <LicenseButton product={product} variant="link" isInline> - {eulaTextLink} - </LicenseButton>{" "} - {eulaTextEnd} - </> - } - /> + {textStart}{" "} + <LicenseButton license={license} dialogTitle={product.name} variant="link" isInline> + {textLink} + </LicenseButton>{" "} + {textEnd} </> ); }; + +/** + * Label naming the license, which works as a link to view it. + */ +const LicenseEulaLabel = ({ license }: Pick<EulaCheckboxProps, "license">) => ( + <Interpolate + // TRANSLATORS: Text used for accepting one of the several licenses of a + // product. %s will be replaced with the license name, which is also a link + // to show the license. + sentence={_("I have read and accept the %s")} + > + {() => ( + <LicenseButton license={license} variant="link" isInline> + {license.name} + </LicenseButton> + )} + </Interpolate> +); + +/** + * Checkbox for accepting a product license. + * Includes a link to view the full license text. + */ +const EulaCheckbox = ({ + license, + product, + namesLicense = false, + onChange, + isChecked, +}: EulaCheckboxProps) => ( + <Checkbox + isChecked={isChecked} + onChange={(_, accepted) => onChange(accepted)} + id={`license-acceptance-${license.id}`} + label={ + namesLicense ? ( + <LicenseEulaLabel license={license} /> + ) : ( + <ProductEulaLabel license={license} product={product} /> + ) + } + /> +); + /** * Props for ProductFormSubmitLabel component */ @@ -355,6 +419,8 @@ type ProductFormProps = { /** List of all available products */ products: Product[]; + /** Licenses known by the system */ + licenses?: License[]; /** The product currently configured in the system */ currentProduct?: Product; /** The id of the product mode currently configured in the system */ @@ -446,6 +512,7 @@ */ const ProductForm = ({ products, + licenses, currentProduct, currentModeId, isSubmitted, @@ -453,16 +520,22 @@ }: ProductFormProps) => { const [selectedProduct, setSelectedProduct] = useState<Product>(); const [selectedMode, setSelectedMode] = useState<Mode>(); - const [eulaAccepted, setEulaAccepted] = useState(false); - const mountEulaCheckbox = selectedProduct && !isEmpty(selectedProduct.license); + const [acceptedLicenses, setAcceptedLicenses] = useState<License["id"][]>([]); + const selectedLicenses = productLicenses(selectedProduct, licenses); + const mountEulaCheckbox = !isEmpty(selectedLicenses); + const eulaAccepted = selectedLicenses.every((l) => acceptedLicenses.includes(l.id)); const isSelectionDisabled = !selectedProduct || isSubmitted || (mountEulaCheckbox && !eulaAccepted) || (!isEmpty(selectedProduct.modes) && !selectedMode); + const toggleLicenseAcceptance = (id: License["id"]) => { + setAcceptedLicenses((ids) => toggle(ids, id)); + }; + const onProductSelectionChange = (product) => { - setEulaAccepted(false); + setAcceptedLicenses([]); setSelectedMode(undefined); setSelectedProduct(product); }; @@ -507,11 +580,18 @@ <Stack hasGutter> {mountEulaCheckbox && ( <StackItem> - <EulaCheckbox - product={selectedProduct} - isChecked={eulaAccepted} - onChange={setEulaAccepted} - /> + <Stack hasGutter> + {selectedLicenses.map((license) => ( + <EulaCheckbox + key={license.id} + license={license} + product={selectedProduct} + namesLicense={selectedLicenses.length > 1 && hasKnownName(license, licenses)} + isChecked={acceptedLicenses.includes(license.id)} + onChange={() => toggleLicenseAcceptance(license.id)} + /> + ))} + </Stack> </StackItem> )} <StackItem> @@ -561,16 +641,20 @@ product?: Product; /** The selected mode */ modeId?: string; + /** Licenses known by the system */ + licenses?: License[]; }; /** * Card displaying information about the currently selected product. * - * Shows product name, description, and a link to view the license if applicable. + * Shows product name, description, and links to view the licenses if applicable. */ -const CurrentProductInfo = ({ product, modeId }: CurrentProductInfoProps) => { +const CurrentProductInfo = ({ product, modeId, licenses }: CurrentProductInfoProps) => { if (!product) return; + const currentLicenses = productLicenses(product, licenses); + let mode: Mode; if (modeId) { mode = product.modes.find((m) => m.id === modeId); @@ -597,11 +681,36 @@ </> )} - {product.license && ( - <LicenseButton product={product} variant="secondary" isInline> + {currentLicenses.length === 1 && ( + <LicenseButton + license={currentLicenses[0]} + dialogTitle={product.name} + variant="secondary" + isInline + > {_("View license")} </LicenseButton> )} + {currentLicenses.length > 1 && ( + <> + <Title headingLevel="h3"> + { + // TRANSLATORS: title of the list of licenses accepted for + // the selected product + _("Accepted licenses") + } + </Title> + <List> + {currentLicenses.map((license) => ( + <ListItem key={license.id}> + <LicenseButton license={license} variant="link" isInline> + {license.name} + </LicenseButton> + </ListItem> + ))} + </List> + </> + )} </Stack> </CardBody> </Card> @@ -677,7 +786,7 @@ const ProductSelectionContent = () => { const navigate = useNavigate(); const product = useProduct(); - const { products } = useSystem(); + const { products, licenses } = useSystem(); const currentProduct = useProductInfo(); const [submittedSelection, setSubmmitedSelection] = useState<Product>(); const [isSubmitted, setIsSubmmited] = useState(false); @@ -733,6 +842,7 @@ <GridItem sm={12} md={8} order={{ default: "1", md: "0" }}> <ProductForm products={products} + licenses={licenses} currentProduct={currentProduct} currentModeId={product?.mode} isSubmitted={isWaiting} @@ -740,7 +850,13 @@ /> </GridItem> <GridItem sm={12} md={4} order={{ default: "0", md: "1" }}> - {!isWaiting && <CurrentProductInfo product={currentProduct} modeId={product?.mode} />} + {!isWaiting && ( + <CurrentProductInfo + product={currentProduct} + modeId={product?.mode} + licenses={licenses} + /> + )} </GridItem> </Grid> </Page.Content> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/ExpandableTechnicalSection.test.tsx new/agama/src/components/questions/ExpandableTechnicalSection.test.tsx --- old/agama/src/components/questions/ExpandableTechnicalSection.test.tsx 1970-01-01 01:00:00.000000000 +0100 +++ new/agama/src/components/questions/ExpandableTechnicalSection.test.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -0,0 +1,75 @@ +/* + * Copyright (c) [2026] 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 } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import ExpandableTechnicalSection from "~/components/questions/ExpandableTechnicalSection"; + +describe("ExpandableTechnicalSection", () => { + it("renders the technical details text", () => { + plainRender(<ExpandableTechnicalSection text="Error: Something went wrong" />); + + screen.getByText("Error: Something went wrong"); + }); + + it("renders multi-line text correctly", () => { + const multiLineText = `Error: Failed to commit + Caused by: + - Storage device not found + - Invalid configuration`; + + plainRender(<ExpandableTechnicalSection text={multiLineText} />); + + screen.getByText(/Error: Failed to commit/); + screen.getByText(/Caused by:/); + screen.getByText(/Storage device not found/); + screen.getByText(/Invalid configuration/); + }); + + it("shows expand/collapse toggle buttons", () => { + plainRender(<ExpandableTechnicalSection text="Error details" />); + + screen.getByText("Show technical details"); + }); + + it("expands and collapses the section", async () => { + const { user } = plainRender(<ExpandableTechnicalSection text="Error details" />); + + const toggleButton = screen.getByText("Show technical details"); + await user.click(toggleButton); + + screen.getByText("Hide technical details"); + }); + + it("renders nothing when text is undefined", () => { + const { container } = plainRender(<ExpandableTechnicalSection text={undefined} />); + + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when text is empty", () => { + const { container } = plainRender(<ExpandableTechnicalSection text="" />); + + expect(container.firstChild).toBeNull(); + }); +}); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/ExpandableTechnicalSection.tsx new/agama/src/components/questions/ExpandableTechnicalSection.tsx --- old/agama/src/components/questions/ExpandableTechnicalSection.tsx 1970-01-01 01:00:00.000000000 +0100 +++ new/agama/src/components/questions/ExpandableTechnicalSection.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2026] 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 { CodeBlock, ExpandableSection } from "@patternfly/react-core"; +import { NestedContent } from "~/components/core"; +import { _ } from "~/i18n"; + +/** + * Component for rendering technical details in an expandable section + * + * @param text - the technical details to display + */ +export default function ExpandableTechnicalSection({ text }: { text?: string }): React.ReactNode { + if (!text) return null; + + return ( + <ExpandableSection + toggleTextExpanded={ + /* TRANSLATORS: Clickable text to hide technical details at a popup window */ + _("Hide technical details") + } + toggleTextCollapsed={ + /* TRANSLATORS: Clickable text to show technical details at a popup window */ + _("Show technical details") + } + > + <NestedContent> + <CodeBlock> + <pre className={"mh-60dvh"}>{text}</pre> + </CodeBlock> + </NestedContent> + </ExpandableSection> + ); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/LoadConfigRetryQuestion.tsx new/agama/src/components/questions/LoadConfigRetryQuestion.tsx --- old/agama/src/components/questions/LoadConfigRetryQuestion.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/questions/LoadConfigRetryQuestion.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -21,18 +21,11 @@ */ import React, { useState } from "react"; -import { - CodeBlock, - Content, - ExpandableSection, - Form, - FormGroup, - Stack, - TextInput, -} from "@patternfly/react-core"; -import { NestedContent, Popup } from "~/components/core"; +import { Content, Form, FormGroup, Stack, TextInput } from "@patternfly/react-core"; +import { Popup } from "~/components/core"; import Text from "~/components/core/Text"; import QuestionActions from "~/components/questions/QuestionActions"; +import ExpandableTechnicalSection from "~/components/questions/ExpandableTechnicalSection"; import { _ } from "~/i18n"; import type { AnswerCallback, Question } from "~/model/question"; @@ -79,24 +72,7 @@ {_("Make sure the location is correct and the configuration is valid.")} </Text> </Content> - {error && ( - <ExpandableSection - toggleTextExpanded={ - /* TRANSLATORS: Clickable text to hide technical details from popup window */ - _("Hide technical details") - } - toggleTextCollapsed={ - /* TRANSLATORS: Clickable text to show technical details at popup window */ - _("Show technical details") - } - > - <NestedContent> - <CodeBlock> - <pre>{error}</pre> - </CodeBlock> - </NestedContent> - </ExpandableSection> - )} + <ExpandableTechnicalSection text={error} /> </Stack> <Popup.Actions> <QuestionActions diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/Questions.test.tsx new/agama/src/components/questions/Questions.test.tsx --- old/agama/src/components/questions/Questions.test.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/questions/Questions.test.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2026] SUSE LLC * * All Rights Reserved. * @@ -41,6 +41,10 @@ <div>LoadConfigRetryQuestion mock</div> )); +jest.mock("~/components/questions/StorageCommitErrorQuestion", () => () => ( + <div>StorageCommitErrorQuestion mock</div> +)); + jest.mock("~/api", () => ({ ...jest.requireActual("~/api"), patchQuestion: (...args) => mockPatchQuestionFn(...args), @@ -95,6 +99,18 @@ defaultAction: "no", }; +const storageCommitErrorQuestion: Question = { + id: 5, + class: "storageCommitError", + text: "Storage configuration could not be applied. Do you want to retry?", + field: { type: FieldType.None }, + actions: [ + { id: "retry", label: "Retry" }, + { id: "cancel", label: "Cancel" }, + ], + defaultAction: "cancel", +}; + describe("Questions", () => { afterEach(() => { jest.restoreAllMocks(); @@ -173,4 +189,15 @@ screen.getByText("LoadConfigRetryQuestion mock"); }); }); + + describe("when there is a storage commit error question pending", () => { + beforeEach(() => { + mockQuestions([storageCommitErrorQuestion]); + }); + + it("renders a StorageCommitErrorQuestion component", () => { + installerRender(<Questions />); + screen.getByText("StorageCommitErrorQuestion mock"); + }); + }); }); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/Questions.tsx new/agama/src/components/questions/Questions.tsx --- old/agama/src/components/questions/Questions.tsx 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/components/questions/Questions.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2025] SUSE LLC + * Copyright (c) [2022-2026] SUSE LLC * * All Rights Reserved. * @@ -28,6 +28,7 @@ import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST"; import RegistrationCertificateQuestion from "~/components/questions/RegistrationCertificateQuestion"; import LoadConfigRetryQuestion from "~/components/questions/LoadConfigRetryQuestion"; +import StorageCommitErrorQuestion from "~/components/questions/StorageCommitErrorQuestion"; import { useQuestions, useQuestionsChanges } from "~/hooks/model/question"; import { patchQuestion } from "~/api"; import { FieldType } from "~/model/question"; @@ -80,5 +81,10 @@ QuestionComponent = LoadConfigRetryQuestion; } + // special popup for storage commit errors + if (questionClass === "storageCommitError") { + QuestionComponent = StorageCommitErrorQuestion; + } + return <QuestionComponent question={currentQuestion} answerCallback={answerQuestion} />; } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx new/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx --- old/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx 1970-01-01 01:00:00.000000000 +0100 +++ new/agama/src/components/questions/StorageCommitErrorQuestion.test.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -0,0 +1,105 @@ +/* + * Copyright (c) [2026] 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 } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { Question, FieldType } from "~/model/question"; +import StorageCommitErrorQuestion from "~/components/questions/StorageCommitErrorQuestion"; + +const questionWithDetails: Question = { + id: 1, + class: "storageCommitError", + text: "Storage configuration could not be applied. Do you want to retry?", + field: { type: FieldType.None }, + data: { + details: `Failed to commit storage changes + + Caused by: + 0: Device /dev/sda not found + 1: Invalid partition table`, + }, + actions: [ + { id: "retry", label: "Retry" }, + { id: "cancel", label: "Cancel" }, + ], + defaultAction: "cancel", +}; + +const questionWithoutDetails: Question = { + id: 2, + class: "storageCommitError", + text: "Storage configuration could not be applied. Do you want to retry?", + field: { type: FieldType.None }, + actions: [ + { id: "retry", label: "Retry" }, + { id: "cancel", label: "Cancel" }, + ], + defaultAction: "cancel", +}; + +const answerFn = jest.fn(); + +describe("StorageCommitErrorQuestion", () => { + it("renders the question text", () => { + plainRender( + <StorageCommitErrorQuestion question={questionWithDetails} answerCallback={answerFn} />, + ); + + screen.getByText(questionWithDetails.text); + }); + + it("renders details from question", () => { + plainRender( + <StorageCommitErrorQuestion question={questionWithDetails} answerCallback={answerFn} />, + ); + + screen.getByText(/Failed to commit storage changes/); + screen.getByText(/Device \/dev\/sda not found/); + }); + + it("does not show expandable section when no details are present", () => { + plainRender( + <StorageCommitErrorQuestion question={questionWithoutDetails} answerCallback={answerFn} />, + ); + + expect(screen.queryByText("Show technical details")).not.toBeInTheDocument(); + }); + + it("calls the callback with the selected action", async () => { + const { user } = plainRender( + <StorageCommitErrorQuestion question={questionWithDetails} answerCallback={answerFn} />, + ); + + const retryButton = await screen.findByRole("button", { name: "Retry" }); + await user.click(retryButton); + + expect(questionWithDetails.answer).toEqual(expect.objectContaining({ action: "retry" })); + expect(answerFn).toHaveBeenCalledWith(questionWithDetails); + + const cancelButton = await screen.findByRole("button", { name: "Cancel" }); + await user.click(cancelButton); + + expect(questionWithDetails.answer).toEqual(expect.objectContaining({ action: "cancel" })); + expect(answerFn).toHaveBeenCalledWith(questionWithDetails); + }); +}); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/components/questions/StorageCommitErrorQuestion.tsx new/agama/src/components/questions/StorageCommitErrorQuestion.tsx --- old/agama/src/components/questions/StorageCommitErrorQuestion.tsx 1970-01-01 01:00:00.000000000 +0100 +++ new/agama/src/components/questions/StorageCommitErrorQuestion.tsx 2026-09-21 16:43:04.000000000 +0200 @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2026] 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 { Content } from "@patternfly/react-core"; +import { Popup } from "~/components/core"; +import QuestionActions from "~/components/questions/QuestionActions"; +import ExpandableTechnicalSection from "~/components/questions/ExpandableTechnicalSection"; +import { _ } from "~/i18n"; +import type { AnswerCallback, Question } from "~/model/question"; + +/** + * Component for rendering storage commit error questions + * + * @param question - the question to be answered + * @param answerCallback - the callback to be triggered on answer + */ +export default function StorageCommitErrorQuestion({ + question, + answerCallback, +}: { + question: Question; + answerCallback: AnswerCallback; +}): React.ReactNode { + const actionCallback = (action: string) => { + question.answer = { action }; + answerCallback(question); + }; + + return ( + <Popup isOpen aria-label={_("Question")} variant="medium"> + <Content component="p">{question.text}</Content> + <ExpandableTechnicalSection text={question.data?.details} /> + <Popup.Actions> + <QuestionActions + actions={question.actions} + defaultAction={question.defaultAction} + actionCallback={actionCallback} + /> + </Popup.Actions> + </Popup> + ); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/hooks/use-progress-tracking.test.ts new/agama/src/hooks/use-progress-tracking.test.ts --- old/agama/src/hooks/use-progress-tracking.test.ts 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/hooks/use-progress-tracking.test.ts 2026-09-21 16:43:04.000000000 +0200 @@ -272,6 +272,11 @@ expect(result.current.loading).toBe(true); + // startTracking is called when operation starts, not when it completes + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalledTimes(1); + }); + // Progress completes but task still running jest.advanceTimersByTime(1000); mockProgresses([]); @@ -279,17 +284,16 @@ rerender(); expect(result.current.loading).toBe(true); - expect(mockStartTracking).not.toHaveBeenCalled(); + // startTracking should only be called once, even though state changed + expect(mockStartTracking).toHaveBeenCalledTimes(1); // Task also completes jest.advanceTimersByTime(1000); mockTasks([]); rerender(); - await waitFor(() => { - expect(mockStartTracking).toHaveBeenCalledTimes(1); - }); - + // Still only called once + expect(mockStartTracking).toHaveBeenCalledTimes(1); expect(result.current.loading).toBe(true); // Queries refetch @@ -301,56 +305,56 @@ }); }); - // FLAW: This test demonstrates that if a query is refetched BEFORE startTracking() - // is called (but after progress started), it will never be considered as refetched. - // The hook gets stuck in loading state because useTrackQueriesRefetch only detects - // queries with dataUpdatedAt > startedAt, where startedAt is captured when - // startTracking() is called, not when the progress originally started. + // This test verifies that startTracking() is called when progress starts, + // not when it completes. This ensures queries that refetch during the operation + // are properly detected as fresh by useTrackQueriesRefetch. // - // This can happen in real scenarios where: - // 1. Progress starts at T0 - // 2. Query automatically refetches at T1 (due to invalidation or auto-refetch) - // 3. Progress completes at T2 - // 4. startTracking() is called at T2, capturing startedAt = T2 - // 5. The query's dataUpdatedAt (T1) < startedAt (T2), so it's never considered refetched - // 6. Hook waits forever for a refetch that already happened - it("FLAW: gets stuck in loading state when query refetches before startTracking is called", async () => { + // The trackingRequested state prevents multiple calls to startTracking() even + // if queries update during the operation, ensuring the startedAt timestamp + // captured by useTrackQueriesRefetch reflects when the operation actually began. + it("calls startTracking when progress starts, not when it completes", async () => { const { result, rerender } = renderHook(() => useProgressTracking("software")); - // Start progress at T0 + // Before progress starts + expect(result.current.loading).toBe(false); + expect(mockStartTracking).not.toHaveBeenCalled(); + + // Progress starts at T0 mockProgresses([fakeSoftwareProgress]); rerender(); + // startTracking should be called immediately when progress starts + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalledTimes(1); + }); expect(result.current.loading).toBe(true); // Query refetches during progress at T+1000ms (this would happen automatically // in real app due to query invalidation or refetchInterval) jest.advanceTimersByTime(1000); - // In reality, TanStack Query would update the query's dataUpdatedAt to T+1000 + rerender(); + + // startTracking should NOT be called again despite query updates + expect(mockStartTracking).toHaveBeenCalledTimes(1); // Progress completes at T+2000ms jest.advanceTimersByTime(1000); mockProgresses([]); rerender(); - // startTracking() is called now, capturing startedAt = T+2000 - await waitFor(() => { - expect(mockStartTracking).toHaveBeenCalledTimes(1); - }); + // startTracking should still only have been called once + expect(mockStartTracking).toHaveBeenCalledTimes(1); - // The query's dataUpdatedAt (T+1000) < startedAt (T+2000), so it won't be - // detected as refetched by useTrackQueriesRefetch. The hook will wait - // forever for a refetch that already happened. - // - // Expected behavior: loading should become false when the refetch from T+1000 - // is detected, but it stays true indefinitely + // Loading stays true until refetch callback fires expect(result.current.loading).toBe(true); - // Even if time advances, loading stays true because no new refetch happens - jest.advanceTimersByTime(3000); - rerender(); + // Queries finish refetching + jest.advanceTimersByTime(1000); - expect(result.current.loading).toBe(true); + await waitFor(() => { + mockRefetchCallback(); + expect(result.current.loading).toBe(false); + }); }); }); }); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/hooks/use-progress-tracking.ts new/agama/src/hooks/use-progress-tracking.ts --- old/agama/src/hooks/use-progress-tracking.ts 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/hooks/use-progress-tracking.ts 2026-09-21 16:43:04.000000000 +0200 @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -101,9 +101,11 @@ const progress = useProgress(scope); const status = useStatus(); const [loading, setLoading] = useState(false); + const [tracking, setTracking] = useState(false); + const [trackingRequested, setTrackingRequested] = useState(false); const { startTracking } = useTrackQueriesRefetch(queryKeys, () => { - setLoading(false); + setTracking(false); }); // Filter tasks by scope @@ -117,14 +119,21 @@ const allFinished = progressesFinished && tasksFinished; useEffect(() => { - if (allFinished && loading) { + if (!allFinished && !trackingRequested) { + setTrackingRequested(true); + setTracking(true); startTracking(); } - }, [allFinished, startTracking, loading]); + }, [allFinished, setTrackingRequested, startTracking, trackingRequested]); + + if (allFinished && !tracking && loading) { + setTrackingRequested(false); + setLoading(false); + } // Enter the loading state as soon as an operation is detected. Setting state // during render (instead of in an effect) avoids a flash of non-loading UI. - if (!allFinished && !loading) { + if (!loading && (!allFinished || tracking)) { setLoading(true); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/src/model/system.ts new/agama/src/model/system.ts --- old/agama/src/model/system.ts 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/src/model/system.ts 2026-09-21 16:43:04.000000000 +0200 @@ -37,6 +37,7 @@ l10n?: L10n.System; network?: Network.System; products?: Product[]; + licenses?: License[]; software?: Software.System; storage?: Storage.System; iscsi?: ISCSI.System; @@ -66,14 +67,21 @@ icon?: string; /** If product is registrable or not */ registration: boolean; - /** The product license id, if any */ - license?: string; + /** Ids of the licenses the user must accept to install the product */ + licenses?: string[]; /** Desktop selection mode; absent when the product does not declare it */ desktopSelection?: DesktopSelection; /** Available modes for this product (names and descriptions are translated to current UI language) */ modes: Mode[]; }; +type License = { + /** License ID (e.g., "license.final") */ + id: string; + /** License name, in the current system language */ + name: string; +}; + type Mode = { /** Mode ID (e.g., "traditional") */ id: string; @@ -86,6 +94,8 @@ type LicenseContent = { /** License ID (e.g., "license.sle") */ id: string; + /** License name */ + name: string; /** License body */ body: string; /** License language (e.g., "en-US") */ @@ -96,6 +106,7 @@ System, Product, DesktopSelection, + License, LicenseContent, L10n, Hardware, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/webpack.config.js new/agama/webpack.config.js --- old/agama/webpack.config.js 2026-09-02 11:36:48.000000000 +0200 +++ new/agama/webpack.config.js 2026-09-21 16:43:04.000000000 +0200 @@ -206,6 +206,12 @@ sassOptions: { includePaths: ["node_modules"], outputStyle: production ? "compressed" : undefined, + // Never emit a @charset or a byte-order mark. Compressed output + // marks a non-ASCII stylesheet with a BOM, and the extracted + // stylesheets are concatenated into a single file, so that BOM + // would land mid-file, where it is part of the next selector + // rather than whitespace and silently kills that rule. + charset: false, silenceDeprecations: ["global-builtin", "if-function"], }, }, ++++++ agama.obsinfo ++++++ --- /var/tmp/diff_new_pack.okt1Fk/_old 2026-09-22 15:49:06.697973564 +0200 +++ /var/tmp/diff_new_pack.okt1Fk/_new 2026-09-22 15:49:06.703973813 +0200 @@ -1,5 +1,5 @@ name: agama -version: 24+0.a836cced5 -mtime: 1788341808 -commit: a836cced585e55fdf43cbbda2c516a143ab80624 +version: 24+79.19b44f04f +mtime: 1790001784 +commit: 19b44f04f10bb7a865c9edd2abf7ea6cb9b012d4 ++++++ node_modules.sums ++++++ ++++ 1740 lines (skipped) ++++ between /work/SRC/openSUSE:Factory/agama-web-ui/node_modules.sums ++++ and /work/SRC/openSUSE:Factory/.agama-web-ui.new.383539/node_modules.sums
