diff --git a/rust/agama-lib/share/README.md b/rust/agama-lib/share/README.md index d5007fbfbb..4c97f5263c 100644 --- a/rust/agama-lib/share/README.md +++ b/rust/agama-lib/share/README.md @@ -10,6 +10,7 @@ The Agama autoinstallation profile JSON schema is defined in these files: - [profile.schema.json](./profile.schema.json) (the main definition) - [storage.schema.json](./storage.schema.json) (referenced from the main definition) - [iscsi.schema.json](./iscsi.schema.json) (referenced from the main definition) +- [dasd.schema.json](./dasd.schema.json) (referenced from the main definition) ## Agama REST API diff --git a/rust/agama-lib/share/dasd.schema.json b/rust/agama-lib/share/dasd.schema.json new file mode 100644 index 0000000000..8b026b9a6c --- /dev/null +++ b/rust/agama-lib/share/dasd.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/dasd.schema.json", + "title": "Config", + "description": "DASD config.", + "type": "object", + "additionalProperties": false, + "properties": { + "devices": { + "description": "List of DASD devices.", + "type": "array", + "items": { "$ref": "#/$defs/device" } + } + }, + "$defs": { + "device": { + "type": "object", + "additionalProperties": false, + "required": ["channel"], + "properties": { + "channel": { + "description": "DASD device channel.", + "type": "string" + }, + "state": { + "description": "Specify target state of device. Either activate it or deactivate it.", + "enum": ["active", "offline"], + "default": "active" + }, + "format": { + "description": "If device should be formatted. If not specified then it format device only if not already formatted.", + "type": "boolean" + }, + "diag": { + "description": "If device have set diag flag. If not specified then it keep what device has before.", + "type": "boolean" + } + } + } + } +} diff --git a/rust/agama-lib/share/package.json b/rust/agama-lib/share/package.json index 90f7f687f5..6778899b4c 100644 --- a/rust/agama-lib/share/package.json +++ b/rust/agama-lib/share/package.json @@ -1,6 +1,6 @@ { "scripts": { - "validate": "ajv compile --spec=draft2019 --verbose --all-errors -r storage.schema.json -r iscsi.schema.json -r software.schema.json -s profile.schema.json && ajv compile --spec=draft2019 --verbose --all-errors -s storage.model.schema.json" + "validate": "ajv compile --spec=draft2019 --verbose --all-errors -r storage.schema.json -r iscsi.schema.json -r dasd.schema.json -r software.schema.json -s profile.schema.json && ajv compile --spec=draft2019 --verbose --all-errors -s storage.model.schema.json" }, "dependencies": { "ajv-cli": "^5.0.0" diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 9ababd98d5..ca73aa29b6 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -98,40 +98,7 @@ } ] }, - "dasd": { - "title": "DASD device activation (s390x only)", - "type": "object", - "properties": { - "devices": { - "title": "List of DASD devices", - "type": "array", - "items": { - "type": "object", - "properties": { - "channel": { - "title": "DASD device channel", - "type": "string" - }, - "state": { - "title": "Specify target state of device. Either activate it or deactivate it.", - "type": "string", - "enum": ["active", "offline"], - "default": "active" - }, - "format": { - "title": "If device should be formatted. If not specified then it format device only if not already formatted.", - "type": "boolean" - }, - "diag": { - "title": "If device have set diag flag. If not specified then it keep what device has before.", - "type": "boolean" - } - }, - "required": ["channel"] - } - } - } - }, + "dasd": { "$ref": "dasd.schema.json" }, "zfcp": { "title": "zFCP device activation (s390x only)", "type": "object", diff --git a/rust/package/agama.changes b/rust/package/agama.changes index eb1e732c17..2414debaea 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Feb 26 16:44:32 UTC 2026 - David Diaz + +- Add schema for DASD config (gh#agama-project/agama#3143). + ------------------------------------------------------------------- Wed Feb 25 08:13:55 UTC 2026 - Josef Reidinger diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 447c8472bb..025bc53094 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -255,6 +255,7 @@ echo $PATH %dir %{_datadir}/agama/jsonnet %{_datadir}/agama/jsonnet/agama.libsonnet %dir %{_datadir}/agama/schema +%{_datadir}/agama/schema/dasd.schema.json %{_datadir}/agama/schema/iscsi.schema.json %{_datadir}/agama/schema/profile.schema.json %{_datadir}/agama/schema/software.schema.json diff --git a/rust/share/system.dasd.schema.json b/rust/share/system.dasd.schema.json index a769fc30a7..4de1c05357 100644 --- a/rust/share/system.dasd.schema.json +++ b/rust/share/system.dasd.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/system.storage.schema.json", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/system.dasd.schema.json", "title": "System", "description": "API description of the DASD system", "type": "object", diff --git a/service/lib/agama/dbus/storage/dasd.rb b/service/lib/agama/dbus/storage/dasd.rb index a8e2c44ba4..59c75baa47 100644 --- a/service/lib/agama/dbus/storage/dasd.rb +++ b/service/lib/agama/dbus/storage/dasd.rb @@ -144,7 +144,7 @@ def device_json(dasd) type: manager.device_type(dasd), diag: dasd.use_diag, accessType: dasd.access_type || "", - partitionInfo: dasd.partition_info || "", + partitionInfo: dasd.offline? ? "" : dasd.partition_info.to_s, status: dasd.status.to_s, active: !dasd.offline?, formatted: dasd.formatted? diff --git a/service/lib/agama/storage/dasd/enable_operation.rb b/service/lib/agama/storage/dasd/enable_operation.rb index d428a4cbe7..aafdd1fd08 100644 --- a/service/lib/agama/storage/dasd/enable_operation.rb +++ b/service/lib/agama/storage/dasd/enable_operation.rb @@ -30,6 +30,9 @@ class EnableOperation < SequentialOperation private def process_dasd(dasd) + # Reset #diag_wanted, otherwise the device cannot be enabled again if a {DiagOperation} + # for enabling diag failed. + dasd.diag_wanted = dasd.use_diag # We considered to stop using dasd_configure in favor of directly calling "chzdev -e". # That would allow us, for example, to enable all the devices with a single command. # But dasd_configure does a couple of extra things we still find valuable: diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 023c9a1073..16642fd523 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Feb 26 16:47:39 UTC 2026 - David Diaz + +- Allow reactivating a DASD if enabling DIAG failed + (gh#agama-project/agama#3143). + ------------------------------------------------------------------- Wed Feb 18 20:28:02 UTC 2026 - Josef Reidinger diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 5d8009eadf..334e376534 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Feb 26 16:43:44 UTC 2026 - David Diaz + +- Restore the interface for managing DASD in the web client + (gh#agama-project/agama#3143). + ------------------------------------------------------------------- Fri Feb 20 15:00:44 UTC 2026 - Imobach Gonzalez Sosa diff --git a/web/src/App.tsx b/web/src/App.tsx index db0dca8b57..464de63258 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -25,7 +25,7 @@ import { Outlet, useLocation } from "react-router"; import { useStatusChanges, useStatus } from "~/hooks/model/status"; import { useSystemChanges } from "~/hooks/model/system"; import { useProposal, useProposalChanges } from "~/hooks/model/proposal"; -import { useIssuesChanges } from "~/hooks/model/issue"; +import { useIssues, useIssuesChanges } from "~/hooks/model/issue"; import { useProductInfo } from "~/hooks/model/config/product"; import { useQueryClient } from "@tanstack/react-query"; import { InstallationFinished, InstallationProgress } from "./components/core"; @@ -44,6 +44,7 @@ const Content = () => { // // https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation useProposal(); + useIssues(); const location = useLocation(); const product = useProductInfo(); diff --git a/web/src/api.ts b/web/src/api.ts index fa9bcda060..2f2144b4a1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -30,7 +30,6 @@ import type { Status } from "~/model/status"; import type { System } from "~/model/system"; import type { Action, L10nSystemConfig, DiscoverISCSIConfig } from "~/model/action"; import type { AxiosResponse } from "axios"; -import type { Job } from "~/types/job"; type Response = Promise; @@ -82,11 +81,6 @@ const discoverISCSIAction = (config: DiscoverISCSIConfig) => postAction({ discov const finishInstallation = () => postAction({ finish: "reboot" }); -/** - * @todo Adapt jobs to the new API. - */ -const getStorageJobs = (): Promise => get("/api/storage/jobs"); - export { getStatus, getConfig, @@ -106,7 +100,6 @@ export { probeStorageAction, discoverISCSIAction, finishInstallation, - getStorageJobs, }; export type { Response }; diff --git a/web/src/components/core/SelectableDataTable.test.tsx b/web/src/components/core/SelectableDataTable.test.tsx index f6e5ecb621..acb2277e0e 100644 --- a/web/src/components/core/SelectableDataTable.test.tsx +++ b/web/src/components/core/SelectableDataTable.test.tsx @@ -220,6 +220,21 @@ describe("SelectableDataTable", () => { expect(editFn).toHaveBeenCalled(); }); + it("uses itemActionsComponent when provided instead of the default ActionsColumn", async () => { + const CustomActions = ({ label }) => Custom actions: {label}; + + plainRender( + [{ title: `Edit ${d.name}`, onClick: jest.fn() }]} + itemActionsLabel={(d) => `Actions for ${d.name}`} + itemActionsComponent={CustomActions} + />, + ); + + screen.getByText("Custom actions: Actions for /dev/sda"); + }); + it("renders a expand toggler in items with children", () => { plainRender(); const table = screen.getByRole("grid"); @@ -287,8 +302,20 @@ describe("SelectableDataTable", () => { expect(sdaChild).not.toBeNull(); }); - it.todo("allows selectionMode#none"); - it.todo("renders nothing for actions column if actions is an empty collection"); + it("allows selectionMode#none", () => { + plainRender(); + const table = screen.getByRole("grid"); + expect(within(table).queryAllByRole("radio")).toEqual([]); + expect(within(table).queryAllByRole("checkbox")).toEqual([]); + }); + + it.skip("renders nothing for actions column if actions is an empty collection", () => { + // TODO: requires a refactor to correctly hide the actions column header + // when no items have actions. Check TODO note in the component + plainRender( []} />); + const table = screen.getByRole("grid"); + expect(within(table).queryByRole("columnheader", { name: "Row actions" })).toBeNull(); + }); describe("when not providing a custom item equality function", () => { const onSelectionChange = jest.fn(); diff --git a/web/src/components/core/SelectableDataTable.tsx b/web/src/components/core/SelectableDataTable.tsx index 2ba0bf1d0a..38d6eb1dde 100644 --- a/web/src/components/core/SelectableDataTable.tsx +++ b/web/src/components/core/SelectableDataTable.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { Bullseye, MenuToggle } from "@patternfly/react-core"; +import { MenuToggle } from "@patternfly/react-core"; import { Table, TableProps, @@ -136,7 +136,7 @@ export type SelectableDataTableProps = { /** * Determines the selection behavior of the table. * - * - `"single"`: Does not allow selection. + * - `"none"`: Does not allow selection. * - `"single"`: Allows selecting only one item at a time (radio buttons). * - `"multiple"`: Allows selecting multiple items (checkboxes). */ @@ -209,6 +209,15 @@ export type SelectableDataTableProps = { */ itemActionsLabel?: (d: T) => string | string; + /** + * Custom component to use for rendering the actions cell. When not provided, + * falls back to PatternFly's ActionsColumn. + * + * The component will receive the actions for the row and the resolved + * accessible label from `itemActionsLabel`. + */ + itemActionsComponent?: React.ComponentType<{ items: IAction[]; label: string }>; + /** * Array of currently selected items. */ @@ -307,7 +316,12 @@ type SharedData = { } & Readonly< Pick< SelectableDataTableProps, - "sortedBy" | "updateSorting" | "allowSelectAll" | "itemActions" | "itemActionsLabel" + | "sortedBy" + | "updateSorting" + | "allowSelectAll" + | "itemActions" + | "itemActionsLabel" + | "itemActionsComponent" > >; @@ -382,7 +396,11 @@ const TableHeader = ({ ); })} - {itemActions && } + { + // TODO: replace with itemActionsMap in SharedData to avoid calling itemActions + // twice per item and to correctly hide the actions column when no items have actions. + itemActions && + } ); @@ -461,6 +479,7 @@ export default function SelectableDataTable({ allowSelectAll = false, itemActions, itemActionsLabel, + itemActionsComponent, emptyState, ...tableProps }: SelectableDataTableProps) { @@ -485,6 +504,8 @@ export default function SelectableDataTable({ }; const updateSelection = (item: object) => { + if (!isFunction(onSelectionChange)) return; + if (!allowMultiple) { onSelectionChange([item]); return; @@ -558,6 +579,9 @@ export default function SelectableDataTable({ return children.map((item) => renderItemChild(item, isItemExpanded(itemKey), sharedData)); }; + const label = isFunction(itemActionsLabel) ? itemActionsLabel(item) : itemActionsLabel; + const ItemActionsComponent = itemActionsComponent; + // TODO: Add label to Tbody? return ( @@ -571,21 +595,23 @@ export default function SelectableDataTable({ ))} {!isEmpty(actions) && ( - ( - - - - )} - /> + {ItemActionsComponent ? ( + + ) : ( + ( + + + + )} + /> + )} )} @@ -603,6 +629,7 @@ export default function SelectableDataTable({ allowSelectAll, itemActions, itemActionsLabel, + itemActionsComponent, // FIXME: drop showSelectAll once items is part of SharedData showSelectAll: allowSelectAll && items.length > 0, isAllSelected: items.length > 0 && items.length === itemsSelected.length, @@ -610,31 +637,14 @@ export default function SelectableDataTable({ isSelecting ? onSelectionChange(items) : onSelectionChange([]), }; - // TODO: extract to a separate component and inject sharedData as prop - const TableEmptyState = () => { - const columnsCount = - columns.length + (sharedData.allowSelectAll && 1) + (sharedData.itemActions && 1); - - return ( - - - {emptyState} - - - ); - }; - - const TableBody = () => { - if (isEmpty(items) && emptyState) { - return ; - } - return items?.map((item) => renderItem(item, sharedData)); - }; + if (isEmpty(items) && emptyState) { + return emptyState; + } return ( - + {items?.map((item) => renderItem(item, sharedData))}
); } diff --git a/web/src/components/core/SimpleDropdown.test.tsx b/web/src/components/core/SimpleDropdown.test.tsx new file mode 100644 index 0000000000..dfba53e419 --- /dev/null +++ b/web/src/components/core/SimpleDropdown.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { installerRender } from "~/test-utils"; +import SimpleDropdown from "./SimpleDropdown"; + +const mockItems = [ + { title: "Activate", onClick: jest.fn() }, + { title: "Deactivate", onClick: jest.fn() }, + { title: "Format", onClick: jest.fn(), isDanger: true }, +]; + +describe("SimpleDropdown", () => { + beforeEach(() => { + mockItems.forEach((item) => item.onClick.mockClear()); + }); + + it("renders the toggle button with the given label", () => { + installerRender(); + screen.getByRole("button", { name: "Actions for 0.0.0160" }); + }); + + it("does not show the menu items initially", () => { + installerRender(); + expect(screen.queryByRole("menuitem", { name: "Activate" })).toBeNull(); + }); + + it("shows the menu items when the toggle is clicked", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + screen.getByRole("menuitem", { name: "Activate" }); + screen.getByRole("menuitem", { name: "Deactivate" }); + screen.getByRole("menuitem", { name: "Format" }); + }); + + it("shows the group label when the menu is open", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + screen.getByText("Actions for 0.0.0160"); + }); + + it("calls onClick when a menu item is clicked", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + await user.click(screen.getByRole("menuitem", { name: "Activate" })); + expect(mockItems[0].onClick).toHaveBeenCalled(); + }); + + it("closes the menu after clicking an item", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + await user.click(screen.getByRole("menuitem", { name: "Activate" })); + expect(screen.queryByRole("menuitem", { name: "Activate" })).not.toBeVisible(); + }); + + it("renders danger items", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + screen.getByRole("menuitem", { name: "Format" }); + }); + + describe("when custom popperProps are given", () => { + it("uses them instead of the defaults", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + screen.getByRole("menuitem", { name: "Activate" }); + }); + }); +}); diff --git a/web/src/components/core/SimpleDropdown.tsx b/web/src/components/core/SimpleDropdown.tsx new file mode 100644 index 0000000000..bd248338db --- /dev/null +++ b/web/src/components/core/SimpleDropdown.tsx @@ -0,0 +1,133 @@ +/* + * 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, { useState } from "react"; +import { + Divider, + Dropdown, + DropdownGroup, + DropdownItem, + DropdownList, + MenuToggle, +} from "@patternfly/react-core"; +import Icon from "~/components/layout/Icon"; + +/** + * Represents a single action item in a {@link SimpleDropdown} menu. + */ +type SimpleDropdownItem = { + /** Label to display for the action. */ + title: React.ReactNode; + /** Callback invoked when the action is clicked. */ + onClick: () => void; + /** + * When true, renders the item in a danger style. + * Typically used for destructive actions such as formatting or deleting. + */ + isDanger?: boolean; +}; + +/** + * Props for the `SimpleDropdown` component. + */ +type SimpleDropdownProps = { + /** + * Actions to display in the dropdown menu. + * + * Each action requires a `title` and an `onClick` handler. The optional + * `isDanger` flag renders the item in a danger style, typically used for + * destructive actions. + */ + items: SimpleDropdownItem[]; + /** + * Accessible label for the toggle button. + * + * Also rendered as the dropdown group label when the menu is open, + * providing visual and accessible context about which row or entity + * the actions belong to. + */ + label: string; + /** + * Props to pass to the PF Dropdown popper for controlling positioning. + * Defaults to `{ position: "right" }` to align the menu to the right + * edge of the toggle, which is the standard behavior for action menus + * in tables. + */ + popperProps?: React.ComponentProps["popperProps"]; +}; + +/** + * A plain dropdown menu with a "more actions" toggle and a labeled group. + * + * Intended as a (temporary?) replacement for PatternFly's `ActionsColumn` when + * a group label is needed to provide context about which row the actions apply + * to. The label is shown both as the `aria-label` of the toggle button and as + * the visible group header inside the open menu. + * + * @example + * ```tsx + * activate() }, + * { title: "Format", onClick: () => format(), isDanger: true }, + * ]} + * /> + * ``` + */ +export default function SimpleDropdown({ + items, + label, + popperProps = { position: "right" }, +}: SimpleDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={(isOpen) => setIsOpen(isOpen)} + popperProps={popperProps} + toggle={(toggleRef) => ( + setIsOpen(!isOpen)} + variant="plain" + aria-label={label} + > + + + )} + > + + + + {items.map(({ title, onClick, isDanger }, i) => ( + + {title} + + ))} + + + + ); +} diff --git a/web/src/components/core/SimpleSelector.test.tsx b/web/src/components/core/SimpleSelector.test.tsx new file mode 100644 index 0000000000..bd11f4fbcf --- /dev/null +++ b/web/src/components/core/SimpleSelector.test.tsx @@ -0,0 +1,125 @@ +/* + * 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, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { _ } from "~/i18n"; +import SimpleSelector from "./SimpleSelector"; + +const onChangeFn = jest.fn(); + +describe("SimpleSelector", () => { + const selectOptions = { + all: _("All"), + active: _("Active"), + offline: _("Offline"), + }; + + it("renders a select menu with given options", async () => { + const { user } = plainRender( + , + ); + + // Not using the label name to retrieve the MenuToggle button because of a bug + // in PF/MenuToggle, check + // https://github.com/patternfly/patternfly-react/issues/11805 + const toggle = screen.getByRole("button"); + await user.click(toggle); + + const options = screen.getByRole("listbox"); + within(options).getByRole("option", { name: "All" }); + within(options).getByRole("option", { name: "Active" }); + within(options).getByRole("option", { name: "Offline" }); + }); + + it("renders the given label", () => { + plainRender( + , + ); + + screen.getByText("Status"); + }); + + it("renders the current value in the toggle", () => { + plainRender( + , + ); + + const toggle = screen.getByRole("button"); + expect(toggle).toHaveTextContent("Active"); + }); + + it("calls onChange when an option is selected", async () => { + const { user } = plainRender( + , + ); + + const toggle = screen.getByRole("button"); + await user.click(toggle); + + const options = screen.getByRole("listbox"); + const activeOption = within(options).getByRole("option", { name: "Active" }); + await user.click(activeOption); + + expect(onChangeFn).toHaveBeenCalledWith(expect.anything(), "active"); + }); + + it("closes the menu after selecting an option", async () => { + const { user } = plainRender( + , + ); + + const toggle = screen.getByRole("button"); + await user.click(toggle); + const options = screen.getByRole("listbox"); + expect(options).toBeVisible(); + + await user.click(within(options).getByRole("option", { name: "Active" })); + expect(options).not.toBeVisible(); + }); +}); diff --git a/web/src/components/storage/dasd/StatusFilter.tsx b/web/src/components/core/SimpleSelector.tsx similarity index 67% rename from web/src/components/storage/dasd/StatusFilter.tsx rename to web/src/components/core/SimpleSelector.tsx index 3e0700cb08..b39a7bc6d2 100644 --- a/web/src/components/storage/dasd/StatusFilter.tsx +++ b/web/src/components/core/SimpleSelector.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2026] SUSE LLC * * All Rights Reserved. * @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useId, useState } from "react"; import { Flex, MenuToggle, @@ -30,53 +30,45 @@ import { SelectOption, SelectProps, } from "@patternfly/react-core"; + import Text from "~/components/core/Text"; -import { N_, _ } from "~/i18n"; -type StatusFilterProps = { +import type { TranslatedString } from "~/i18n"; + +type SimpleSelectorProps = { + label: TranslatedString; value: string; + options: Record; onChange: SelectProps["onSelect"]; }; -const options = { - all: N_("all"), - active: N_("active"), - read_only: N_("read_only"), - offline: N_("offline"), -}; - -const ID = "dasd-status-filter"; - /** - * Select component for filtering DASD devices by status. - * - * Renders a PF/Select input allowing users to choose one of the available DASD - * statuses: "active", "read_only", "offline", or "all". The selected value is - * passed to the parent via the `onChange` callback along with the event - * originating the action. + * Wrapper component for simplifying PF/Select usage for simple dropdowns. * - * Used as part of the DASD table filtering toolbar. + * Renders a PF/Select input allowing users to choose one of the available + * options. The selected value is passed to the parent via the `onChange` + * callback along with the event originating the action. * * @privateRemarks * There is an issue with a11y label for the PF/MenuToggle, check * https://github.com/patternfly/patternfly-react/issues/11805 */ -export default function StatusFilter({ value, onChange }: StatusFilterProps) { +export default function SimpleSelector({ label, value, options, onChange }: SimpleSelectorProps) { + const id = useId(); const [isOpen, setIsOpen] = useState(false); const onToggle = () => setIsOpen(!isOpen); const toggle = (toggleRef: React.Ref) => ( - - {/* eslint-disable agama-i18n/string-literals */} - {_(options[value])} + + {options[value]} ); return ( -