From 8f7cfdb62cf95cadb45a237b57835e9aaec4efae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Feb 2026 16:22:44 +0000 Subject: [PATCH 01/46] Extract schema of DASD config - It makes easier to generate the types for the UI. --- rust/agama-lib/share/dasd.schema.json | 41 ++++++++++++++++++++++++ rust/agama-lib/share/profile.schema.json | 35 +------------------- rust/package/agama.spec | 1 + 3 files changed, 43 insertions(+), 34 deletions(-) create mode 100644 rust/agama-lib/share/dasd.schema.json diff --git a/rust/agama-lib/share/dasd.schema.json b/rust/agama-lib/share/dasd.schema.json new file mode 100644 index 0000000000..37b3b6ac3e --- /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/iscsi.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": { + "decription": "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/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 37ad53666f..b4bd551687 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -94,40 +94,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.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 From 3e384914a0319b3bbfebd2c31e8f4686488bba50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Feb 2026 16:24:17 +0000 Subject: [PATCH 02/46] Add model and hooks for DASD --- web/src/hooks/model/config/dasd.ts | 60 ++++++++++++++++++++++++++++++ web/src/hooks/model/system.ts | 3 +- web/src/hooks/model/system/dasd.ts | 37 ++++++++++++++++++ web/src/model/config.ts | 6 ++- web/src/model/config/dasd.ts | 43 +++++++++++++++++++++ web/src/model/system.ts | 6 ++- web/src/model/system/dasd.ts | 34 +++++++++++++++++ web/src/openapi/config/dasd.ts | 30 +++++++++++++++ web/src/openapi/system/dasd.ts | 26 +++++++++++++ 9 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 web/src/hooks/model/config/dasd.ts create mode 100644 web/src/hooks/model/system/dasd.ts create mode 100644 web/src/model/config/dasd.ts create mode 100644 web/src/model/system/dasd.ts create mode 100644 web/src/openapi/config/dasd.ts create mode 100644 web/src/openapi/system/dasd.ts diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts new file mode 100644 index 0000000000..ae172557f1 --- /dev/null +++ b/web/src/hooks/model/config/dasd.ts @@ -0,0 +1,60 @@ +/* + * 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 { useSuspenseQuery } from "@tanstack/react-query"; +import { configQuery } from "~/hooks/model/config"; +import { patchConfig, Response } from "~/api"; +import dasd from "~/model/config/dasd"; +import type { Config, DASD } from "~/model/config"; + +const selectConfig = (data: Config | null): DASD.Config => data?.dasd; + +function useConfig(): DASD.Config | null { + const { data } = useSuspenseQuery({ + ...configQuery, + select: selectConfig, + }); + return data; +} + +const addDevice = (config: DASD.Config | null, device: DASD.Device): DASD.Config => + config ? dasd.addDevice(config, device) : { devices: [device] }; + +type addDeviceFn = (device: DASD.Device) => Response; + +function useAddDevice(): addDeviceFn { + const config = useConfig(); + return (device: DASD.Device) => patchConfig({ dasd: addDevice(config, device) }); +} + +const removeDevice = (config: DASD.Config | null, channel: string): DASD.Config => + config ? dasd.removeDevice(config, channel) : {}; + +type removeDeviceFn = (name: string) => Response; + +function useRemoveDevice(): removeDeviceFn { + const config = useConfig(); + return (channel: string) => patchConfig({ dasd: removeDevice(config, channel) }); +} + +export { useConfig, useAddDevice, useRemoveDevice }; +export type { addDeviceFn, removeDeviceFn }; diff --git a/web/src/hooks/model/system.ts b/web/src/hooks/model/system.ts index 4fe6ab2dd2..d3b628d7ac 100644 --- a/web/src/hooks/model/system.ts +++ b/web/src/hooks/model/system.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -57,3 +57,4 @@ export { systemQuery, useSystem, useSystemChanges }; export * as l10n from "~/hooks/model/system/l10n"; export * as storage from "~/hooks/model/system/storage"; export * as software from "~/hooks/model/system/software"; +export * as dasd from "~/hooks/model/system/dasd"; diff --git a/web/src/hooks/model/system/dasd.ts b/web/src/hooks/model/system/dasd.ts new file mode 100644 index 0000000000..6f626ddfc2 --- /dev/null +++ b/web/src/hooks/model/system/dasd.ts @@ -0,0 +1,37 @@ +/* + * 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 { useSuspenseQuery } from "@tanstack/react-query"; +import { systemQuery } from "~/hooks/model/system"; +import type { System, DASD } from "~/model/system"; + +const selectSystem = (data: System | null): DASD.System => data?.dasd; + +function useSystem(): DASD.System | null { + const { data } = useSuspenseQuery({ + ...systemQuery, + select: selectSystem, + }); + return data; +} + +export { useSystem }; diff --git a/web/src/model/config.ts b/web/src/model/config.ts index bfbd86aac6..96e30fc7ab 100644 --- a/web/src/model/config.ts +++ b/web/src/model/config.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -28,6 +28,7 @@ import type * as Software from "~/model/config/software"; import type * as User from "~/model/config/user"; import type * as Root from "~/model/config/root"; import type * as Storage from "~/openapi/config/storage"; +import type * as DASD from "~/openapi/config/dasd"; type Config = { hostname?: Hostname.Config; @@ -35,9 +36,10 @@ type Config = { network?: Network.Config; product?: Product.Config; storage?: Storage.Config; + dasd?: DASD.Config; software?: Software.Config; user?: User.Config; root?: Root.Config; }; -export type { Config, Hostname, Product, L10n, Network, Storage, User, Root }; +export type { Config, Hostname, Product, L10n, Network, Storage, User, Root, DASD }; diff --git a/web/src/model/config/dasd.ts b/web/src/model/config/dasd.ts new file mode 100644 index 0000000000..7987e1d840 --- /dev/null +++ b/web/src/model/config/dasd.ts @@ -0,0 +1,43 @@ +/* + * 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 { Config, Device } from "~/openapi/config/dasd"; + +function findDeviceIndex(config: Config, channel: string): number | undefined { + return config.devices?.findIndex((d) => d.channel === channel); +} + +function findDevice(config: Config, channel: string): Device | undefined { + return config.devices?.find((d) => d.channel === channel); +} + +function addDevice(config: Config, device: Device): Config { + return { devices: [...(config.devices || []), device] }; +} + +function removeDevice(config: Config, channel: string): Config { + const devices = config.devices.filter((d) => d.channel !== channel); + return { ...config, devices }; +} + +export default { findDeviceIndex, findDevice, addDevice, removeDevice }; +export type * from "~/openapi/config/dasd"; diff --git a/web/src/model/system.ts b/web/src/model/system.ts index 34638860a3..1730e32c30 100644 --- a/web/src/model/system.ts +++ b/web/src/model/system.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -26,6 +26,7 @@ import type * as L10n from "~/model/system/l10n"; import type * as Network from "~/model/system/network"; import type * as Software from "~/model/system/software"; import type * as Storage from "~/model/system/storage"; +import type * as DASD from "~/model/system/dasd"; type System = { hardware?: Hardware.System; @@ -35,6 +36,7 @@ type System = { products?: Product[]; software?: Software.System; storage?: Storage.System; + dasd?: DASD.System; }; type Product = { @@ -70,4 +72,4 @@ type Mode = { description: string; }; -export type { System, Product, L10n, Hardware, Hostname, Mode, Network, Software, Storage }; +export type { System, Product, L10n, Hardware, Hostname, Mode, Network, Software, Storage, DASD }; diff --git a/web/src/model/system/dasd.ts b/web/src/model/system/dasd.ts new file mode 100644 index 0000000000..ac54e61b4f --- /dev/null +++ b/web/src/model/system/dasd.ts @@ -0,0 +1,34 @@ +/* + * 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 { System, Device } from "~/openapi/system/dasd"; + +function findDeviceIndex(system: System, channel: string): number | undefined { + return system.devices?.findIndex((t) => t.channel === channel); +} + +function findDevice(system: System, channel: string): Device | undefined { + return system.devices?.find((t) => t.channel === channel); +} + +export default { findDeviceIndex, findDevice }; +export type * from "~/openapi/system/dasd"; diff --git a/web/src/openapi/config/dasd.ts b/web/src/openapi/config/dasd.ts new file mode 100644 index 0000000000..9a49a20050 --- /dev/null +++ b/web/src/openapi/config/dasd.ts @@ -0,0 +1,30 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * DASD config. + */ +export interface Config { + /** + * List of DASD devices. + */ + devices?: Device[]; +} +export interface Device { + channel: string; + /** + * Specify target state of device. Either activate it or deactivate it. + */ + state?: "active" | "offline"; + /** + * If device should be formatted. If not specified then it format device only if not already formatted. + */ + format?: boolean; + /** + * If device have set diag flag. If not specified then it keep what device has before. + */ + diag?: boolean; +} diff --git a/web/src/openapi/system/dasd.ts b/web/src/openapi/system/dasd.ts new file mode 100644 index 0000000000..c610eda686 --- /dev/null +++ b/web/src/openapi/system/dasd.ts @@ -0,0 +1,26 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * API description of the DASD system + */ +export interface System { + /** + * DASD devices + */ + devices: Device[]; +} +export interface Device { + channel: string; + deviceName: string; + type: string; + diag: boolean; + accessType: string; + partitionInfo: string; + status: string; + active: boolean; + formatted: boolean; +} From 04730e7f977be66b228f0a0abba507cb62b9a4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 14 Feb 2026 18:42:52 +0000 Subject: [PATCH 03/46] fix(web): please linter --- web/src/model/config.ts | 2 +- web/src/model/system.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/web/src/model/config.ts b/web/src/model/config.ts index aa92de4706..ad81a4157c 100644 --- a/web/src/model/config.ts +++ b/web/src/model/config.ts @@ -44,4 +44,4 @@ type Config = { root?: Root.Config; }; -export type { Config, Hostname, Product, L10n, Network, Storage, User, Root, ISCSI, DASD }; \ No newline at end of file +export type { Config, Hostname, Product, L10n, Network, Storage, User, Root, ISCSI, DASD }; diff --git a/web/src/model/system.ts b/web/src/model/system.ts index 7d10fdf431..5345c35761 100644 --- a/web/src/model/system.ts +++ b/web/src/model/system.ts @@ -74,4 +74,16 @@ type Mode = { description: string; }; -export type { System, Product, L10n, Hardware, Hostname, Mode, Network, Software, Storage, ISCSI, DASD }; +export type { + System, + Product, + L10n, + Hardware, + Hostname, + Mode, + Network, + Software, + Storage, + ISCSI, + DASD, +}; From cbb75252762e10b18d63cab19775e9057fac804b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 14 Feb 2026 20:02:27 +0000 Subject: [PATCH 04/46] fix(web): fix and test model/config/dassd --- web/src/model/config/dasd.test.ts | 116 ++++++++++++++++++++++++++++++ web/src/model/config/dasd.ts | 22 +++--- 2 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 web/src/model/config/dasd.test.ts diff --git a/web/src/model/config/dasd.test.ts b/web/src/model/config/dasd.test.ts new file mode 100644 index 0000000000..e1c49e18d6 --- /dev/null +++ b/web/src/model/config/dasd.test.ts @@ -0,0 +1,116 @@ +/* + * 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 dasdModel from "~/model/config/dasd"; +import type { Config, Device } from "~/model/config/dasd"; + +type ConfigStructurePreservationTest = Config & { + futureProperty: "must_be_preserved"; +}; + +const mockDASDDevice: Device = { + channel: "0.0.0160", + state: "offline", + format: false, + diag: true, +}; + +const mockInitialDASDConfig: Config = { + devices: [mockDASDDevice], +}; + +describe("model/storage/dasd", () => { + describe("#addDevice", () => { + it("preserves existing config properties while adding the device", () => { + const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; + + const initialConfig = { + ...mockInitialDASDConfig, + futureProperty: "must_be_preserved", + } as ConfigStructurePreservationTest; + + const newConfig = dasdModel.addDevice( + initialConfig, + deviceToAdd, + ) as ConfigStructurePreservationTest; + + expect(newConfig).not.toBe(initialConfig); + expect(newConfig.futureProperty).toBe("must_be_preserved"); + }); + + describe("when config already has devices", () => { + it("appends the new device to the existing list", () => { + const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; + + const newConfig = dasdModel.addDevice(mockInitialDASDConfig, deviceToAdd); + expect(newConfig).not.toBe(mockInitialDASDConfig); + expect(newConfig.devices).toContain(mockDASDDevice); + expect(newConfig.devices).toContain(deviceToAdd); + }); + }); + + describe("when config devices are empty or undefined", () => { + it("returns a config containing only the new device", () => { + const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; + + expect(dasdModel.addDevice({}, deviceToAdd)).toEqual({ + devices: [deviceToAdd], + }); + + expect(dasdModel.addDevice({ devices: [] }, deviceToAdd)).toEqual({ + devices: [deviceToAdd], + }); + + expect(dasdModel.addDevice({ devices: undefined }, deviceToAdd)).toEqual({ + devices: [deviceToAdd], + }); + + expect(dasdModel.addDevice({ devices: null }, deviceToAdd)).toEqual({ + devices: [deviceToAdd], + }); + }); + }); + }); + + describe("#removeDevice", () => { + it("preserves existing config properties while removing the device", () => { + const initialConfig = { + ...mockInitialDASDConfig, + futureProperty: "must_be_preserved", + } as ConfigStructurePreservationTest; + + const newConfig = dasdModel.removeDevice( + initialConfig, + mockDASDDevice.channel, + ) as ConfigStructurePreservationTest; + + expect(newConfig).not.toBe(initialConfig); + expect(newConfig.futureProperty).toBe("must_be_preserved"); + }); + + it("returns a new config with the specified device removed", () => { + const newConfig = dasdModel.removeDevice(mockInitialDASDConfig, mockDASDDevice.channel); + expect(newConfig).not.toBe(mockInitialDASDConfig); + expect(newConfig.devices).not.toContain(mockDASDDevice); + }); + }); +}); diff --git a/web/src/model/config/dasd.ts b/web/src/model/config/dasd.ts index 7987e1d840..8803e39ecb 100644 --- a/web/src/model/config/dasd.ts +++ b/web/src/model/config/dasd.ts @@ -20,24 +20,22 @@ * find current contact information at www.suse.com. */ +import { concat } from "radashi"; import { Config, Device } from "~/openapi/config/dasd"; -function findDeviceIndex(config: Config, channel: string): number | undefined { - return config.devices?.findIndex((d) => d.channel === channel); -} - -function findDevice(config: Config, channel: string): Device | undefined { - return config.devices?.find((d) => d.channel === channel); -} - function addDevice(config: Config, device: Device): Config { - return { devices: [...(config.devices || []), device] }; + return { + ...config, + devices: concat(config.devices, device), + }; } function removeDevice(config: Config, channel: string): Config { - const devices = config.devices.filter((d) => d.channel !== channel); - return { ...config, devices }; + return { + ...config, + devices: config.devices.filter((d) => d.channel !== channel), + }; } -export default { findDeviceIndex, findDevice, addDevice, removeDevice }; +export default { addDevice, removeDevice }; export type * from "~/openapi/config/dasd"; From de7ce9ffecea83dc17b016d4eb8dc9ad0680176f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 14 Feb 2026 21:09:21 +0000 Subject: [PATCH 05/46] fix(web): remove dead code and begin adapting existing UI Apply only the minimal required changes (including commenting out lines) to satisfy linters and the test suite, providing a clean baseline for adapting the DASD web UI to the new API. Upcoming work will likely require updating the DASDTable component to support two modes: config and system, and to handle both device types accordingly. --- .../components/storage/ProposalPage.test.tsx | 4 - .../storage/dasd/DASDFormatProgress.test.tsx | 42 +-- .../storage/dasd/DASDFormatProgress.tsx | 30 +- web/src/components/storage/dasd/DASDPage.tsx | 6 +- .../storage/dasd/DASDTable.test.tsx | 33 +-- web/src/components/storage/dasd/DASDTable.tsx | 170 +++++------ .../storage/dasd/FormatActionHandler.test.tsx | 48 ++-- .../storage/dasd/FormatActionHandler.tsx | 29 +- .../components/storage/dasd/FormatFilter.tsx | 4 +- web/src/model/storage/dasd.ts | 92 ------ web/src/model/system/dasd.ts | 11 - web/src/queries/storage/dasd.ts | 267 ------------------ web/src/routes/storage.tsx | 12 +- web/src/types/dasd.ts | 47 --- 14 files changed, 192 insertions(+), 603 deletions(-) delete mode 100644 web/src/model/storage/dasd.ts delete mode 100644 web/src/queries/storage/dasd.ts delete mode 100644 web/src/types/dasd.ts diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index 8ea6b5df9c..ae11761315 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -97,10 +97,6 @@ jest.mock("~/queries/storage/zfcp", () => ({ })); const mockUseDASDSupported = jest.fn(); -jest.mock("~/queries/storage/dasd", () => ({ - ...jest.requireActual("~/queries/storage/dasd"), - useDASDSupported: () => mockUseDASDSupported(), -})); jest.mock("./ProposalFailedInfo", () => () =>
proposal failed info
); jest.mock("./UnsupportedModelInfo", () => () =>
unsupported model info
); diff --git a/web/src/components/storage/dasd/DASDFormatProgress.test.tsx b/web/src/components/storage/dasd/DASDFormatProgress.test.tsx index 272667720b..9815d64aca 100644 --- a/web/src/components/storage/dasd/DASDFormatProgress.test.tsx +++ b/web/src/components/storage/dasd/DASDFormatProgress.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2026] SUSE LLC * * All Rights Reserved. * @@ -23,18 +23,28 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; +import type { Device } from "~/model/system/dasd"; + import DASDFormatProgress from "./DASDFormatProgress"; -import { DASDDevice, FormatJob } from "~/types/dasd"; -let mockDASDFormatJobs: FormatJob[]; -let mockDASDDevices: DASDDevice[]; +// FIXME: adapt to new API +type FormatSummary = { + total: number; + step: number; + done: boolean; +}; -jest.mock("~/queries/storage/dasd", () => ({ - useDASDRunningFormatJobs: () => mockDASDFormatJobs, - useDASDDevices: () => mockDASDDevices, -})); +type FormatJob = { + jobId: string; + summary?: { [key: string]: FormatSummary }; +}; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +let mockDASDFormatJobs: FormatJob[]; +let mockDASDDevices: Device[]; -describe("DASDFormatProgress", () => { +// Skipped during migration to v2 +describe.skip("DASDFormatProgress", () => { describe("when there is already some progress", () => { beforeEach(() => { mockDASDFormatJobs = [ @@ -52,16 +62,15 @@ describe("DASDFormatProgress", () => { mockDASDDevices = [ { - id: "0.0.0200", - enabled: false, + channel: "0.0.0200", + active: false, deviceName: "dasda", - deviceType: "eckd", + type: "eckd", formatted: false, diag: false, status: "active", accessType: "rw", partitionInfo: "1", - hexId: 0x200, }, ]; }); @@ -79,16 +88,15 @@ describe("DASDFormatProgress", () => { mockDASDDevices = [ { - id: "0.0.0200", - enabled: false, + channel: "0.0.0200", + active: false, deviceName: "dasda", - deviceType: "eckd", + type: "eckd", formatted: false, diag: false, status: "active", accessType: "rw", partitionInfo: "1", - hexId: 0x200, }, ]; }); diff --git a/web/src/components/storage/dasd/DASDFormatProgress.tsx b/web/src/components/storage/dasd/DASDFormatProgress.tsx index 627e86e1c0..7877781f7b 100644 --- a/web/src/components/storage/dasd/DASDFormatProgress.tsx +++ b/web/src/components/storage/dasd/DASDFormatProgress.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2026] SUSE LLC * * All Rights Reserved. * @@ -24,26 +24,36 @@ import React from "react"; import { Progress, Stack } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { _ } from "~/i18n"; -import { useDASDDevices, useDASDRunningFormatJobs } from "~/queries/storage/dasd"; -import { DASDDevice, FormatSummary } from "~/types/dasd"; -const DeviceProgress = ({ device, progress }: { device: DASDDevice; progress: FormatSummary }) => ( +import type { Device } from "~/model/system/dasd"; + +// FIXME: adapt to new API +type FormatSummary = { + total: number; + step: number; + done: boolean; +}; + +type FormatJob = { + jobId: string; + summary?: { [key: string]: FormatSummary }; +}; + +const DeviceProgress = ({ device, progress }: { device: Device; progress: FormatSummary }) => ( ); export default function DASDFormatProgress() { - const devices = useDASDDevices(); - const runningJobs = useDASDRunningFormatJobs().filter( - (job) => Object.keys(job.summary || {}).length > 0, - ); + const devices = []; // FIXME: use APIv2 equivalent to useDASDDevices(); + const runningJobs: FormatJob[] = []; // FIXME use APIv2 equivalent to useDASDRunningFormatJobs() return ( 0} disableFocusTrap> diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index 64e2a072fb..6b097a8538 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -24,13 +24,13 @@ import React from "react"; import { Page } from "~/components/core"; import DASDTable from "./DASDTable"; import DASDFormatProgress from "./DASDFormatProgress"; -import { useDASDDevicesChanges, useDASDFormatJobChanges } from "~/queries/storage/dasd"; import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; export default function DASDPage() { - useDASDDevicesChanges(); - useDASDFormatJobChanges(); + // FIXME: use the API v2 equivalent + // useDASDDevicesChanges(); + // useDASDFormatJobChanges(); return ( diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index 8d38180762..9563d142e8 100644 --- a/web/src/components/storage/dasd/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024-2025] SUSE LLC + * Copyright (c) [2024-2026] SUSE LLC * * All Rights Reserved. * @@ -23,10 +23,12 @@ import React, { act } from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { DASDDevice } from "~/types/dasd"; import DASDTable from "./DASDTable"; -let mockDASDDevices: DASDDevice[] = []; +// FIXME: use a test for DASDTAble holding config devices too. +import type { Device } from "~/model/system/dasd"; + +let mockDASDDevices: Device[] = []; let eventCallback; const mockClient = { onEvent: jest.fn().mockImplementation((cb) => { @@ -40,45 +42,36 @@ jest.mock("~/context/installer", () => ({ useInstallerClient: () => mockClient, })); -jest.mock("~/queries/storage/dasd", () => ({ - useDASDDevices: () => mockDASDDevices, - useDASDMutation: () => ({ - mutate: jest.fn(), - }), - useFormatDASDMutation: () => jest.fn(), -})); - jest.mock("~/components/storage/dasd/FormatActionHandler", () => () => (
FormatActionHandler Mock
)); -describe("DASDTable", () => { +// Skipped during migration to v2 +describe.skip("DASDTable", () => { describe("when there is some DASD devices available", () => { beforeEach(() => { mockDASDDevices = [ { - id: "0.0.0160", - enabled: false, + channel: "0.0.0160", + active: false, deviceName: "", - deviceType: "", + type: "", formatted: false, diag: false, status: "offline", accessType: "", partitionInfo: "", - hexId: 0x160, }, { - id: "0.0.0200", - enabled: true, + channel: "0.0.0200", + active: true, deviceName: "dasda", - deviceType: "eckd", + type: "eckd", formatted: false, diag: false, status: "active", accessType: "rw", partitionInfo: "1", - hexId: 0x200, }, ]; }); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index a06461d848..d9d41addfd 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2025] SUSE LLC + * Copyright (c) [2023-2026] SUSE LLC * * All Rights Reserved. * @@ -40,19 +40,14 @@ import FormatFilter from "~/components/storage/dasd/FormatFilter"; import SelectableDataTable, { SortedBy } from "~/components/core/SelectableDataTable"; import StatusFilter from "~/components/storage/dasd/StatusFilter"; import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; -import { DASDDevice } from "~/types/dasd"; -import { - DASDMutationFn, - DASDMutationFnProps, - useDASDDevices, - useDASDMutation, -} from "~/queries/storage/dasd"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { useInstallerClient } from "~/context/installer"; import { hex, sortCollection } from "~/utils"; import { _, n_ } from "~/i18n"; +import type { Device } from "~/model/config/dasd"; + /** * Filter options for narrowing down DASD devices shown in the table. * @@ -60,14 +55,14 @@ import { _, n_ } from "~/i18n"; */ export type DASDDevicesFilters = { /** Lower bound for channel ID filtering (inclusive). */ - minChannel?: DASDDevice["id"]; + minChannel?: Device["channel"]; /** Upper bound for channel ID filtering (inclusive). */ - maxChannel?: DASDDevice["id"]; - /** Only show devices with this status (e.g. "read_only", "offline"). */ - status?: DASDDevice["status"]; - /** Filter by formatting status: "yes" (formatted), "no" (not formatted), or + maxChannel?: Device["channel"]; + /** Only show devices with this status (e.g. "active", "offline"). */ + state?: "all" | Device["state"]; + /** Filter by formatting status: "yes" (to be formatted), "no" (not to be formatted), or * "all" (all devices). */ - formatted?: "all" | "yes" | "no"; + format?: "all" | "yes" | "no"; }; /** @@ -77,16 +72,16 @@ export type DASDDevicesFilters = { * Used internally to compose filter logic when narrowing down the list of * devices shown in the DASD table. */ -type DASDDeviceCondition = (device: DASDDevice) => boolean; +type DASDDeviceCondition = (device: Device) => boolean; /** * Props required to generate bulk actions for selected DASD devices. */ type DASDActionsBuilderProps = { /** The list of selected DASD devices. */ - devices: DASDDevice[]; + devices: Device[]; /** Mutation function used to trigger backend updates (e.g. enable, disable). */ - updater: DASDMutationFn; + updater: (options: unknown) => void; // FIXME: adapt former DASDMutationFn to its API v2 equivalent; /** State dispatcher for triggering actions */ dispatcher: (props: DASDTableAction) => void; }; @@ -94,29 +89,29 @@ type DASDActionsBuilderProps = { /** * Filters an array of devices based on given filters. * - * @param devices - The array of DASDDevice objects to filter. + * @param devices - The array of DASD Device objects to filter. * @param filters - The filters to apply. - * @returns The filtered array of DASDDevice objects matching all conditions. + * @returns The filtered array of DASD Device objects matching all conditions. */ -const filterDevices = (devices: DASDDevice[], filters: DASDDevicesFilters): DASDDevice[] => { - const { minChannel, maxChannel, status, formatted } = filters; +const filterDevices = (devices: Device[], filters: DASDDevicesFilters): Device[] => { + const { minChannel, maxChannel, state, format } = filters; const conditions: DASDDeviceCondition[] = []; if (minChannel || maxChannel) { - const allChannels = devices.map((d) => d.hexId); + const allChannels = devices.map((d) => hex(d.channel)); // FIXME: review te hexId stuff.. const min = hex(minChannel) || Math.min(...allChannels); const max = hex(maxChannel) || Math.max(...allChannels); - conditions.push((d) => d.hexId >= min && d.hexId <= max); + conditions.push((d) => hex(d.channel) >= min && hex(d.channel) <= max); } - if (status && status !== "all") { - conditions.push((d) => d.status === status); + if (state && state !== "all") { + conditions.push((d) => d.state === state); } - if (formatted === "yes" || formatted === "no") { - conditions.push((d) => (formatted === "yes" ? d.formatted : !d.formatted)); + if (format === "yes" || format === "no") { + conditions.push((d) => (format === "yes" ? d.format : !d.format)); } return devices.filter((device) => conditions.every((conditionFn) => conditionFn(device))); @@ -130,7 +125,7 @@ const filterDevices = (devices: DASDDevice[], filters: DASDDevicesFilters): DASD * others (like format) dispatch updates via `dispatcher`. */ const buildActions = ({ devices, updater, dispatcher }: DASDActionsBuilderProps) => { - const ids = devices.map((d) => d.id); + const ids = devices.map((d) => d.channel); return [ { title: _("Activate"), @@ -182,13 +177,10 @@ const FiltersToolbar = ({ filters, onFilterChange }: FiltersToolbarProps) => ( - onFilterChange("status", v)} /> + onFilterChange("state", v)} /> - onFilterChange("formatted", v)} - /> + onFilterChange("format", v)} /> - action.payload.id === dev.id ? action.payload : dev, + action.payload.channel === dev.channel ? action.payload : dev, ); const devicesToFormat = state.devicesToFormat.map((dev) => - action.payload.id === dev.id ? action.payload : dev, + action.payload.channel === dev.channel ? action.payload : dev, ); return { ...state, selectedDevices, devicesToFormat }; } @@ -434,62 +426,73 @@ const createColumns = () => [ { // TRANSLATORS: table header for a DASD devices table name: _("Channel ID"), - value: (d: DASDDevice) => d.id, - sortingKey: "hexId", // uses the hexadecimal representation for sorting + value: (d: Device) => d.channel, + // FIXME: Needs to be rethink with the new types. Most probably an specific type + // for the table should be created + // sortingKey: "hexId", // uses the hexadecimal representation for sorting }, { // TRANSLATORS: table header for a DASD devices table - name: _("Status"), - value: (d: DASDDevice) => d.status, - sortingKey: "status", - }, - { - // TRANSLATORS: table header for a DASD devices table - name: _("Device"), - value: (d: DASDDevice) => d.deviceName, - sortingKey: "deviceName", - }, - { - // TRANSLATORS: table header for a DASD devices table - name: _("Type"), - value: (d: DASDDevice) => d.deviceType, - sortingKey: "deviceType", + name: _("State"), + value: (d: Device) => d.state, + sortingKey: "state", }, + // FIXME: reactivate for the DASD system devices table + // { + // // TRANSLATORS: table header for a DASD devices table + // name: _("Device"), + // value: (d: DASDDevice) => d.deviceName, + // sortingKey: "deviceName", + // }, + // + // FIXME: reactivate for the DASD system devices table + // { + // // TRANSLATORS: table header for a DASD devices table + // name: _("Type"), + // value: (d: Device) => d.deviceType, + // sortingKey: "deviceType", + // }, + // FIXME: review { // TRANSLATORS: table header for `DIAG access mode` on DASD devices table. // It refers to an special disk access mode on IBM mainframes. Keep // untranslated. name: _("DIAG"), - value: (d: DASDDevice) => { - if (!d.enabled) return ""; + value: (d: Device) => { + if (!d.state) return ""; return d.diag ? _("Yes") : _("No"); }, sortingKey: "diag", }, - { - // TRANSLATORS: table header for a column in a DASD devices table that - // usually contains "Yes" or "No"" values - name: _("Formatted"), - value: (d: DASDDevice) => (d.formatted ? _("Yes") : _("No")), - sortingKey: "formatted", - }, - { - // TRANSLATORS: table header for a DASD devices table - name: _("Partition Info"), - value: (d: DASDDevice) => - // Displays comma-separated partition info as individual lines using
- d.partitionInfo.split(",").map((d: string) =>
{d}
), - sortingKey: "partitionInfo", - }, + // FIXME: reactivate for the DASD system devices table. Most probably for + // system it's formatted and for config just format (to be formatted) + // { + // // TRANSLATORS: table header for a column in a DASD devices table that + // // usually contains "Yes" or "No"" values + // name: _("Formatted"), + // value: (d: Device) => (d.formatted ? _("Yes") : _("No")), + // sortingKey: "formatted", + // }, + // { + // FIXME: reactivate for the DASD system devices table. Most probably for + // // TRANSLATORS: table header for a DASD devices table + // name: _("Partition Info"), + // + // value: (d: Device) => + // // Displays comma-separated partition info as individual lines using
+ // d.partitionInfo.split(",").map((d: string) =>
{d}
), + // sortingKey: "partitionInfo", + // }, ]; export default function DASDTable() { const client = useInstallerClient(); - const devices = useDASDDevices(); - const { mutate: updateDASD } = useDASDMutation(); + const devices = []; // FIXME: use new api useDASDDevices(); + // FIXME: use te equivalent in the new API + // const { mutate: updateDASD } = useDASDMutation(); const [state, dispatch] = useReducer(reducer, initialState); const columns = createColumns(); @@ -514,7 +517,7 @@ export default function DASDTable() { dispatch({ type: "RESET_SELECTION" }); }; - const onSelectionChange = (devices: DASDDevice[]) => { + const onSelectionChange = (devices: Device[]) => { dispatch({ type: "UPDATE_SELECTION", payload: devices }); }; @@ -537,10 +540,7 @@ export default function DASDTable() { * * @param mutation Parameters describing the DASD update operation */ - const updater = (mutation: DASDMutationFnProps) => { - updateDASD(mutation); - dispatch({ type: "START_WAITING", payload: mutation.devices }); - }; + const updater = (options) => console.log("FIXME: implement equivalente for new API", options); return ( @@ -580,7 +580,7 @@ export default function DASDTable() { itemActions={(d) => buildActions({ devices: [d], - updater: updateDASD, + updater, // FIXME dispatcher: dispatch, }) } diff --git a/web/src/components/storage/dasd/FormatActionHandler.test.tsx b/web/src/components/storage/dasd/FormatActionHandler.test.tsx index 8c6323a714..cc7984898a 100644 --- a/web/src/components/storage/dasd/FormatActionHandler.test.tsx +++ b/web/src/components/storage/dasd/FormatActionHandler.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -23,59 +23,55 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; + +import type { Device } from "~/model/system/dasd"; + import FormatActionHandler from "./FormatActionHandler"; const formatDASDMutationMock = jest.fn(); -jest.mock("~/queries/storage/dasd", () => ({ - useFormatDASDMutation: () => ({ - mutate: formatDASDMutationMock, - }), -})); - -const offlineDasdMock = { - id: "0.0.0191", - enabled: false, +const offlineDasdMock: Device = { + channel: "0.0.0191", + active: false, deviceName: "", formatted: false, diag: false, status: "offline", - deviceType: "ECKD", + type: "ECKD", accessType: "rw", partitionInfo: "1", - hexId: 401, }; -const onlineDasdMock = { - id: "0.0.0160", - enabled: true, +const onlineDasdMock: Device = { + channel: "0.0.0160", + active: true, deviceName: "dasda", formatted: true, diag: false, status: "active", - deviceType: "ECKD", + type: "ECKD", accessType: "rw", partitionInfo: "/dev/dasda1 (Linux native), /dev/dasda2 (Linux native), /dev/dasda3 (Linux native)", - hexId: 352, }; -const anotherOnlineDasdMock = { - id: "0.0.0592", - enabled: true, +const anotherOnlineDasdMock: Device = { + channel: "0.0.0592", + active: true, deviceName: "dasdk", formatted: true, diag: false, status: "read_only", - deviceType: "ECKD", + type: "ECKD", accessType: "rw", partitionInfo: "", - hexId: 1426, }; let consoleErrorSpy: jest.SpyInstance; -describe("DASD/FormatActionHandler", () => { +// FIXME: migrate to equivalent APIV2 +// Skipped during migration to v2 +describe.skip("DASD/FormatActionHandler", () => { beforeAll(() => { consoleErrorSpy = jest.spyOn(console, "error"); consoleErrorSpy.mockImplementation(); @@ -113,8 +109,8 @@ describe("DASD/FormatActionHandler", () => { plainRender(); screen.getByText("Format selected devices?"); screen.getByText(/destroy any data stored on the devices/); - screen.getByText(onlineDasdMock.id); - screen.getByText(anotherOnlineDasdMock.id); + screen.getByText(onlineDasdMock.channel); + screen.getByText(anotherOnlineDasdMock.channel); }); it("calls formatDASD and onAccept on user confirmation", async () => { @@ -124,7 +120,7 @@ describe("DASD/FormatActionHandler", () => { ); const confirmButton = screen.getByRole("button", { name: "Format now" }); await user.click(confirmButton); - expect(formatDASDMutationMock).toHaveBeenCalledWith([onlineDasdMock.id]); + expect(formatDASDMutationMock).toHaveBeenCalledWith([onlineDasdMock.channel]); expect(onAccept).toHaveBeenCalled(); }); diff --git a/web/src/components/storage/dasd/FormatActionHandler.tsx b/web/src/components/storage/dasd/FormatActionHandler.tsx index 1ab0a31b87..46cbfc0001 100644 --- a/web/src/components/storage/dasd/FormatActionHandler.tsx +++ b/web/src/components/storage/dasd/FormatActionHandler.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -24,21 +24,21 @@ import React from "react"; import { Content, List, ListItem, Stack } from "@patternfly/react-core"; import Text from "~/components/core/Text"; import Popup from "~/components/core/Popup"; -import { DASDDevice } from "~/types/dasd"; -import { useFormatDASDMutation } from "~/queries/storage/dasd"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { isEmpty } from "radashi"; +import type { Device } from "~/model/config/dasd"; + /** * Shared type for defining props used by all DASD format-related dialogs and * the main controller component. */ type CommonFormatDASDProps = { /** A single DASD device, used for single-device dialogs. */ - device: DASDDevice; + device: Device; /** An array of DASD devices selected for formatting. */ - devices: DASDDevice[]; + devices: Device[]; /** Callback triggered when the user confirms the format operation. */ onCancel?: () => void; /** Callback triggered when the user cancels the operation. */ @@ -50,8 +50,8 @@ type CommonFormatDASDProps = { */ const DevicesList = ({ devices }: Pick) => ( - {devices.map((d: DASDDevice) => ( - {d.id} + {devices.map((d: Device) => ( + {d.channel} ))} ); @@ -65,7 +65,7 @@ const DeviceOffline = ({ onCancel, }: Pick) => { return ( - + {_("It is offline and must be activated before formatting it.")} @@ -84,7 +84,7 @@ const SomeDevicesOffline = ({ devices, onCancel, }: Pick) => { - const offlineDevices = devices.filter((d) => !d.enabled); + const offlineDevices = devices.filter((d) => d.state === "offline"); const totalOffline = offlineDevices.length; return ( @@ -112,7 +112,7 @@ const DeviceFormatConfirmation = ({ onCancel, }: Pick) => { return ( - + {_("This action could destroy any data stored on the device.")} @@ -176,9 +176,10 @@ export default function FormatActionHandler({ onAccept, onCancel, }: Pick) { - const { mutate: formatDASD } = useFormatDASDMutation(); + // TODO: adapt former mutation to its API v2 counterpart + // const { mutate: formatDASD } = useFormatDASDMutation(); const format = () => { - formatDASD(devices.map((d) => d.id)); + // formatDASD(devices.map((d) => d.id)); onAccept(); }; @@ -190,14 +191,14 @@ export default function FormatActionHandler({ if (devices.length === 1) { const device = devices[0]; - if (device.enabled) { + if (device.state === "active") { return ; } else { return ; } } - if (devices.some((d) => !d.enabled)) { + if (devices.some((d) => d.state === "offline")) { return ; } else { return ( diff --git a/web/src/components/storage/dasd/FormatFilter.tsx b/web/src/components/storage/dasd/FormatFilter.tsx index 90faf2183b..d95aa77be9 100644 --- a/web/src/components/storage/dasd/FormatFilter.tsx +++ b/web/src/components/storage/dasd/FormatFilter.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -35,7 +35,7 @@ import { DASDDevicesFilters } from "~/components/storage/dasd/DASDTable"; import { N_, _ } from "~/i18n"; type FormatFilterProps = { - value: DASDDevicesFilters["formatted"]; + value: DASDDevicesFilters["format"]; onChange: SelectProps["onSelect"]; }; diff --git a/web/src/model/storage/dasd.ts b/web/src/model/storage/dasd.ts deleted file mode 100644 index d9317970a3..0000000000 --- a/web/src/model/storage/dasd.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of 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. - */ - -// @todo Move to the new API. - -import { post, get, put } from "~/http"; -import { DASDDevice } from "~/types/dasd"; - -/** - * Returns the list of DASD devices - */ -const fetchDASDDevices = (): Promise => get("/api/storage/dasd/devices"); - -/** - * Returns if DASD is supported at all - */ -const supportedDASD = (): Promise => get("/api/storage/dasd/supported"); - -/** - * probes DASD devices - */ -const probeDASD = () => post("/api/storage/dasd/probe"); - -/** - * Start format job for given list of DASD devices - * @param devicesIDs - array of DASD device ids - * @return id of format job - */ -const formatDASD = (devicesIDs: string[]): Promise => - post("/api/storage/dasd/format", { devices: devicesIDs }).then(({ data }) => data); - -/** - * Enable given list of DASD devices - * - * @param devicesIDs - array of DASD device ids - */ -const enableDASD = (devicesIDs: string[]) => - post("/api/storage/dasd/enable", { devices: devicesIDs }); - -/** - * Disable given list of DASD devices - * - * @param devicesIDs - array of DASD device ids - */ -const disableDASD = (devicesIDs: string[]) => - post("/api/storage/dasd/disable", { devices: devicesIDs }); - -/** - * Enables diag on given list of DASD devices - * - * @param devicesIDs - array of DASD device ids - */ -const enableDiag = (devicesIDs: string[]) => - put("/api/storage/dasd/diag", { devices: devicesIDs, diag: true }); - -/** - * Disables diag on given list of DASD devices - * - * @param devicesIDs - array of DASD device ids - */ -const disableDiag = (devicesIDs: string[]) => - put("/api/storage/dasd/diag", { devices: devicesIDs, diag: false }); - -export { - fetchDASDDevices, - supportedDASD, - formatDASD, - probeDASD, - enableDASD, - disableDASD, - enableDiag, - disableDiag, -}; diff --git a/web/src/model/system/dasd.ts b/web/src/model/system/dasd.ts index ac54e61b4f..257dc83710 100644 --- a/web/src/model/system/dasd.ts +++ b/web/src/model/system/dasd.ts @@ -20,15 +20,4 @@ * find current contact information at www.suse.com. */ -import { System, Device } from "~/openapi/system/dasd"; - -function findDeviceIndex(system: System, channel: string): number | undefined { - return system.devices?.findIndex((t) => t.channel === channel); -} - -function findDevice(system: System, channel: string): Device | undefined { - return system.devices?.find((t) => t.channel === channel); -} - -export default { findDeviceIndex, findDevice }; export type * from "~/openapi/system/dasd"; diff --git a/web/src/queries/storage/dasd.ts b/web/src/queries/storage/dasd.ts deleted file mode 100644 index 44fec7940b..0000000000 --- a/web/src/queries/storage/dasd.ts +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of 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 { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { - disableDASD, - disableDiag, - enableDASD, - enableDiag, - fetchDASDDevices, - formatDASD, - supportedDASD, -} from "~/model/storage/dasd"; -import { useInstallerClient } from "~/context/installer"; -import React from "react"; -import { hex } from "~/utils"; -import { DASDDevice, FormatJob } from "~/types/dasd"; -import { getStorageJobs } from "~/api"; - -/** - * Returns a query for retrieving the dasd devices - */ -const dasdDevicesQuery = () => ({ - queryKey: ["dasd", "devices"], - queryFn: fetchDASDDevices, -}); - -/** - * Hook that returns DASD devices. - */ -const useDASDDevices = () => { - const { data: devices } = useSuspenseQuery(dasdDevicesQuery()); - return devices.map((d) => ({ ...d, hexId: hex(d.id) })); -}; - -const dasdSupportedQuery = { - queryKey: ["dasd", "supported"], - queryFn: supportedDASD, -}; - -/** - * Hook that returns whether DASD is supported. - */ -const useDASDSupported = (): boolean => { - const { data: supported } = useSuspenseQuery(dasdSupportedQuery); - return supported; -}; - -/** - * Returns a query for retrieving the running dasd format jobs - */ -const dasdRunningFormatJobsQuery = () => ({ - queryKey: ["dasd", "formatJobs", "running"], - queryFn: () => - getStorageJobs().then((jobs) => jobs.filter((j) => j.running).map(({ id }) => ({ jobId: id }))), - staleTime: 200, -}); - -/** - * Hook that returns and specific DASD format job. - */ -const useDASDRunningFormatJobs = (): FormatJob[] => { - const { data: jobs } = useSuspenseQuery(dasdRunningFormatJobsQuery()); - return jobs; -}; - -/** - * Listens for DASD format job changes. - */ -const useDASDFormatJobChanges = () => { - const client = useInstallerClient(); - const queryClient = useQueryClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - // TODO: for simplicity we now just invalidate query instead of manually adding, removing or changing devices - switch (event.type) { - case "DASDFormatJobChanged": { - const data = queryClient.getQueryData(["dasd", "formatJobs", "running"]) as FormatJob[]; - const nextData = data.map((job) => { - if (job.jobId !== event.jobId) return job; - - return { - ...job, - summary: { ...job?.summary, ...event.summary }, - }; - }); - queryClient.setQueryData(["dasd", "formatJobs", "running"], nextData); - break; - } - case "JobAdded": { - const formatJob: FormatJob = { jobId: event.job.id }; - const data = queryClient.getQueryData(["dasd", "formatJobs", "running"]) as FormatJob[]; - - queryClient.setQueryData(["dasd", "formatJobs", "running"], [...data, formatJob]); - break; - } - case "JobChanged": { - const { id, running } = event.job; - if (running) return; - const data = queryClient.getQueryData(["dasd", "formatJobs", "running"]) as FormatJob[]; - const nextData = data.filter((j) => j.jobId !== id); - if (data.length !== nextData.length) { - queryClient.setQueryData(["dasd", "formatJobs", "running"], nextData); - } - break; - } - } - }); - }); - - const { data: jobs } = useSuspenseQuery(dasdRunningFormatJobsQuery()); - return jobs; -}; - -/** - * Listens for DASD devices changes. - */ -const useDASDDevicesChanges = () => { - const client = useInstallerClient(); - const queryClient = useQueryClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - switch (event.type) { - case "DASDDeviceAdded": { - const device: DASDDevice = event.device; - queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { - return [...prev, device]; - }); - break; - } - case "DASDDeviceRemoved": { - const device: DASDDevice = event.device; - const { id } = device; - queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { - const res = prev.filter((dev) => dev.id !== id); - return res; - }); - break; - } - case "DASDDeviceChanged": { - const device: DASDDevice = event.device; - const { id } = device; - queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { - // deep copy of original to have it immutable - const res = [...prev]; - const index = res.findIndex((dev) => dev.id === id); - res[index] = device; - return res; - }); - break; - } - } - }); - }); - - const { data: devices } = useSuspenseQuery(dasdDevicesQuery()); - return devices; -}; - -export type DASDMutationFnProps = { - action: "enable" | "disable" | "diagOn" | "diagOff"; - devices: DASDDevice["id"][]; -}; - -export type DASDMutationFn = (props: DASDMutationFnProps) => void; - -const useDASDMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: ({ action, devices }: DASDMutationFnProps) => { - switch (action) { - case "enable": { - return enableDASD(devices); - } - case "disable": { - return disableDASD(devices); - } - case "diagOn": { - return enableDiag(devices); - } - case "diagOff": { - return disableDiag(devices); - } - } - }, - onSuccess: (_: object, { action, devices }: { action: string; devices: string[] }) => { - queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { - const nextData = prev.map((prevDev) => { - const dev = { ...prevDev }; - if (devices.includes(dev.id)) { - switch (action) { - case "enable": { - dev.enabled = true; - break; - } - case "disable": { - dev.enabled = false; - break; - } - case "diagOn": { - dev.diag = true; - break; - } - case "diagOff": { - dev.diag = false; - break; - } - } - } - - return dev; - }); - - return nextData; - }); - }, - }; - - return useMutation(query); -}; - -const useFormatDASDMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: formatDASD, - onSuccess: (data: string) => { - queryClient.setQueryData(["dasd", "formatJob", data], { jobId: data }); - }, - }; - - return useMutation(query); -}; - -export { - useDASDDevices, - useDASDSupported, - useDASDDevicesChanges, - useDASDFormatJobChanges, - useDASDRunningFormatJobs, - useFormatDASDMutation, - useDASDMutation, -}; diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 2826b8e417..6543927a4d 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -39,7 +39,8 @@ import DASDPage from "~/components/storage/dasd/DASDPage"; import TargetLoginPage from "~/components/storage/iscsi/TargetLoginPage"; import DiscoverFormPage from "~/components/storage/iscsi/DiscoverFormPage"; import DeviceSelectorPage from "~/components/storage/DeviceSelectorPage"; -import { supportedDASD, probeDASD } from "~/model/storage/dasd"; +// FIXME: adapt to new API +// import { supportedDASD, probeDASD } from "~/model/storage/dasd"; import { probeZFCP, supportedZFCP } from "~/model/storage/zfcp"; import { STORAGE as PATHS } from "~/routes/paths"; import InitiatorFormPage from "~/components/storage/iscsi/InitiatorFormPage"; @@ -116,10 +117,11 @@ const routes = (): Route => ({ path: PATHS.dasd, element: , handle: { name: N_("DASD") }, - loader: async () => { - if (!supportedDASD()) return redirect(PATHS.root); - return probeDASD(); - }, + // FIXME: adapt to new API + // loader: async () => { + // if (!supportedDASD()) return redirect(PATHS.root); + // return probeDASD(); + // }, }, { path: PATHS.zfcp.root, diff --git a/web/src/types/dasd.ts b/web/src/types/dasd.ts deleted file mode 100644 index 50a5298e56..0000000000 --- a/web/src/types/dasd.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of 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. - */ - -type DASDDevice = { - id: string; - enabled: boolean; - deviceName: string; - formatted: boolean; - diag: boolean; - status: string; // TODO: sync with rust when it switch to enum - deviceType: string; // TODO: sync with rust when it switch to enum - accessType: string; // TODO: sync with rust when it switch to enum - partitionInfo: string; - hexId: number; -}; - -type FormatSummary = { - total: number; - step: number; - done: boolean; -}; - -type FormatJob = { - jobId: string; - summary?: { [key: string]: FormatSummary }; -}; - -export type { DASDDevice, FormatSummary, FormatJob }; From 24a522ba0b3da74166c937f14dae2b6423c4a84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sun, 15 Feb 2026 12:50:28 +0000 Subject: [PATCH 06/46] refactor(web): add tests for hooks/model/config/dasd Add tests for useAddDevice and useRemoveDevice while eliminating duplicate helper functions that simply wrapped model/config/dasd calls. Hooks now call model/config/dasd#addDevice and removeDevice directly instead of maintaining wrapper functions. The separation between model/config/dasd and hooks/model/config/dasd is worth to be re-evaluated, since consolidating them might help to reduce indirection and ease testing, as the hooks already provide the primary interface.. Commit also adds test-utils/tanstack-query for query-key-based mocking when hooks share modules with their dependencies. --- web/src/hooks/model/config/dasd.test.ts | 160 ++++++++++++++++++++++++ web/src/hooks/model/config/dasd.ts | 37 +++--- web/src/model/config/dasd.test.ts | 56 ++++----- web/src/model/config/dasd.ts | 12 +- web/src/test-utils/tanstack-query.ts | 128 +++++++++++++++++++ 5 files changed, 340 insertions(+), 53 deletions(-) create mode 100644 web/src/hooks/model/config/dasd.test.ts create mode 100644 web/src/test-utils/tanstack-query.ts diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts new file mode 100644 index 0000000000..1176f34acd --- /dev/null +++ b/web/src/hooks/model/config/dasd.test.ts @@ -0,0 +1,160 @@ +/* + * 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 { act, renderHook } from "@testing-library/react"; +// NOTE: check notes about mockConfigQuery in its documentation +import { clearMockedQueries, mockConfigQuery } from "~/test-utils/tanstack-query"; +import { useAddDevice, useRemoveDevice } from "~/hooks/model/config/dasd"; +import type { Device } from "~/model/config/dasd"; + +const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; +const mockDeviceActive: Device = { channel: "0.0.0160", state: "active" as const }; +const mockDeviceToBeFormmated: Device = { + channel: "0.0.0170", + state: "active" as const, + format: true, +}; + +const mockPatchConfig = jest.fn(); + +// Mock the API +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + patchConfig: (config: any) => mockPatchConfig(config), +})); + +describe("hooks/model/storage/dasd", () => { + beforeEach(() => { + jest.clearAllMocks(); + clearMockedQueries(); + }); + + describe("useAddDevice", () => { + describe("when there is not a DASD config yet", () => { + it("calls API#patchConfig with a new config for DASD including added device", async () => { + mockConfigQuery(null); + + const { result } = renderHook(() => useAddDevice()); + + await act(async () => { + result.current(mockDeviceActive); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ devices: [mockDeviceActive] }), + }); + }); + }); + + describe("when there is an existing DASD config", () => { + it("calls API#patchConfig with updated config including added device", async () => { + mockConfigQuery({ + dasd: { devices: [mockDeviceOffline] }, + }); + + const { result } = renderHook(() => useAddDevice()); + + await act(async () => { + result.current(mockDeviceActive); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ + devices: [mockDeviceOffline, mockDeviceActive], + }), + }); + }); + }); + }); + + describe("useRemoveDevice", () => { + describe("when there is not a DASD config yet", () => { + it("calls API#patchConfig with an empty config for DASD", async () => { + mockConfigQuery(null); + + const channelToRemove = "0.0.0190"; + const { result } = renderHook(() => useRemoveDevice()); + + await act(async () => { + result.current(channelToRemove); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: {}, + }); + }); + }); + + describe("when there is an existing DASD config without devices", () => { + it("calls API#patchConfig with the same config", async () => { + const initialConfig = { dasd: {} }; + mockConfigQuery(initialConfig); + + const { result } = renderHook(() => useRemoveDevice()); + + await act(async () => { + result.current(mockDeviceActive.channel); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith(initialConfig); + }); + }); + + describe("when there is an existing DASD config with devices", () => { + it("calls API#patchConfig with device removed from config", async () => { + mockConfigQuery({ + dasd: { devices: [mockDeviceOffline, mockDeviceActive, mockDeviceToBeFormmated] }, + }); + + const { result } = renderHook(() => useRemoveDevice()); + + await act(async () => { + result.current(mockDeviceActive.channel); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ + devices: [mockDeviceOffline, mockDeviceToBeFormmated], + }), + }); + }); + + it("calls API#patchConfig with unchanged config when removing non-existent device", async () => { + const initialConfig = { + dasd: { + devices: [mockDeviceOffline, mockDeviceActive, mockDeviceToBeFormmated], + }, + }; + mockConfigQuery(initialConfig); + + const nonExistentChannel = "0.0.9999"; + const { result } = renderHook(() => useRemoveDevice()); + + await act(async () => { + result.current(nonExistentChannel); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith(initialConfig); + }); + }); + }); +}); diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts index ae172557f1..3c1b591108 100644 --- a/web/src/hooks/model/config/dasd.ts +++ b/web/src/hooks/model/config/dasd.ts @@ -23,37 +23,44 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { configQuery } from "~/hooks/model/config"; import { patchConfig, Response } from "~/api"; -import dasd from "~/model/config/dasd"; +import dasdConfig from "~/model/config/dasd"; + import type { Config, DASD } from "~/model/config"; -const selectConfig = (data: Config | null): DASD.Config => data?.dasd; +type addDeviceFn = (device: DASD.Device) => Response; +type removeDeviceFn = (name: DASD.Device["channel"]) => Response; + +const configSelector = (data: Config | null): DASD.Config => data?.dasd; function useConfig(): DASD.Config | null { const { data } = useSuspenseQuery({ ...configQuery, - select: selectConfig, + select: configSelector, }); return data; } -const addDevice = (config: DASD.Config | null, device: DASD.Device): DASD.Config => - config ? dasd.addDevice(config, device) : { devices: [device] }; - -type addDeviceFn = (device: DASD.Device) => Response; - function useAddDevice(): addDeviceFn { const config = useConfig(); - return (device: DASD.Device) => patchConfig({ dasd: addDevice(config, device) }); -} - -const removeDevice = (config: DASD.Config | null, channel: string): DASD.Config => - config ? dasd.removeDevice(config, channel) : {}; -type removeDeviceFn = (name: string) => Response; + return (device: DASD.Device) => { + return patchConfig({ + // FIXME: useConfig should return an empty object instead of falling back + // to an empty object all the time + dasd: dasdConfig.addDevice(config || {}, device), + }); + }; +} function useRemoveDevice(): removeDeviceFn { const config = useConfig(); - return (channel: string) => patchConfig({ dasd: removeDevice(config, channel) }); + + return (channel: string) => + patchConfig({ + // FIXME: useConfig should return an empty object instead of falling back + // to an empty object all the time + dasd: dasdConfig.removeDevice(config || {}, channel), + }); } export { useConfig, useAddDevice, useRemoveDevice }; diff --git a/web/src/model/config/dasd.test.ts b/web/src/model/config/dasd.test.ts index e1c49e18d6..97be41c9dc 100644 --- a/web/src/model/config/dasd.test.ts +++ b/web/src/model/config/dasd.test.ts @@ -27,24 +27,17 @@ type ConfigStructurePreservationTest = Config & { futureProperty: "must_be_preserved"; }; -const mockDASDDevice: Device = { - channel: "0.0.0160", - state: "offline", - format: false, - diag: true, -}; - -const mockInitialDASDConfig: Config = { - devices: [mockDASDDevice], -}; +const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; +const mockDeviceActive: Device = { channel: "0.0.0160", state: "active" as const }; +const mockInitialConfig: Config = { devices: [mockDeviceActive] }; -describe("model/storage/dasd", () => { +describe("model/config/dasd", () => { describe("#addDevice", () => { it("preserves existing config properties while adding the device", () => { const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; const initialConfig = { - ...mockInitialDASDConfig, + ...mockInitialConfig, futureProperty: "must_be_preserved", } as ConfigStructurePreservationTest; @@ -59,33 +52,29 @@ describe("model/storage/dasd", () => { describe("when config already has devices", () => { it("appends the new device to the existing list", () => { - const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; - - const newConfig = dasdModel.addDevice(mockInitialDASDConfig, deviceToAdd); - expect(newConfig).not.toBe(mockInitialDASDConfig); - expect(newConfig.devices).toContain(mockDASDDevice); - expect(newConfig.devices).toContain(deviceToAdd); + const newConfig = dasdModel.addDevice(mockInitialConfig, mockDeviceOffline); + expect(newConfig).not.toBe(mockInitialConfig); + expect(newConfig.devices).toContain(mockDeviceActive); + expect(newConfig.devices).toContain(mockDeviceOffline); }); }); describe("when config devices are empty or undefined", () => { it("returns a config containing only the new device", () => { - const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; - - expect(dasdModel.addDevice({}, deviceToAdd)).toEqual({ - devices: [deviceToAdd], + expect(dasdModel.addDevice({}, mockDeviceOffline)).toEqual({ + devices: [mockDeviceOffline], }); - expect(dasdModel.addDevice({ devices: [] }, deviceToAdd)).toEqual({ - devices: [deviceToAdd], + expect(dasdModel.addDevice({ devices: [] }, mockDeviceOffline)).toEqual({ + devices: [mockDeviceOffline], }); - expect(dasdModel.addDevice({ devices: undefined }, deviceToAdd)).toEqual({ - devices: [deviceToAdd], + expect(dasdModel.addDevice({ devices: undefined }, mockDeviceOffline)).toEqual({ + devices: [mockDeviceOffline], }); - expect(dasdModel.addDevice({ devices: null }, deviceToAdd)).toEqual({ - devices: [deviceToAdd], + expect(dasdModel.addDevice({ devices: null }, mockDeviceOffline)).toEqual({ + devices: [mockDeviceOffline], }); }); }); @@ -94,13 +83,13 @@ describe("model/storage/dasd", () => { describe("#removeDevice", () => { it("preserves existing config properties while removing the device", () => { const initialConfig = { - ...mockInitialDASDConfig, + ...mockInitialConfig, futureProperty: "must_be_preserved", } as ConfigStructurePreservationTest; const newConfig = dasdModel.removeDevice( initialConfig, - mockDASDDevice.channel, + mockDeviceActive.channel, ) as ConfigStructurePreservationTest; expect(newConfig).not.toBe(initialConfig); @@ -108,9 +97,10 @@ describe("model/storage/dasd", () => { }); it("returns a new config with the specified device removed", () => { - const newConfig = dasdModel.removeDevice(mockInitialDASDConfig, mockDASDDevice.channel); - expect(newConfig).not.toBe(mockInitialDASDConfig); - expect(newConfig.devices).not.toContain(mockDASDDevice); + const toRemove = mockInitialConfig.devices[0]; + const newConfig = dasdModel.removeDevice(mockInitialConfig, toRemove.channel); + expect(newConfig).not.toBe(mockInitialConfig); + expect(newConfig.devices).not.toContain(toRemove); }); }); }); diff --git a/web/src/model/config/dasd.ts b/web/src/model/config/dasd.ts index 8803e39ecb..f5f45f966d 100644 --- a/web/src/model/config/dasd.ts +++ b/web/src/model/config/dasd.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { concat } from "radashi"; +import { concat, isEmpty } from "radashi"; import { Config, Device } from "~/openapi/config/dasd"; function addDevice(config: Config, device: Device): Config { @@ -31,10 +31,12 @@ function addDevice(config: Config, device: Device): Config { } function removeDevice(config: Config, channel: string): Config { - return { - ...config, - devices: config.devices.filter((d) => d.channel !== channel), - }; + return isEmpty(config.devices) + ? { ...config } + : { + ...config, + devices: config.devices.filter((d) => d.channel !== channel), + }; } export default { addDevice, removeDevice }; diff --git a/web/src/test-utils/tanstack-query.ts b/web/src/test-utils/tanstack-query.ts new file mode 100644 index 0000000000..a0e330ccd8 --- /dev/null +++ b/web/src/test-utils/tanstack-query.ts @@ -0,0 +1,128 @@ +/* + * 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 type { UseSuspenseQueryOptions, QueryKey } from "@tanstack/react-query"; + +type QueryMock = { queryKey: QueryKey; data: unknown }; + +/** + * Compare two query keys for equality + * + * @param keyA - First query key + * @param keyB - Second query key + * @returns true if the query keys are equal + * + * @example + * queryKeysEqual(["config"], ["config"]) // true + * queryKeysEqual(["users", 1], ["users", 1]) // true + * queryKeysEqual(["config"], ["users"]) // false + */ +function queryKeysEqual(keyA: QueryKey, keyB: QueryKey): boolean { + return JSON.stringify(keyA) === JSON.stringify(keyB); +} + +/** + * Internal mock for manipulating React Query's useSuspenseQuery results. + * + * @remarks Last resort testing solution + * **USE SPARINGLY** - Prefer mocking hooks directly when possible. + * + * This utility exists to work around Jest's limitation with mocking internal + * module calls. When a hook that uses a query (e.g., useConfig) lives in the + * same module as hooks that consume it (e.g., useAddDevice, useRemoveDevice), + * Jest cannot mock the consumer hook because internal module calls bypass + * Jest's module mocking system. + * + * Use only when a query hook and its consumer are in the same module and it is + * not feasible to: + * - Restructure the module to make these hooks live in different modles + * - Extract the main logic for testing purposes + * + * @see ~/hooks/model/system/network#getNetworkStatus for an example of + * extracting logic for testing + * + * @example Problem scenario + * ```typescript + * // ~/hooks/model/config/dasd.ts + * export function useConfig() { return useSuspenseQuery(...); } + * export function useAddDevice() { + * const config = useConfig(); // <- This internal call bypasses mocks! + * // ... + * } + * ``` + */ +const mockUseSuspenseQuery: jest.Mock = jest.fn(); + +/** + * Storage for mocked queries by query key + */ +let mockedQueries: QueryMock[] = []; + +function clearMockedQueries() { + mockedQueries = []; +} + +/** + * Mock data for a specific query key + * + * @example + * mockQuery(["config"], { dasd: { devices: [] } }); + * mockQuery(["users"], { users: [{ id: 1, name: "Alice" }] }); + */ +function mockQuery(queryKey: QueryKey, data: unknown) { + const existingIndex = mockedQueries.findIndex((mock) => queryKeysEqual(mock.queryKey, queryKey)); + + if (existingIndex >= 0) { + mockedQueries[existingIndex].data = data; + } else { + mockedQueries.push({ queryKey, data }); + } +} + +/** + * Mock data for config query + * + * @example + * mockConfigQuery({ dasd: { devices: [] } }); + */ +function mockConfigQuery(data: any) { + mockQuery(["config"], data); +} + +// Set up the mock implementation +mockUseSuspenseQuery.mockImplementation((options: UseSuspenseQueryOptions) => { + const match = mockedQueries.find((mock) => queryKeysEqual(mock.queryKey, options.queryKey)); + + if (match) { + const data = options.select ? options.select(match.data) : match.data; + return { data }; + } + + return { data: null }; +}); + +jest.mock("@tanstack/react-query", () => ({ + ...jest.requireActual("@tanstack/react-query"), + useSuspenseQuery: (options: UseSuspenseQueryOptions) => mockUseSuspenseQuery(options), +})); + +export { mockQuery, mockConfigQuery, clearMockedQueries }; From e7a23a56645c366341e7ffba5dc16ed7e4807506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sun, 15 Feb 2026 15:09:23 +0000 Subject: [PATCH 07/46] fix(web): please linter and add more tests and doc --- web/src/hooks/model/config/dasd.test.ts | 19 ++++++++-- web/src/hooks/model/config/dasd.ts | 47 +++++++++++++++++++++++-- web/src/test-utils/tanstack-query.ts | 2 +- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts index 1176f34acd..b7021afc61 100644 --- a/web/src/hooks/model/config/dasd.test.ts +++ b/web/src/hooks/model/config/dasd.test.ts @@ -23,7 +23,8 @@ import { act, renderHook } from "@testing-library/react"; // NOTE: check notes about mockConfigQuery in its documentation import { clearMockedQueries, mockConfigQuery } from "~/test-utils/tanstack-query"; -import { useAddDevice, useRemoveDevice } from "~/hooks/model/config/dasd"; +import { patchConfig } from "~/api"; +import { useConfig, useAddDevice, useRemoveDevice } from "~/hooks/model/config/dasd"; import type { Device } from "~/model/config/dasd"; const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; @@ -39,7 +40,7 @@ const mockPatchConfig = jest.fn(); // Mock the API jest.mock("~/api", () => ({ ...jest.requireActual("~/api"), - patchConfig: (config: any) => mockPatchConfig(config), + patchConfig: (config: Parameters) => mockPatchConfig(config), })); describe("hooks/model/storage/dasd", () => { @@ -48,6 +49,20 @@ describe("hooks/model/storage/dasd", () => { clearMockedQueries(); }); + describe("useConfig", () => { + it("returns only dasd config data", () => { + mockConfigQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + dasd: { devices: [mockDeviceActive] }, + }); + + const { result } = renderHook(() => useConfig()); + + expect(result.current).toEqual({ devices: [mockDeviceActive] }); + expect(result.current).not.toHaveProperty("product"); + }); + }); + describe("useAddDevice", () => { describe("when there is not a DASD config yet", () => { it("calls API#patchConfig with a new config for DASD including added device", async () => { diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts index 3c1b591108..c306bebcb7 100644 --- a/web/src/hooks/model/config/dasd.ts +++ b/web/src/hooks/model/config/dasd.ts @@ -30,16 +30,49 @@ import type { Config, DASD } from "~/model/config"; type addDeviceFn = (device: DASD.Device) => Response; type removeDeviceFn = (name: DASD.Device["channel"]) => Response; -const configSelector = (data: Config | null): DASD.Config => data?.dasd; +/** + * Extract DASD config from a config object. + * + * @remarks + * Used by useSuspenseQuery's select option to transform the query result. + * Returns undefined when data is undefined or when dasd property is not present. + * + * @see {@link https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#select TanStack Query Select} + * @see {@link https://tkdodo.eu/blog/react-query-selectors-supercharged#what-is-select Query Selectors Supercharged} + * + * FIXME: Read todo note below. + * @todo Consider returning an empty object ({}) instead of undefined to simplify + * consuming code and eliminate the need for fallback checks throughout the codebase. + */ +const dasdSelector = (data: Config | undefined): DASD.Config => data?.dasd; -function useConfig(): DASD.Config | null { +/** + * Hook to retrieve DASD configuration object. + * + * @example + * ```typescript + * function MyComponent() { + * const dasdConfig = useConfig(); + * return
{dasdConfig?.devices.length} devices
; + * } + * ``` + */ +function useConfig(): DASD.Config | undefined { const { data } = useSuspenseQuery({ ...configQuery, - select: configSelector, + select: dasdSelector, }); return data; } +/** + * Add a device to DASD configuration. + * + * @remarks + * Falls back to empty config when useConfig returns undefined. + * + * @todo Remove fallback once useConfig returns empty object by default + */ function useAddDevice(): addDeviceFn { const config = useConfig(); @@ -52,6 +85,14 @@ function useAddDevice(): addDeviceFn { }; } +/** + * Remove a device from DASD configuration by channel. + * + * @remarks + * Falls back to empty config when useConfig returns undefined. + * + * @todo Remove fallback once useConfig returns empty object by default + */ function useRemoveDevice(): removeDeviceFn { const config = useConfig(); diff --git a/web/src/test-utils/tanstack-query.ts b/web/src/test-utils/tanstack-query.ts index a0e330ccd8..3a0e767370 100644 --- a/web/src/test-utils/tanstack-query.ts +++ b/web/src/test-utils/tanstack-query.ts @@ -104,7 +104,7 @@ function mockQuery(queryKey: QueryKey, data: unknown) { * @example * mockConfigQuery({ dasd: { devices: [] } }); */ -function mockConfigQuery(data: any) { +function mockConfigQuery(data: unknown) { mockQuery(["config"], data); } From f20d62eacc02d6973bc5c25b7af8c71cc8a2cb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sun, 15 Feb 2026 15:32:05 +0000 Subject: [PATCH 08/46] web: add test and doc for dasd#useSystem hook And also add some more for dasd#useConfig hook based on the ones added for useSystem. --- web/src/hooks/model/config/dasd.test.ts | 18 +++++++ web/src/hooks/model/system/dasd.test.ts | 70 +++++++++++++++++++++++++ web/src/hooks/model/system/dasd.ts | 28 ++++++++-- web/src/test-utils/tanstack-query.ts | 15 +++++- 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 web/src/hooks/model/system/dasd.test.ts diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts index b7021afc61..86d4628dff 100644 --- a/web/src/hooks/model/config/dasd.test.ts +++ b/web/src/hooks/model/config/dasd.test.ts @@ -61,6 +61,24 @@ describe("hooks/model/storage/dasd", () => { expect(result.current).toEqual({ devices: [mockDeviceActive] }); expect(result.current).not.toHaveProperty("product"); }); + + it("returns undefined when config data is undefined", () => { + mockConfigQuery(undefined); + + const { result } = renderHook(() => useConfig()); + + expect(result.current).toBeUndefined(); + }); + + it("returns undefined when dasd property is not present", () => { + mockConfigQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + }); + + const { result } = renderHook(() => useConfig()); + + expect(result.current).toBeUndefined(); + }); }); describe("useAddDevice", () => { diff --git a/web/src/hooks/model/system/dasd.test.ts b/web/src/hooks/model/system/dasd.test.ts new file mode 100644 index 0000000000..30633f2bb0 --- /dev/null +++ b/web/src/hooks/model/system/dasd.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { renderHook } from "@testing-library/react"; +// NOTE: check notes about mockSystemQuery in its documentation +import { clearMockedQueries, mockSystemQuery } from "~/test-utils/tanstack-query"; +import { useSystem } from "~/hooks/model/system/dasd"; +import type { Device } from "~/model/config/dasd"; + +const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; +const mockDeviceActive: Device = { channel: "0.0.0160", state: "active" as const }; + +describe("~/hooks/model/system/dasd", () => { + beforeEach(() => { + clearMockedQueries(); + }); + + describe("useSystem", () => { + it("returns only dasd system data, not the full system object", () => { + mockSystemQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + dasd: { + devices: [mockDeviceActive, mockDeviceOffline], + }, + }); + + const { result } = renderHook(() => useSystem()); + + expect(result.current).toEqual({ devices: [mockDeviceActive, mockDeviceOffline] }); + expect(result.current).not.toHaveProperty("product"); + }); + + it("returns undefined when system data is undefined", () => { + mockSystemQuery(undefined); + + const { result } = renderHook(() => useSystem()); + + expect(result.current).toBeUndefined(); + }); + + it("returns undefined when dasd property is not present", () => { + mockSystemQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + }); + + const { result } = renderHook(() => useSystem()); + + expect(result.current).toBeUndefined(); + }); + }); +}); diff --git a/web/src/hooks/model/system/dasd.ts b/web/src/hooks/model/system/dasd.ts index 6f626ddfc2..aa2e3e8dc0 100644 --- a/web/src/hooks/model/system/dasd.ts +++ b/web/src/hooks/model/system/dasd.ts @@ -24,12 +24,34 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { systemQuery } from "~/hooks/model/system"; import type { System, DASD } from "~/model/system"; -const selectSystem = (data: System | null): DASD.System => data?.dasd; +/** + * Extract DASD system information a system object. + * + * @remarks + * Used by useSuspenseQuery's select option to transform the query result. + * Returns undefined when data is undefined or when dasd property is not present. + * + * @see {@link https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#select TanStack Query Select} + * @see {@link https://tkdodo.eu/blog/react-query-selectors-supercharged#what-is-select Query Selectors Supercharged} + * + * FIXME: Read todo note below. + * @todo Consider returning an empty object ({}) instead of undefined to + * simplify consuming code and eliminate the need for fallback checks throughout + * the codebase. + */ +const dasdSelector = (data: System | undefined): DASD.System => data?.dasd; -function useSystem(): DASD.System | null { +/** + * Retrieve DASD system information. + * + * @todo Returning an empty object by default would eliminate null checks and + * simplify all consuming code. This pattern would be more consistent with having + * a "no system data" state represented as an empty object rather than undefined. + */ +function useSystem(): DASD.System | undefined { const { data } = useSuspenseQuery({ ...systemQuery, - select: selectSystem, + select: dasdSelector, }); return data; } diff --git a/web/src/test-utils/tanstack-query.ts b/web/src/test-utils/tanstack-query.ts index 3a0e767370..011163e0f4 100644 --- a/web/src/test-utils/tanstack-query.ts +++ b/web/src/test-utils/tanstack-query.ts @@ -108,6 +108,19 @@ function mockConfigQuery(data: unknown) { mockQuery(["config"], data); } +/** + * Mock data for system query + * + * @example + * mockSystemQuery({ + * l10n: { locales, keymaps, locale: "us_US.UTF-8", keymap: "us" }, + * dasd: { devices: [] } } + * }); + */ +function mockSystemQuery(data: unknown) { + mockQuery(["system"], data); +} + // Set up the mock implementation mockUseSuspenseQuery.mockImplementation((options: UseSuspenseQueryOptions) => { const match = mockedQueries.find((mock) => queryKeysEqual(mock.queryKey, options.queryKey)); @@ -125,4 +138,4 @@ jest.mock("@tanstack/react-query", () => ({ useSuspenseQuery: (options: UseSuspenseQueryOptions) => mockUseSuspenseQuery(options), })); -export { mockQuery, mockConfigQuery, clearMockedQueries }; +export { mockQuery, mockConfigQuery, mockSystemQuery, clearMockedQueries }; From b4fc4570008cfffe7afc17096ddcd023434a00af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 18 Feb 2026 09:27:37 +0000 Subject: [PATCH 09/46] feat(web): add util for merging two collections Needed for merging information from both hooks at dasd, useConfig and useSystem. There was a "mergeSources" utils added previously in the iSCSI context that it's not valid for what is needed now. Most probably, iSCSI would move in the DASD behavior direction and mergeSource deprecated or drop. --- web/src/utils.test.ts | 278 ++++++++++++++++++++++++++++++++++++++++++ web/src/utils.ts | 107 ++++++++++++++++ 2 files changed, 385 insertions(+) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index f182480d80..b5004a4a1c 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -28,9 +28,12 @@ import { timezoneTime, sortCollection, mergeSources, + extendCollection, } from "./utils"; import type { Target as ConfigTarget } from "~/openapi/config/iscsi"; import type { Target as SystemTarget } from "~/openapi/system/iscsi"; +import type { Device as ConfigDevice } from "./openapi/config/dasd"; +import type { Device as SystemDevice } from "./openapi/system/dasd"; describe("compact", () => { it("removes null and undefined values", () => { @@ -515,3 +518,278 @@ describe("mergeSources", () => { expect(result[0].connected).toBe(true); }); }); + +describe("extendCollection", () => { + describe("single key matching", () => { + it("extends items matching keys", () => { + const configDevices: ConfigDevice[] = [ + { channel: "0.0.0160", diag: false, format: true, state: "offline" }, + ]; + + const systemDevices: SystemDevice[] = [ + { + channel: "0.0.0160", + active: false, + deviceName: "dasda", + type: "eckd", + formatted: false, + diag: true, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]; + + const result = extendCollection(configDevices, { + with: systemDevices, + matching: "channel", + }); + + expect(result).toEqual([ + { + channel: "0.0.0160", + diag: false, // from config (baseWins / default precedence) + format: true, + state: "offline", + active: false, // from system + deviceName: "dasda", + type: "eckd", + formatted: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]); + }); + + it("respects 'extensionWins' precedence", () => { + const configDevices: ConfigDevice[] = [{ channel: "0.0.0160", diag: false, format: true }]; + + const systemDevices: SystemDevice[] = [ + { + channel: "0.0.0160", + active: true, + deviceName: "dasda", + type: "eckd", + formatted: true, + diag: true, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]; + + const result = extendCollection(configDevices, { + with: systemDevices, + matching: "channel", + precedence: "extensionWins", + }); + + expect(result).toEqual([ + { + channel: "0.0.0160", + diag: true, // from system (extensionWins precedence) + format: true, + active: true, + deviceName: "dasda", + type: "eckd", + formatted: true, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]); + }); + + it("keeps items without matches unchanged", () => { + const configDevices: ConfigDevice[] = [ + { channel: "0.0.0160", diag: false }, + { channel: "0.0.0200", format: true }, + ]; + + const systemDevices: SystemDevice[] = [ + { + channel: "0.0.0160", + active: true, + deviceName: "dasda", + type: "eckd", + formatted: false, + diag: true, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]; + + const result = extendCollection(configDevices, { + with: systemDevices, + matching: "channel", + }); + + expect(result).toHaveLength(2); + expect(result[0].channel).toBe("0.0.0160"); + expect(result[0].deviceName).toBe("dasda"); + expect(result[1]).toEqual({ channel: "0.0.0200", format: true }); // unchanged + }); + + it("does not include items present only in extension collection", () => { + const configDevices: ConfigDevice[] = [{ channel: "0.0.0160", diag: false }]; + + const systemDevices: SystemDevice[] = [ + { + channel: "0.0.0160", + active: true, + deviceName: "dasda", + type: "eckd", + formatted: false, + diag: true, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + { + channel: "0.0.0200", + active: true, + deviceName: "dasdb", + type: "fba", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]; + + const result = extendCollection(configDevices, { + with: systemDevices, + matching: "channel", + }); + + expect(result).toHaveLength(1); + expect(result[0].channel).toBe("0.0.0160"); + }); + }); + + describe("multiple key matching", () => { + it("extends devices with matching keys", () => { + const configDevices: ConfigDevice[] = [ + { channel: "0.0.0150", state: "offline", diag: false }, + { channel: "0.0.0160", state: "active", diag: false }, + ]; + + const systemDevices: SystemDevice[] = [ + { + channel: "0.0.0150", + status: "offline", + active: false, + deviceName: "dasdc", + type: "eckd", + formatted: false, + diag: true, + accessType: "rw", + partitionInfo: "1", + }, + { + channel: "0.0.0160", + status: "offline", + active: false, + deviceName: "dasda", + type: "eckd", + formatted: false, + diag: false, + accessType: "rw", + partitionInfo: "1", + }, + ]; + + const result = extendCollection(configDevices, { + with: systemDevices, + matching: ["channel", "diag"], + }); + + expect(result).toEqual([ + { channel: "0.0.0150", state: "offline", diag: false }, + { + channel: "0.0.0160", + state: "active", + diag: false, + status: "offline", + active: false, + deviceName: "dasda", + type: "eckd", + formatted: false, + accessType: "rw", + partitionInfo: "1", + }, + ]); + }); + }); + + describe("edge cases", () => { + it("handles empty base collection", () => { + const result = extendCollection([], { + with: [{ id: 1, name: "test" }], + matching: "id", + }); + + expect(result).toEqual([]); + }); + + it("handles empty extension collection", () => { + const items = [{ channel: "0.0.0160", diag: false }]; + + const result = extendCollection(items, { + with: [], + matching: "channel", + }); + + expect(result).toEqual(items); + }); + + it("handles both collections empty", () => { + const result = extendCollection([], { + with: [], + matching: "id", + }); + + expect(result).toEqual([]); + }); + + it("does not mutate original collections", () => { + const original = [{ channel: "0.0.0160", diag: false }]; + const extension: SystemDevice[] = [ + { + channel: "0.0.0160", + active: true, + deviceName: "dasda", + type: "eckd", + formatted: false, + diag: true, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]; + + extendCollection(original, { + with: extension, + matching: "channel", + }); + + expect(original).toEqual([{ channel: "0.0.0160", diag: false }]); + expect(extension[0].deviceName).toBe("dasda"); + }); + + it("handles numeric and string keys", () => { + const items = [{ id: 1, value: "a" }]; + const extension = [{ id: 1, extra: "b" }]; + + const result = extendCollection(items, { + with: extension, + matching: "id", + }); + + expect(result[0]).toEqual({ id: 1, value: "a", extra: "b" }); + }); + }); +}); diff --git a/web/src/utils.ts b/web/src/utils.ts index c487886132..73fde3d3ac 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -415,6 +415,112 @@ function mergeSources({ return Array.from(map.values()); } +/** + * Options for extending a base collection with matching merge items. + */ +interface ExtendCollectionOptions { + /** + * Collection providing merge data to enrich base items. + * + * Items in this collection will be matched against the base collection + * according to the `matching` fields. + */ + with: U[]; + + /** + * Field name or array of field names used to match items between the base + * collection and the merge collection. + * - Single field: matches on that field. + * - Multiple fields: all fields are combined to determine a match. + */ + matching: keyof T | (keyof T)[]; + + /** + * Determines which collection's properties take priority when merging: + * - `"baseWins"`: base item properties overwrite merge properties. + * - `"extensionWins"`: merge properties overwrite base item properties. + * @default "baseWins" + */ + precedence?: "baseWins" | "extensionWins"; +} + +/** + * Extends a base collection of items by merging in matching items from a + * secondary collection. + * + * For each item in the base collection: + * 1. Find a matching item in the merge collection based on the specified + * fields. + * 2. Merge the matched item's properties into the base item according to + * `precedence`. + * + * @returns A new array of items where each base item has been extended with + * matching merge item properties. + * + * @example + * // Single field matching with default precedence (initial wins) + * const configDevices = [ + * { channel: "0.0.0160", diag: false, format: true } + * ]; + * const systemDevices = [ + * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: true } + * ]; + * const extendedConfigDevices = extendCollection(configDevices, { + * with: systemDevices, + * matching: 'channel' + * }); + * // Result: [ + * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true } + * ] + * + * @example + * // Multiple field matching with extensionWins precedence + * const configDevices = [ + * { channel: "0.0.0160", state: "offline", diag: false } + * ]; + * const systemDevices = [ + * { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }, + * { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } + * ]; + * const extendedConfigDevices = extendCollection(configDevices, { + * with: systemDevices, + * matching: ['channel', 'state'] + * precedence: "extensionWins" + * }); + * // Result: [ + * { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true, } + * ] + */ +function extendCollection, U extends Record>( + collection: T[], + options: ExtendCollectionOptions, +): (T & U)[] { + const { with: extension, matching, precedence = "baseWins" } = options; + + // Normalize matching field(s) + const keys = Array.isArray(matching) ? matching : [matching]; + + // Create a string key for each item for lookup + const getKey = (item: T | U): string => + keys.map((k) => String((item as Record)[k as string])).join("|"); + + // Build a lookup map from merge items keyed by their matching fields + const extensionLookup = new Map(extension.map((item) => [getKey(item), item])); + + // Extend each item in the base collection + return collection.map((item) => { + const match = extensionLookup.get(getKey(item)); + + // No match found - return item unchanged + if (!match) return item as T & U; + + // Merge matched items based on precedence + return precedence === "baseWins" + ? { ...match, ...item } // Base item properties take priority + : { ...item, ...match }; // Merge item properties take priority + }); +} + export { compact, hex, @@ -426,4 +532,5 @@ export { generateEncodedPath, sortCollection, mergeSources, + extendCollection, }; From a1ff7fb425e305de7352930806418b46e85b3a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 18 Feb 2026 15:38:15 +0000 Subject: [PATCH 10/46] refactor(web): return unmatched items from extendCollection Previously, extendCollection returned only items from the base collection extended with data from the extension collection. Now returns an object with both extended and unmatched items, enabling use cases like: - Identifying which extension items have no match in the base collection - Validation and reporting without additional filtering - Single source of truth for both intersection and difference Before: const result = extendCollection(config, { with: system, matching: 'id' }) After: const { extended } = extendCollection(config, { with: system, matching: 'id' }) --- web/src/utils.test.ts | 271 +++++++++++++++++++++++------------------- web/src/utils.ts | 104 +++++++++++----- 2 files changed, 221 insertions(+), 154 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 20f87eaee9..8a3b4185ac 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -276,6 +276,107 @@ describe("simpleFastSort", () => { }); }); +describe("maskSecrets", () => { + it("should filter sensitive keys from an object", () => { + const obj = { user: "test", password: "123" }; + const sanitized = maskSecrets(obj, { stringify: false }); + expect(sanitized).toEqual({ user: "test", password: "[FILTERED]" }); + }); + + it("should not modify an object without sensitive keys", () => { + const obj = { user: "test", id: 1 }; + const sanitized = maskSecrets(obj, { stringify: false }); + expect(sanitized).toEqual({ user: "test", id: 1 }); + }); + + it("should recursively filter sensitive keys in nested objects", () => { + const obj = { + user: "test", + credentials: { + password: "123", + }, + }; + const sanitized = maskSecrets(obj, { stringify: false }); + expect(sanitized).toEqual({ + user: "test", + credentials: { + password: "[FILTERED]", + }, + }); + }); + + it("should handle arrays of objects", () => { + const arr = [ + { user: "one", password: "123" }, + { user: "two", id: 2 }, + ]; + const sanitized = maskSecrets(arr, { stringify: false }); + expect(sanitized).toEqual([ + { user: "one", password: "[FILTERED]" }, + { user: "two", id: 2 }, + ]); + }); + + it("should not mutate the original object", () => { + const originalObj = { user: "test", password: "123" }; + const originalObjCopy = JSON.parse(JSON.stringify(originalObj)); + maskSecrets(originalObj, { stringify: false }); + expect(originalObj).toEqual(originalObjCopy); + }); + + it("should handle null and undefined values correctly", () => { + const obj = { user: "test", password: "123", data: null, extra: undefined }; + const sanitized = maskSecrets(obj, { stringify: false }); + // Note: `undefined` properties are omitted when creating a new object from an existing one. + expect(sanitized).toEqual({ user: "test", password: "[FILTERED]", data: null }); + }); + + it("should return primitive values unmodified", () => { + expect(maskSecrets("string", { stringify: false })).toBe("string"); + expect(maskSecrets(123, { stringify: false })).toBe(123); + expect(maskSecrets(true, { stringify: false })).toBe(true); + expect(maskSecrets(null, { stringify: false })).toBe(null); + expect(maskSecrets(undefined, { stringify: false })).toBe(undefined); + }); + + it("should handle an empty object", () => { + expect(maskSecrets({}, { stringify: false })).toEqual({}); + }); + + it("should handle an empty array", () => { + expect(maskSecrets([], { stringify: false })).toEqual([]); + }); + + it("should filter all defined sensitive keys", () => { + const obj = { + user: "test", + password: "123", + hashedPassword: false, + registrationCode: "xyz", + }; + expect(maskSecrets(obj, { stringify: false })).toEqual({ + user: "test", + password: "[FILTERED]", + hashedPassword: false, + registrationCode: "[FILTERED]", + }); + }); + + it("should handle custom sensitive keys", () => { + const obj = { user: "test", password: "123", sensitive: "abc" }; + const sanitized = maskSecrets(obj, { sensitiveKeys: ["sensitive"], stringify: false }); + expect(sanitized).toEqual({ user: "test", password: "123", sensitive: "[FILTERED]" }); + }); + + it("can optionally stringify the output", () => { + expect(maskSecrets([], { stringify: true })).toEqual("[]"); + expect(maskSecrets({}, { stringify: true })).toEqual("{}"); + expect(maskSecrets({ user: "test", password: "123" }, { stringify: true })).toEqual( + '{\n "user": "test",\n "password": "[FILTERED]"\n}', + ); + }); +}); + describe("mergeSources", () => { it("merges collections honoring precedence when primary key is based on single attribute", () => { const result = mergeSources({ @@ -541,9 +642,12 @@ describe("extendCollection", () => { }, ]; - const result = extendCollection(configDevices, { with: systemDevices, matching: "channel" }); + const { extended, unmatched } = extendCollection(configDevices, { + with: systemDevices, + matching: "channel", + }); - expect(result).toEqual([ + expect(extended).toEqual([ { channel: "0.0.0160", diag: false, // from config (baseWins / default precedence) @@ -558,6 +662,7 @@ describe("extendCollection", () => { partitionInfo: "1", }, ]); + expect(unmatched).toEqual([]); }); it("respects 'extensionWins' precedence", () => { @@ -577,13 +682,13 @@ describe("extendCollection", () => { }, ]; - const result = extendCollection(configDevices, { + const { extended, unmatched } = extendCollection(configDevices, { with: systemDevices, matching: "channel", precedence: "extensionWins", }); - expect(result).toEqual([ + expect(extended).toEqual([ { channel: "0.0.0160", diag: true, // from system (extensionWins precedence) @@ -597,6 +702,7 @@ describe("extendCollection", () => { partitionInfo: "1", }, ]); + expect(unmatched).toEqual([]); }); it("keeps items without matches unchanged", () => { @@ -619,18 +725,19 @@ describe("extendCollection", () => { }, ]; - const result = extendCollection(configDevices, { + const { extended, unmatched } = extendCollection(configDevices, { with: systemDevices, matching: "channel", }); - expect(result).toHaveLength(2); - expect(result[0].channel).toBe("0.0.0160"); - expect(result[0].deviceName).toBe("dasda"); - expect(result[1]).toEqual({ channel: "0.0.0200", format: true }); // unchanged + expect(extended).toHaveLength(2); + expect(extended[0].channel).toBe("0.0.0160"); + expect(extended[0].deviceName).toBe("dasda"); + expect(extended[1]).toEqual({ channel: "0.0.0200", format: true }); // unchanged + expect(unmatched).toEqual([]); }); - it("does not include items present only in extension collection", () => { + it("returns unmatched items from extension collection", () => { const configDevices: ConfigDevice[] = [{ channel: "0.0.0160", diag: false }]; const systemDevices: SystemDevice[] = [ @@ -658,13 +765,25 @@ describe("extendCollection", () => { }, ]; - const result = extendCollection(configDevices, { + const { extended, unmatched } = extendCollection(configDevices, { with: systemDevices, matching: "channel", }); - expect(result).toHaveLength(1); - expect(result[0].channel).toBe("0.0.0160"); + expect(extended).toHaveLength(1); + expect(extended[0].channel).toBe("0.0.0160"); + expect(unmatched).toHaveLength(1); + expect(unmatched[0]).toEqual({ + channel: "0.0.0200", + active: true, + deviceName: "dasdb", + type: "fba", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + }); }); }); @@ -700,12 +819,12 @@ describe("extendCollection", () => { }, ]; - const result = extendCollection(configDevices, { + const { extended, unmatched } = extendCollection(configDevices, { with: systemDevices, matching: ["channel", "diag"], }); - expect(result).toEqual([ + expect(extended).toEqual([ { channel: "0.0.0150", state: "offline", diag: false }, { channel: "0.0.0160", @@ -720,37 +839,42 @@ describe("extendCollection", () => { partitionInfo: "1", }, ]); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].channel).toBe("0.0.0150"); }); }); describe("edge cases", () => { it("handles empty base collection", () => { - const result = extendCollection([], { + const { extended, unmatched } = extendCollection([], { with: [{ id: 1, name: "test" }], matching: "id", }); - expect(result).toEqual([]); + expect(extended).toEqual([]); + expect(unmatched).toEqual([{ id: 1, name: "test" }]); }); it("handles empty extension collection", () => { const items = [{ channel: "0.0.0160", diag: false }]; - const result = extendCollection(items, { + const { extended, unmatched } = extendCollection(items, { with: [], matching: "channel", }); - expect(result).toEqual(items); + expect(extended).toEqual(items); + expect(unmatched).toEqual([]); }); it("handles both collections empty", () => { - const result = extendCollection([], { + const { extended, unmatched } = extendCollection([], { with: [], matching: "id", }); - expect(result).toEqual([]); + expect(extended).toEqual([]); + expect(unmatched).toEqual([]); }); it("does not mutate original collections", () => { @@ -782,113 +906,12 @@ describe("extendCollection", () => { const items = [{ id: 1, value: "a" }]; const extension = [{ id: 1, extra: "b" }]; - const result = extendCollection(items, { + const { extended } = extendCollection(items, { with: extension, matching: "id", }); - expect(result[0]).toEqual({ id: 1, value: "a", extra: "b" }); - }); - }); -}); - -describe("maskSecrets", () => { - it("should filter sensitive keys from an object", () => { - const obj = { user: "test", password: "123" }; - const sanitized = maskSecrets(obj, { stringify: false }); - expect(sanitized).toEqual({ user: "test", password: "[FILTERED]" }); - }); - - it("should not modify an object without sensitive keys", () => { - const obj = { user: "test", id: 1 }; - const sanitized = maskSecrets(obj, { stringify: false }); - expect(sanitized).toEqual({ user: "test", id: 1 }); - }); - - it("should recursively filter sensitive keys in nested objects", () => { - const obj = { - user: "test", - credentials: { - password: "123", - }, - }; - const sanitized = maskSecrets(obj, { stringify: false }); - expect(sanitized).toEqual({ - user: "test", - credentials: { - password: "[FILTERED]", - }, + expect(extended[0]).toEqual({ id: 1, value: "a", extra: "b" }); }); }); - - it("should handle arrays of objects", () => { - const arr = [ - { user: "one", password: "123" }, - { user: "two", id: 2 }, - ]; - const sanitized = maskSecrets(arr, { stringify: false }); - expect(sanitized).toEqual([ - { user: "one", password: "[FILTERED]" }, - { user: "two", id: 2 }, - ]); - }); - - it("should not mutate the original object", () => { - const originalObj = { user: "test", password: "123" }; - const originalObjCopy = JSON.parse(JSON.stringify(originalObj)); - maskSecrets(originalObj, { stringify: false }); - expect(originalObj).toEqual(originalObjCopy); - }); - - it("should handle null and undefined values correctly", () => { - const obj = { user: "test", password: "123", data: null, extra: undefined }; - const sanitized = maskSecrets(obj, { stringify: false }); - // Note: `undefined` properties are omitted when creating a new object from an existing one. - expect(sanitized).toEqual({ user: "test", password: "[FILTERED]", data: null }); - }); - - it("should return primitive values unmodified", () => { - expect(maskSecrets("string", { stringify: false })).toBe("string"); - expect(maskSecrets(123, { stringify: false })).toBe(123); - expect(maskSecrets(true, { stringify: false })).toBe(true); - expect(maskSecrets(null, { stringify: false })).toBe(null); - expect(maskSecrets(undefined, { stringify: false })).toBe(undefined); - }); - - it("should handle an empty object", () => { - expect(maskSecrets({}, { stringify: false })).toEqual({}); - }); - - it("should handle an empty array", () => { - expect(maskSecrets([], { stringify: false })).toEqual([]); - }); - - it("should filter all defined sensitive keys", () => { - const obj = { - user: "test", - password: "123", - hashedPassword: false, - registrationCode: "xyz", - }; - expect(maskSecrets(obj, { stringify: false })).toEqual({ - user: "test", - password: "[FILTERED]", - hashedPassword: false, - registrationCode: "[FILTERED]", - }); - }); - - it("should handle custom sensitive keys", () => { - const obj = { user: "test", password: "123", sensitive: "abc" }; - const sanitized = maskSecrets(obj, { sensitiveKeys: ["sensitive"], stringify: false }); - expect(sanitized).toEqual({ user: "test", password: "123", sensitive: "[FILTERED]" }); - }); - - it("can optionally stringify the output", () => { - expect(maskSecrets([], { stringify: true })).toEqual("[]"); - expect(maskSecrets({}, { stringify: true })).toEqual("{}"); - expect(maskSecrets({ user: "test", password: "123" }, { stringify: true })).toEqual( - '{\n "user": "test",\n "password": "[FILTERED]"\n}', - ); - }); }); diff --git a/web/src/utils.ts b/web/src/utils.ts index bcef98e9cf..afb2f7516f 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -500,33 +500,49 @@ interface ExtendCollectionOptions { } /** - * Extends a base collection of items by merging in matching items from a - * secondary collection. + * Result of extending a collection. + */ +interface ExtendCollectionResult { + /** + * Items from the base collection, extended with matching extension data. + * + * Items without matches are returned unchanged. + */ + extended: (T & U)[]; + /** + * Items from the extension collection that did not match any base item. + */ + unmatched: U[]; +} + +/** + * Extends a base collection of items by merging in matching items from an + * extension collection. * * For each item in the base collection: - * 1. Find a matching item in the merge collection based on the specified - * fields. + * 1. Find a matching item in the extension collection based on the specified + * field(s). * 2. Merge the matched item's properties into the base item according to * `precedence`. * - * @returns A new array of items where each base item has been extended with - * matching merge item properties. + * @returns Object containing `extended` items and `unmatched` extension items. * * @example - * // Single field matching with default precedence (initial wins) + * // Single field matching with default precedence (base wins) * const configDevices = [ * { channel: "0.0.0160", diag: false, format: true } * ]; * const systemDevices = [ * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: true } * ]; - * const extendedConfigDevices = extendCollection(configDevices, { + * const { extended, unmatched } = extendCollection(configDevices, { * with: systemDevices, * matching: 'channel' * }); - * // Result: [ - * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true } - * ] + * // extended: [ + * // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true } + * // ] + * // unmatched: [] * * @example * // Multiple field matching with extensionWins precedence @@ -537,42 +553,70 @@ interface ExtendCollectionOptions { * { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }, * { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } * ]; - * const extendedConfigDevices = extendCollection(configDevices, { + * const { extended, unmatched } = extendCollection(configDevices, { * with: systemDevices, - * matching: ['channel', 'state'] + * matching: ['channel', 'state'], * precedence: "extensionWins" * }); - * // Result: [ - * { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true, } - * ] + * // extended: [ + * // { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true } + * // ] + * // unmatched: [ + * // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } + * // ] */ function extendCollection( collection: T[], options: ExtendCollectionOptions, -): (T & U)[] { +): ExtendCollectionResult { const { with: extension, matching, precedence = "baseWins" } = options; - // Normalize matching field(s) + // Normalize matching field(s) to array const keys = Array.isArray(matching) ? matching : [matching]; - // Create a string key for each item for lookup - const getKey = (item: T | U): string => keys.map((k) => item[k as string]).join("|"); + // Create a composite key from item values + // For single field: returns the value directly (e.g., "0.0.0160") + // For multiple fields: joins values with pipe separator (e.g., "0.0.0160|offline") + const getKey = (item: T | U): string => + keys.map((k) => String(item[k as keyof (T | U)])).join("|"); - // Build a lookup map from merge items keyed by their matching fields + // Build a lookup map from extension items keyed by their matching fields const extensionLookup = new Map(extension.map((item) => [getKey(item), item])); + // Prepare result arrays + const extended: (T & U)[] = []; + const unmatched: U[] = []; + // Extend each item in the base collection - return collection.map((item) => { - const match = extensionLookup.get(getKey(item)); + for (const item of collection) { + const key = getKey(item); + const match = extensionLookup.get(key); + + if (!match) { + // No match found - return item unchanged + extended.push(item as T & U); + } else { + // Remove from lookup for easing tracking of unmatched later + extensionLookup.delete(key); + + // Merge matched items based on precedence + const merged = ( + precedence === "baseWins" + ? { ...match, ...item } // Base item properties take priority + : { ...item, ...match } + ) as T & U; // Extension item properties take priority + + extended.push(merged); + } + } - // No match found - return item unchanged - if (!match) return item as T & U; + // Remaining items in lookup are unmatched + unmatched.push(...extensionLookup.values()); - // Merge matched items based on precedence - return precedence === "baseWins" - ? { ...match, ...item } // Base item properties take priority - : { ...item, ...match }; // Merge item properties take priority - }); + return { + extended, + unmatched, + }; } export { From 66d88a648eaa041d4a29536fe2fc60d4e19cea56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 19 Feb 2026 16:38:05 +0000 Subject: [PATCH 11/46] refactor(web): simplify SelectableDataTable empty state rendering Drop the in-table empty state approach (Bullseye inside a colSpan Td) in favor of an early return. Rendering the empty state outside and instead of the table is a better approach since it avoids visual noise from sortable column headers that would be rendered but serve no purpose with no data to sort. Also fix a typo in the documentation ("single" -> "none"). --- .../components/core/SelectableDataTable.tsx | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/web/src/components/core/SelectableDataTable.tsx b/web/src/components/core/SelectableDataTable.tsx index 2ba0bf1d0a..8d7fb4072d 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). */ @@ -610,31 +610,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))}
); } From 1893018c141bfe2405f553b28c5be00519bf0598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 19 Feb 2026 16:41:35 +0000 Subject: [PATCH 12/46] fix(web): add missing guard in SelectableDataTable And write pending tests, although one of them has to be skipped until the code will be refactored to satisfy it. --- .../components/core/SelectableDataTable.test.tsx | 16 ++++++++++++++-- web/src/components/core/SelectableDataTable.tsx | 8 +++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/web/src/components/core/SelectableDataTable.test.tsx b/web/src/components/core/SelectableDataTable.test.tsx index f6e5ecb621..463f316bbe 100644 --- a/web/src/components/core/SelectableDataTable.test.tsx +++ b/web/src/components/core/SelectableDataTable.test.tsx @@ -287,8 +287,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 8d7fb4072d..d1ff99ec6f 100644 --- a/web/src/components/core/SelectableDataTable.tsx +++ b/web/src/components/core/SelectableDataTable.tsx @@ -382,7 +382,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 && + } ); @@ -485,6 +489,8 @@ export default function SelectableDataTable({ }; const updateSelection = (item: object) => { + if (!isFunction(onSelectionChange)) return; + if (!allowMultiple) { onSelectionChange([item]); return; From f0fcc9d15ff420ebea471f9d81e3fd0225a67283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 23 Feb 2026 09:14:19 +0000 Subject: [PATCH 13/46] web: add textStyle prop to Text component It allows to apply PatternFly text utility styles. https://www.patternfly.org/utility-classes/text --- web/src/components/core/Text.test.tsx | 14 ++++++++++++++ web/src/components/core/Text.tsx | 28 +++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/web/src/components/core/Text.test.tsx b/web/src/components/core/Text.test.tsx index 5be26fbd25..7f2e1bce79 100644 --- a/web/src/components/core/Text.test.tsx +++ b/web/src/components/core/Text.test.tsx @@ -85,4 +85,18 @@ describe("Text", () => { ); expect(screen.getByText("Installer")).toHaveClass("custom-class", a11yStyles.screenReader); }); + + describe("when textStyle is given", () => { + it("applies the style when a single key is given", () => { + plainRender(Installer); + expect(screen.getByText("Installer")).toHaveClass(textStyles.fontSizeLg); + }); + + it("applies all styles when an array of keys is given", () => { + plainRender(Installer); + const element = screen.getByText("Installer"); + expect(element).toHaveClass(textStyles.fontSizeLg); + expect(element).toHaveClass(textStyles.fontWeightBold); + }); + }); }); diff --git a/web/src/components/core/Text.tsx b/web/src/components/core/Text.tsx index 91b49cd4d1..b9ef95d4fb 100644 --- a/web/src/components/core/Text.tsx +++ b/web/src/components/core/Text.tsx @@ -21,12 +21,13 @@ */ import React from "react"; +import { capitalize, isArray, isString } from "radashi"; import { Page } from "@patternfly/react-core"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; -import { capitalize } from "radashi"; type PageBreakPoints = ReturnType>; +type TextStyleKey = keyof typeof textStyles; type TextProps = React.HTMLProps & React.PropsWithChildren<{ @@ -44,6 +45,22 @@ type TextProps = React.HTMLProps & * Ignored if `srOnly` is true. */ srOn?: PageBreakPoints; + /** + * One or more PatternFly text utility class keys to apply to the element. + * These map directly to keys of the `textStyles` utility object, e.g. + * `"textColorDisabled"` or `["textColorDisabled", "fontSizeSm"]`. + * + * @see https://www.patternfly.org/utility-classes/text + * + * @example + * // Single style + * + * + * @example + * // Multiple styles + * + */ + textStyle?: TextStyleKey | TextStyleKey[]; }>; /** @@ -58,8 +75,9 @@ export default function Text({ isBold = false, srOnly = false, srOn, - className, children, + textStyle, + className, ...props }: TextProps) { const Wrapper = component; @@ -69,11 +87,17 @@ export default function Text({ {...props} className={[ className, + isString(textStyle) && textStyles[textStyle], + isArray(textStyle) && textStyle.map((s) => textStyles[s]), isBold && textStyles.fontWeightBold, (srOnly || srOn === "default") && a11yStyles.screenReader, !srOnly && srOn && srOn !== "default" && a11yStyles[`screenReaderOn${capitalize(srOn)}`], ] .filter(Boolean) + // flat() is needed because join() only applies the separator at the top level, + // using commas for nested arrays instead. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join#description + .flat() .join(" ")} > {children} From 71faf424829de1bef598235b6038cd1b7fbb1551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 23 Feb 2026 13:14:43 +0000 Subject: [PATCH 14/46] web: start adapting storage DASD page Still pending some refactorizations and, more important, the call to the setConfig request when an action is performed. Also, worth reviewing when an action is shown and when not. --- web/src/components/storage/dasd/DASDPage.tsx | 80 ++- .../storage/dasd/DASDTable.test.tsx | 269 +++++---- web/src/components/storage/dasd/DASDTable.tsx | 517 ++++++++++-------- .../storage/dasd/FormatActionHandler.tsx | 16 +- .../storage/dasd/FormatFilter.test.tsx | 25 +- .../components/storage/dasd/FormatFilter.tsx | 13 +- .../storage/dasd/StatusFilter.test.tsx | 28 +- .../components/storage/dasd/StatusFilter.tsx | 19 +- .../storage/dasd/TextinputFilter.tsx | 23 +- 9 files changed, 590 insertions(+), 400 deletions(-) diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index 6b097a8538..2d8c791fb2 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -21,29 +21,79 @@ */ import React from "react"; -import { Page } from "~/components/core"; +import { isEmpty } from "radashi"; +import { EmptyState, EmptyStateBody } from "@patternfly/react-core"; +import Page from "~/components/core/Page"; import DASDTable from "./DASDTable"; -import DASDFormatProgress from "./DASDFormatProgress"; -import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; +import { useSystem } from "~/hooks/model/system/dasd"; +import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; -export default function DASDPage() { - // FIXME: use the API v2 equivalent - // useDASDDevicesChanges(); - // useDASDFormatJobChanges(); +/** + * Renders a PatternFly `EmptyState` block used when no DASD devices are detected + * on the host machine. + */ +const NoDevicesAvailable = () => { + return ( + + {_("No DASD devices were found in this machine.")} + + ); +}; + +/** + * Data-aware content switcher for the DASD page. + * + * Reads the device list and renders the content based on it. + */ +const DASDPageContent = () => { + // const { devices = [] } = useSystem() || {}; + console.log(useSystem); + const devices = [ + { + channel: "0.0.0160", + active: false, + deviceName: "", + type: "", + formatted: false, + diag: false, + status: "offline", + accessType: "", + partitionInfo: "", + }, + { + channel: "0.0.0200", + active: true, + deviceName: "dasda", + type: "eckd", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + ]; + if (isEmpty(devices)) { + return ; + } + + return ; +}; + +/** + * Top-level page component for the DASD storage section. + * + * Wraps content in the shared `Page` shell which provides breadcrumb navigation + * (Storage > DASD) and a standardised content layout. All data concerns are + * delegated to internal `DASDPageContent` component. + */ +export default function DASDPage() { return ( - - + - - - - {_("Back")} - - ); } diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index 9563d142e8..66d60cfdda 100644 --- a/web/src/components/storage/dasd/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -20,144 +20,221 @@ * find current contact information at www.suse.com. */ -import React, { act } from "react"; -import { screen } from "@testing-library/react"; +import React from "react"; +import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import DASDTable from "./DASDTable"; -// FIXME: use a test for DASDTAble holding config devices too. import type { Device } from "~/model/system/dasd"; let mockDASDDevices: Device[] = []; -let eventCallback; -const mockClient = { - onEvent: jest.fn().mockImplementation((cb) => { - eventCallback = cb; - return () => {}; - }), -}; - -jest.mock("~/context/installer", () => ({ - ...jest.requireActual("~/context/installer"), - useInstallerClient: () => mockClient, -})); jest.mock("~/components/storage/dasd/FormatActionHandler", () => () => (
FormatActionHandler Mock
)); -// Skipped during migration to v2 -describe.skip("DASDTable", () => { - describe("when there is some DASD devices available", () => { - beforeEach(() => { - mockDASDDevices = [ - { - channel: "0.0.0160", - active: false, - deviceName: "", - type: "", - formatted: false, - diag: false, - status: "offline", - accessType: "", - partitionInfo: "", - }, - { - channel: "0.0.0200", - active: true, - deviceName: "dasda", - type: "eckd", - formatted: false, - diag: false, - status: "active", - accessType: "rw", - partitionInfo: "1", - }, - ]; - }); +describe("DASDTable", () => { + beforeEach(() => { + mockDASDDevices = [ + { + channel: "0.0.0160", + active: false, + deviceName: "", + type: "", + formatted: false, + diag: false, + status: "offline", + accessType: "", + partitionInfo: "", + }, + { + channel: "0.0.0200", + active: true, + deviceName: "dasda", + type: "eckd", + formatted: true, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + }, + { + channel: "0.0.0300", + active: false, + deviceName: "dasdb", + type: "eckd", + formatted: false, + diag: false, + status: "offline", + accessType: "ro", + partitionInfo: "", + }, + ]; + }); + describe("when there is some DASD devices available", () => { it("renders those devices", () => { - installerRender(); + installerRender(); + + // Both channel IDs appear as rows + screen.getByText("0.0.0160"); + screen.getByText("0.0.0200"); + screen.getByText("0.0.0300"); + + // Status values are rendered + expect(screen.queryAllByText("offline").length).toBe(2); screen.getByText("active"); + + // Device name is shown for the active device + screen.getByText("dasda"); + screen.getByText("dasdb"); }); it("does not offer bulk actions until a device is selected", async () => { - const { user } = installerRender(); - screen.getByText("Select devices to enable bulk actions."); + const { user } = installerRender(); + screen.getByText("Select devices to perform bulk actions"); expect(screen.queryByRole("button", { name: "Activate" })).toBeNull(); const selection = screen.getByRole("checkbox", { name: "Select row 0" }); await user.click(selection); - expect(screen.queryByText("Select devices to enable bulk actions.")).toBeNull(); + expect(screen.queryByText("Select devices to perform bulk actions")).toBeNull(); screen.getByRole("button", { name: "Activate" }); }); it("mounts FormatActionHandler on format action request", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const selection = screen.getByRole("checkbox", { name: "Select row 1" }); await user.click(selection); const button = screen.getByRole("button", { name: "Format" }); await user.click(button); screen.getByText("FormatActionHandler Mock"); }); + }); + + describe("filtering", () => { + beforeEach(() => {}); - describe("when an action is requested", () => { - it("set component as busy", async () => { - const { user } = installerRender(); - const selection = screen.getByRole("checkbox", { name: "Select row 0" }); - await user.click(selection); - const button = screen.getByRole("button", { name: "Activate" }); - await user.click(button); - screen.getByRole("dialog", { name: "Applying changes" }); - expect(screen.queryByRole("checkbox", { name: "Select row 1" })).toBeNull(); + describe("status filter", () => { + it("renders only devices matching the selected status", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Active" })); + + screen.getByText("0.0.0200"); + expect(screen.queryByText("0.0.0160")).toBeNull(); + expect(screen.queryByText("0.0.0300")).toBeNull(); }); }); - describe("when all pending actions are done", () => { - it("set component as idle", async () => { - const { user } = installerRender(); - const selection = screen.getByRole("checkbox", { name: "Select row 0" }); - await user.click(selection); - const button = screen.getByRole("button", { name: "Activate" }); - await user.click(button); - screen.getByRole("dialog", { name: "Applying changes" }); - expect(screen.queryByRole("checkbox", { name: "Select row 0" })).toBeNull(); - - // Simulate a DASDDeviceChanged event - // - act(() => { - eventCallback({ type: "DASDDeviceChanged", device: mockDASDDevices[0] }); - }); - - expect(screen.queryByRole("dialog", { name: "Applying changes" })).toBeNull(); - screen.getByRole("checkbox", { name: "Select row 0" }); + describe("formatted filter", () => { + it("renders only formatted devices when 'yes' is selected", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Formatted")); + await user.click(screen.getByRole("option", { name: "Yes" })); + + screen.getByText("0.0.0200"); + expect(screen.queryByText("0.0.0160")).toBeNull(); + expect(screen.queryByText("0.0.0300")).toBeNull(); + }); + + it("renders only unformatted devices when 'no' is selected", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Formatted")); + await user.click(screen.getByRole("option", { name: "No" })); + + screen.getByText("0.0.0160"); + screen.getByText("0.0.0300"); + expect(screen.queryByText("0.0.0200")).toBeNull(); }); }); - }); - describe("DASDTable/DASDTableEmptyState", () => { - describe("when there are no devices in the system", () => { - beforeEach(() => { - mockDASDDevices = []; + describe("channel range filter", () => { + it("renders only devices within the given channel range", async () => { + const { user } = installerRender(); + await user.type(screen.getByLabelText("Min channel"), "0.0.0200"); + await user.type(screen.getByLabelText("Max channel"), "0.0.0200"); + + screen.getByText("0.0.0200"); + expect(screen.queryByText("0.0.0160")).toBeNull(); + expect(screen.queryByText("0.0.0300")).toBeNull(); }); + }); - it("renders informative empty state with no actions", () => { - installerRender(); - screen.getByRole("heading", { name: "No devices available", level: 2 }); - screen.getByText("No DASD devices were found in this machine."); + describe("device count", () => { + it("renders the total device count when no filter is active", () => { + installerRender(); + screen.getByText("3 devices available"); + }); + + it("renders matching vs total count when a filter is active", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Active" })); + screen.getByText("1 of 3 devices match filters"); + }); + + it("renders 0 of total when no devices match filters", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Formatted")); + await user.click(screen.getByRole("option", { name: "Yes" })); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Offline" })); + screen.getByText("0 of 3 devices match filters"); }); }); - describe("when filters results in no matching device", () => { - it.todo("renders empty state with clear all filters option"); - // it("renders empty state with clear all filters option", async () => { - // const { user } = installerRender(); - // const statusFilterToggle = screen.getByRole("button", { name: "Status" }); - // await user.click(statusFilterToggle); - // const readOnlyOption = screen.getByRole("option", { name: "read_only"}); - // await user.click(readOnlyOption); - // screen.getByRole("heading", { name: "No devices found", level: 2 }); - // screen.getByRole("button", { name: "Clear all filters" }); - // }); + describe("empty state", () => { + it("renders empty state with clear all filters option when no devices match", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Formatted")); + await user.click(screen.getByRole("option", { name: "Yes" })); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Offline" })); + const emptyState = screen + .getByRole("heading", { name: "No devices match filters", level: 2 }) + .closest(".pf-v6-c-empty-state"); + within(emptyState as HTMLElement).getByRole("button", { name: "Clear all filters" }); + }); + }); + + describe("clearing filters", () => { + it("does not render 'Clear all filters' when no filter is active", () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Clear all filters" })).toBeNull(); + }); + + it("renders 'Clear all filters' in the toolbar as soon as a filter is active", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Active" })); + + // Results are non-empty (0.0.0200 matches) but the toolbar link appears anyway + screen.getByText("0.0.0200"); + screen.getByRole("button", { name: "Clear all filters" }); + }); + + it("restores all devices after clearing filters from the toolbar", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Active" })); + expect(screen.queryByText("0.0.0160")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Clear all filters" })); + + screen.getByText("0.0.0160"); + screen.getByText("0.0.0200"); + screen.getByText("0.0.0300"); + }); + + it("hides 'Clear all filters' when filters are manually restored to defaults", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Active" })); + screen.getByRole("button", { name: "Clear all filters" }); + + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "All" })); + expect(screen.queryByRole("button", { name: "Clear all filters" })).toBeNull(); + }); }); }); }); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index d9d41addfd..13f7125b2b 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -20,10 +20,11 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useReducer } from "react"; +import React, { useReducer } from "react"; import { Button, Content, + Divider, EmptyState, EmptyStateActions, EmptyStateBody, @@ -34,6 +35,7 @@ import { ToolbarItem, } from "@patternfly/react-core"; import Icon from "~/components/layout/Icon"; +import Text from "~/components/core/Text"; import Popup from "~/components/core/Popup"; import FormatActionHandler from "~/components/storage/dasd/FormatActionHandler"; import FormatFilter from "~/components/storage/dasd/FormatFilter"; @@ -42,11 +44,10 @@ import StatusFilter from "~/components/storage/dasd/StatusFilter"; import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; -import { useInstallerClient } from "~/context/installer"; import { hex, sortCollection } from "~/utils"; -import { _, n_ } from "~/i18n"; +import { _, n_, N_ } from "~/i18n"; -import type { Device } from "~/model/config/dasd"; +import type { Device } from "~/model/system/dasd"; /** * Filter options for narrowing down DASD devices shown in the table. @@ -59,10 +60,10 @@ export type DASDDevicesFilters = { /** Upper bound for channel ID filtering (inclusive). */ maxChannel?: Device["channel"]; /** Only show devices with this status (e.g. "active", "offline"). */ - state?: "all" | Device["state"]; - /** Filter by formatting status: "yes" (to be formatted), "no" (not to be formatted), or + status?: "all" | Device["status"]; + /** Filter by formatting status: "yes" (formatted), "no" (not formatted), or * "all" (all devices). */ - format?: "all" | "yes" | "no"; + formatted?: "all" | "yes" | "no"; }; /** @@ -75,15 +76,47 @@ export type DASDDevicesFilters = { type DASDDeviceCondition = (device: Device) => boolean; /** - * Props required to generate bulk actions for selected DASD devices. + * Props shared by `buildActions` and `BulkActionsToolbar`. + * + * Used for both single-device row actions and multi-device bulk actions. */ -type DASDActionsBuilderProps = { - /** The list of selected DASD devices. */ +type DASDActionsProps = { + /** The list of DASD devices to act on. */ devices: Device[]; - /** Mutation function used to trigger backend updates (e.g. enable, disable). */ - updater: (options: unknown) => void; // FIXME: adapt former DASDMutationFn to its API v2 equivalent; - /** State dispatcher for triggering actions */ - dispatcher: (props: DASDTableAction) => void; + /** Triggers a backend operation (activate, deactivate, DIAG toggle, etc.). */ + updater: (options: unknown) => void; // FIXME: adapt to API v2 equivalent + /** State dispatcher for in-component actions such as requesting a format. */ + dispatcher: (action: DASDTableAction) => void; +}; + +/** + * Possible DASD devices statuses. + * + * Values use `N_()` for translation extraction. Translate with `_()` at render time. + * + * @example + * ```ts + * const statusLabel = _(STATUS_OPTIONS[device.status]); + * ``` + */ +export const STATUS_OPTIONS = { + active: N_("Active"), + offline: N_("Offline"), +}; + +/** + * Possible DASD format state. + * + * Values use `N_()` for translation extraction. Translate with `_()` at render time. + * + * @example + * ```ts + * const formatLabel = _(FORMAT_OPTIONS[device.formatted]); + * ``` + */ +export const FORMAT_OPTIONS = { + yes: N_("Yes"), + no: N_("No"), }; /** @@ -94,37 +127,36 @@ type DASDActionsBuilderProps = { * @returns The filtered array of DASD Device objects matching all conditions. */ const filterDevices = (devices: Device[], filters: DASDDevicesFilters): Device[] => { - const { minChannel, maxChannel, state, format } = filters; + const { minChannel, maxChannel, status, formatted } = filters; const conditions: DASDDeviceCondition[] = []; if (minChannel || maxChannel) { - const allChannels = devices.map((d) => hex(d.channel)); // FIXME: review te hexId stuff.. + const allChannels = devices.map((d) => hex(d.channel)); const min = hex(minChannel) || Math.min(...allChannels); const max = hex(maxChannel) || Math.max(...allChannels); conditions.push((d) => hex(d.channel) >= min && hex(d.channel) <= max); } - if (state && state !== "all") { - conditions.push((d) => d.state === state); + if (status && status !== "all") { + conditions.push((d) => d.status === status); } - if (format === "yes" || format === "no") { - conditions.push((d) => (format === "yes" ? d.format : !d.format)); + if (formatted === "yes" || formatted === "no") { + conditions.push((d) => (formatted === "yes" ? d.formatted : !d.formatted)); } return devices.filter((device) => conditions.every((conditionFn) => conditionFn(device))); }; /** - * Builds the list of available actions for given DASD devices. + * Builds the list of available actions for a set of DASD devices. * * Returns an array of action objects, each with a label and an `onClick` - * handler. Some actions mutate device state directly (via `updater`), while - * others (like format) dispatch updates via `dispatcher`. + * handler. */ -const buildActions = ({ devices, updater, dispatcher }: DASDActionsBuilderProps) => { +const buildActions = ({ devices, updater, dispatcher }: DASDActionsProps) => { const ids = devices.map((d) => d.channel); return [ { @@ -159,77 +191,146 @@ const buildActions = ({ devices, updater, dispatcher }: DASDActionsBuilderProps) ]; }; -/** - * Props for the FiltersToolbar component used in the DASD table. - */ +/** Props for `FiltersToolbar`. */ type FiltersToolbarProps = { - /** Current filter state */ + /** Currently active filter values. */ filters: DASDDevicesFilters; - /** Callback invoked when a filter value changes. */ + /** + * Status options to show in the status filter, derived from the actual device + * list. + * */ + availableStatuses: object; + /** Whether any filter differs from its default value. */ + hasActiveFilters: boolean; + /** Total number of devices before filtering. */ + totalDevices: number; + /** Number of devices that pass the current filters. */ + matchingDevices: number; + /** Callback invoked when a single filter value changes. */ onFilterChange: (filter: keyof DASDDevicesFilters, value: string | number) => void; + /** Callback invoked when all filters should be reset to their defaults. */ + onReset: () => void; }; /** - * Renders the toolbar used to filter DASD devices. + * Renders the filter controls toolbar for the DASD table. + * + * Displays status, format, and channel range filters alongside a device count + * summary. When any filter is active the count switches from "N devices + * available" to "M of N devices match filters" and a "Clear all filters" link + * appears. */ -const FiltersToolbar = ({ filters, onFilterChange }: FiltersToolbarProps) => ( - - - - - onFilterChange("state", v)} /> - - - onFilterChange("format", v)} /> - - - onFilterChange("minChannel", v)} - /> - - - onFilterChange("maxChannel", v)} - /> - - - - -); +const FiltersToolbar = ({ + filters, + availableStatuses, + hasActiveFilters, + totalDevices, + matchingDevices, + onFilterChange, + onReset, +}: FiltersToolbarProps) => { + const countText = hasActiveFilters + ? sprintf( + // TRANSLATORS: shown in the filter toolbar when filters are active. + // %1$s is the number of matching devices, %2$s is the total number. + _("%1$d of %2$d devices match filters"), + matchingDevices, + totalDevices, + ) + : sprintf( + // TRANSLATORS: shown in the filter toolbar when no filters are active. + // %s is the total number of devices. + _("%d devices available"), + totalDevices, + ); + + return ( + + + + + onFilterChange("status", v)} + /> + + + onFilterChange("formatted", v)} + /> + + + onFilterChange("minChannel", v)} + /> + + + onFilterChange("maxChannel", v)} + /> + + + + + {countText} + + {hasActiveFilters && ( + + + + )} + + + + ); +}; /** * Displays a toolbar containing bulk action buttons for selected DASD devices. * - * If devices are selected, shows available actions; otherwise, displays an - * instructional message. Depends on the same props as `buildActions`. + * @remarks + * When no devices are selected an instructional hint is shown instead. + * Reuses `DASDActionsProps` since it needs the same dependencies as `buildActions`. */ -const BulkActionsToolbar = ({ devices, updater, dispatcher }: DASDActionsBuilderProps) => { +const BulkActionsToolbar = ({ devices, updater, dispatcher }: DASDActionsProps) => { const applyText = sprintf( n_( // TRANSLATORS: message shown in bulk action toolbar when just one device // is selected - "Apply to the selected device", + "Actions for the selected device:", // TRANSLATORS: message shown in bulk action toolbar when some devices are // selected. %s is replaced with the amount of devices - "Apply to the %s selected devices", + "Actions for %s selected devices:", devices.length, ), devices.length, ); return ( - - - - {devices.length ? ( - <> - {applyText}{" "} + + + {devices.length ? ( + <> + + + {applyText} + + + + {buildActions({ devices, updater, dispatcher }) .filter((a) => !a.isSeparator) .map(({ onClick, title }, i) => ( @@ -239,82 +340,17 @@ const BulkActionsToolbar = ({ devices, updater, dispatcher }: DASDActionsBuilder ))} - - ) : ( - _("Select devices to enable bulk actions.") - )} - + + + ) : ( + {_("Select devices to perform bulk actions")} + )} ); }; -/** - * Represents the mode of the empty state shown in the DASD table. - * - * - "noDevices": No DASD devices are present on the system. - * - "noFilterResults": No matching results after appluing filters. - */ -type DASDEmptyStateMode = "noDevices" | "noFilterResults"; - -/** - * Props for the DASDTableEmptyState component. - */ -type DASDTableEmptyStateProps = { - /** - * Determines the type of empty state to display. - */ - mode: DASDEmptyStateMode; - /** - * Callback to reset filters when in "noFilterResults" mode. - */ - resetFilters: () => void; -}; - -/** - * Displays an appropriate empty state interface for the DASD table, - * depending on the mode. - */ -const DASDTableEmptyState = ({ mode, resetFilters }: DASDTableEmptyStateProps) => { - switch (mode) { - case "noDevices": { - return ( - } - variant="sm" - > - {_("No DASD devices were found in this machine.")} - - ); - } - case "noFilterResults": { - return ( - } - variant="sm" - > - {_("Change filters and try again.")} - - - - - - - ); - } - } -}; - -/** - * Encapsulates all state used by the DASD table component, including filters, - * sorting configuration, current selection, and devices to be format. - */ +/** Internal state shape for the DASD table component. */ type DASDTableState = { /** Current sorting state */ sortedBy: SortedBy; @@ -329,13 +365,18 @@ type DASDTableState = { }; /** - * Defines the initial state used by the DASD table reducer. + * Initial state for `reducer`. + * + * @remarks + * Also serves as the canonical "no filters active" reference: filter changes + * are detected by comparing the current filters against this object via + * `JSON.stringify`. */ const initialState: DASDTableState = { sortedBy: { index: 0, direction: "asc" }, filters: { - state: "all", - format: "all", + status: "all", + formatted: "all", minChannel: "", maxChannel: "", }, @@ -345,8 +386,8 @@ const initialState: DASDTableState = { }; /** - * Action types for updating the DASD table state via the reducer. - */ + * Union of all actions that can be dispatched to update the DASD table state. + **/ type DASDTableAction = | { type: "UPDATE_SORTING"; payload: DASDTableState["sortedBy"] } | { type: "UPDATE_FILTERS"; payload: DASDTableState["filters"] } @@ -355,12 +396,12 @@ type DASDTableAction = | { type: "RESET_SELECTION" } | { type: "REQUEST_FORMAT"; payload: DASDTableState["devicesToFormat"] } | { type: "CANCEL_FORMAT_REQUEST" } - | { type: "START_WAITING"; payload: Device["channel"][] } - | { type: "UPDATE_WAITING"; payload: Device["channel"] } | { type: "UPDATE_DEVICE"; payload: Device }; /** - * Reducer function that handles all DASD table state transitions. + * Reducer for the DASD table. + * + * Handles all state transitions driven by `DASDTableAction` dispatches. */ const reducer = (state: DASDTableState, action: DASDTableAction): DASDTableState => { switch (action.type) { @@ -392,16 +433,6 @@ const reducer = (state: DASDTableState, action: DASDTableAction): DASDTableState return { ...state, devicesToFormat: [] }; } - case "START_WAITING": { - return { ...state, waitingFor: action.payload }; - } - - case "UPDATE_WAITING": { - const prev = state.waitingFor; - const waitingFor = prev.filter((id) => action.payload !== id); - return { ...state, waitingFor }; - } - case "UPDATE_DEVICE": { const selectedDevices = state.selectedDevices.map((dev) => action.payload.channel === dev.channel ? action.payload : dev, @@ -417,97 +448,83 @@ const reducer = (state: DASDTableState, action: DASDTableAction): DASDTableState /** * Column definitions for the DASD devices table. * - * Each entry defines how a column is labeled, how its value is derived from a - * DASDDevice object, and which field is used for sorting. - * - * These columns are consumed by the core component. + * Each entry defines the column header label, how its value is derived from a + * `Device`, and which field drives sorting. Consumed by `SelectableDataTable`. */ const createColumns = () => [ { // TRANSLATORS: table header for a DASD devices table - name: _("Channel ID"), + name: _("Channel"), value: (d: Device) => d.channel, - // FIXME: Needs to be rethink with the new types. Most probably an specific type - // for the table should be created - // sortingKey: "hexId", // uses the hexadecimal representation for sorting + sortingKey: (d: Device) => hex(d.channel), + }, + { + // TRANSLATORS: table header for a DASD devices table + name: _("Status"), + value: (d: Device) => d.status, + sortingKey: "status", + }, + { + // TRANSLATORS: table header for a DASD devices table + name: _("Device"), + value: (d: Device) => d.deviceName, + sortingKey: "deviceName", }, { // TRANSLATORS: table header for a DASD devices table - name: _("State"), - value: (d: Device) => d.state, - sortingKey: "state", + name: _("Type"), + value: (d: Device) => d.type, + sortingKey: "type", }, - // FIXME: reactivate for the DASD system devices table - // { - // // TRANSLATORS: table header for a DASD devices table - // name: _("Device"), - // value: (d: DASDDevice) => d.deviceName, - // sortingKey: "deviceName", - // }, - // - // FIXME: reactivate for the DASD system devices table - // { - // // TRANSLATORS: table header for a DASD devices table - // name: _("Type"), - // value: (d: Device) => d.deviceType, - // sortingKey: "deviceType", - // }, - // FIXME: review { // TRANSLATORS: table header for `DIAG access mode` on DASD devices table. // It refers to an special disk access mode on IBM mainframes. Keep // untranslated. name: _("DIAG"), value: (d: Device) => { - if (!d.state) return ""; + if (!d.status) return ""; return d.diag ? _("Yes") : _("No"); }, sortingKey: "diag", }, + { + // TRANSLATORS: table header for a column in a DASD devices table that + // usually contains "Yes" or "No"" values + name: _("Formatted"), + value: (d: Device) => (d.formatted ? _("Yes") : _("No")), + sortingKey: "formatted", + }, + { + // TRANSLATORS: table header for a DASD devices table + name: _("Partition Info"), - // FIXME: reactivate for the DASD system devices table. Most probably for - // system it's formatted and for config just format (to be formatted) - // { - // // TRANSLATORS: table header for a column in a DASD devices table that - // // usually contains "Yes" or "No"" values - // name: _("Formatted"), - // value: (d: Device) => (d.formatted ? _("Yes") : _("No")), - // sortingKey: "formatted", - // }, - // { - // FIXME: reactivate for the DASD system devices table. Most probably for - // // TRANSLATORS: table header for a DASD devices table - // name: _("Partition Info"), - // - // value: (d: Device) => - // // Displays comma-separated partition info as individual lines using
- // d.partitionInfo.split(",").map((d: string) =>
{d}
), - // sortingKey: "partitionInfo", - // }, + value: (d: Device) => + // Displays comma-separated partition info as individual lines using
+ d.partitionInfo.split(",").map((d: string) =>
{d}
), + sortingKey: "partitionInfo", + }, ]; -export default function DASDTable() { - const client = useInstallerClient(); - const devices = []; // FIXME: use new api useDASDDevices(); - // FIXME: use te equivalent in the new API - // const { mutate: updateDASD } = useDASDMutation(); +/** + * Dispatches a DASD config requrest + * + * @fixme implement equivalent for API v2 + */ +const updater = (options) => console.log("FIXME: implement equivalente for new API", options); + +/** + * Displays a filterable, sortable, selectable table of DASD storage devices. + * + * Manages its own UI state (filters, sorting, selection, pending format + * requests) via a reducer. + */ +export default function DASDTable({ devices }) { const [state, dispatch] = useReducer(reducer, initialState); const columns = createColumns(); - useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - if (event.type === "DASDDeviceChanged") { - dispatch({ type: "UPDATE_DEVICE", payload: event.device }); - dispatch({ type: "UPDATE_WAITING", payload: event.device.id }); - } - }); - }, [client, dispatch]); - const onSortingChange = (sortedBy: SortedBy) => { dispatch({ type: "UPDATE_SORTING", payload: sortedBy }); }; @@ -530,17 +547,9 @@ export default function DASDTable() { const sortingKey = columns[state.sortedBy.index].sortingKey; const sortedDevices = sortCollection(filteredDevices, state.sortedBy.direction, sortingKey); - // Determine the appropriate empty state mode, if needed - let emptyMode: DASDEmptyStateMode; - if (isEmpty(filteredDevices)) { - emptyMode = state.filters === initialState.filters ? "noDevices" : "noFilterResults"; - } - /** - * Dispatches a DASD mutation and marks devices as waiting. - * - * @param mutation Parameters describing the DASD update operation - */ - const updater = (options) => console.log("FIXME: implement equivalente for new API", options); + const availableStatuses = Object.fromEntries( + Object.entries(STATUS_OPTIONS).filter(([key]) => devices.some((d: Device) => d.status === key)), + ); return ( @@ -554,8 +563,26 @@ export default function DASDTable() { )} - - + + {!isEmpty(filteredDevices) && ( + <> + + + + + )} {!isEmpty(state.devicesToFormat) && ( `Actions for ${d.id}`} - emptyState={} + emptyState={ + } + variant="sm" + > + {_("Change filters and try again.")} + + + + + + + } /> ); diff --git a/web/src/components/storage/dasd/FormatActionHandler.tsx b/web/src/components/storage/dasd/FormatActionHandler.tsx index 46cbfc0001..0fceb4761d 100644 --- a/web/src/components/storage/dasd/FormatActionHandler.tsx +++ b/web/src/components/storage/dasd/FormatActionHandler.tsx @@ -28,7 +28,7 @@ import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { isEmpty } from "radashi"; -import type { Device } from "~/model/config/dasd"; +import type { Device } from "~/model/system/dasd"; /** * Shared type for defining props used by all DASD format-related dialogs and @@ -51,7 +51,9 @@ type CommonFormatDASDProps = { const DevicesList = ({ devices }: Pick) => ( {devices.map((d: Device) => ( - {d.channel} + + {d.channel} {d.deviceName} + ))} ); @@ -84,7 +86,7 @@ const SomeDevicesOffline = ({ devices, onCancel, }: Pick) => { - const offlineDevices = devices.filter((d) => d.state === "offline"); + const offlineDevices = devices.filter((d) => d.status === "offline"); const totalOffline = offlineDevices.length; return ( @@ -115,7 +117,7 @@ const DeviceFormatConfirmation = ({ - {_("This action could destroy any data stored on the device.")} + {_("This action will destroy any data stored on the device.")} {_("Confirm that you really want to continue.")} @@ -140,7 +142,7 @@ const MultipleDevicesFormatConfirmation = ({ - {_("This action could destroy any data stored on the devices listed below.")} + {_("This action will destroy any data stored on the devices listed below.")} {_("Confirm that you really want to continue.")} @@ -191,14 +193,14 @@ export default function FormatActionHandler({ if (devices.length === 1) { const device = devices[0]; - if (device.state === "active") { + if (device.status === "active") { return ; } else { return ; } } - if (devices.some((d) => d.state === "offline")) { + if (devices.some((d) => d.status === "offline")) { return ; } else { return ( diff --git a/web/src/components/storage/dasd/FormatFilter.test.tsx b/web/src/components/storage/dasd/FormatFilter.test.tsx index 77d5af37f5..12ee5b77c4 100644 --- a/web/src/components/storage/dasd/FormatFilter.test.tsx +++ b/web/src/components/storage/dasd/FormatFilter.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -23,30 +23,41 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; +import { N_ } from "~/i18n"; import FormatFilter from "./FormatFilter"; const onChangeFn = jest.fn(); +const selectOptions = { + all: N_("All"), + yes: N_("Yes"), + no: N_("No"), +}; + describe("DASD/FormatFilter", () => { it("renders a select menu to filter DASD devices by format status", async () => { - const { user } = plainRender(); + const { user } = plainRender( + , + ); // Not using the label name to retrieve the MenuToggle button because a bug // PF/MenuToggle has, 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: "yes" }); - within(options).getByRole("option", { name: "no" }); + within(options).getByRole("option", { name: "All" }); + within(options).getByRole("option", { name: "Yes" }); + within(options).getByRole("option", { name: "No" }); }); it("calls onChange when a format option is selected", async () => { - const { user } = plainRender(); + const { user } = plainRender( + , + ); const toggle = screen.getByRole("button"); await user.click(toggle); const options = screen.getByRole("listbox"); - const activeOption = within(options).getByRole("option", { name: "yes" }); + const activeOption = within(options).getByRole("option", { name: "Yes" }); await user.click(activeOption); expect(onChangeFn).toHaveBeenCalledWith(expect.anything(), "yes"); }); diff --git a/web/src/components/storage/dasd/FormatFilter.tsx b/web/src/components/storage/dasd/FormatFilter.tsx index d95aa77be9..2f0cdd6be0 100644 --- a/web/src/components/storage/dasd/FormatFilter.tsx +++ b/web/src/components/storage/dasd/FormatFilter.tsx @@ -32,19 +32,14 @@ import { } from "@patternfly/react-core"; import Text from "~/components/core/Text"; import { DASDDevicesFilters } from "~/components/storage/dasd/DASDTable"; -import { N_, _ } from "~/i18n"; +import { _ } from "~/i18n"; type FormatFilterProps = { - value: DASDDevicesFilters["format"]; + value: DASDDevicesFilters["formatted"]; + options: object; onChange: SelectProps["onSelect"]; }; -const options = { - all: N_("all"), - yes: N_("yes"), - no: N_("no"), -}; - const ID = "dasd-format-filter"; /** @@ -61,7 +56,7 @@ const ID = "dasd-format-filter"; * There is an issue with a11y label for the PF/MenuToggle, check * https://github.com/patternfly/patternfly-react/issues/11805 */ -export default function FormattedFilter({ value, onChange }: FormatFilterProps) { +export default function FormattedFilter({ value, options, onChange }: FormatFilterProps) { const [isOpen, setIsOpen] = useState(false); const onToggle = () => setIsOpen(!isOpen); diff --git a/web/src/components/storage/dasd/StatusFilter.test.tsx b/web/src/components/storage/dasd/StatusFilter.test.tsx index 16205c6eec..791bc6697f 100644 --- a/web/src/components/storage/dasd/StatusFilter.test.tsx +++ b/web/src/components/storage/dasd/StatusFilter.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -23,31 +23,41 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; +import { N_ } from "~/i18n"; import StatusFilter from "./StatusFilter"; const onChangeFn = jest.fn(); +const selectOptions = { + all: N_("All"), + active: N_("Active"), + inactive: N_("Not active"), +}; + describe("DASD/StatusFilter", () => { - it("renders a select menu with available DASD status options", async () => { - const { user } = plainRender(); + it("renders a select menu with given options", async () => { + const { user } = plainRender( + , + ); // Not using the label name to retrieve the MenuToggle button because a bug // PF/MenuToggle has, 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: "read_only" }); - within(options).getByRole("option", { name: "offline" }); + within(options).getByRole("option", { name: "All" }); + within(options).getByRole("option", { name: "Active" }); + within(options).getByRole("option", { name: "Not active" }); }); it("calls onChange when a status option is selected", async () => { - const { user } = plainRender(); + 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" }); + const activeOption = within(options).getByRole("option", { name: "Active" }); await user.click(activeOption); expect(onChangeFn).toHaveBeenCalledWith(expect.anything(), "active"); }); diff --git a/web/src/components/storage/dasd/StatusFilter.tsx b/web/src/components/storage/dasd/StatusFilter.tsx index 3e0700cb08..5e69857ba7 100644 --- a/web/src/components/storage/dasd/StatusFilter.tsx +++ b/web/src/components/storage/dasd/StatusFilter.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -31,29 +31,22 @@ import { SelectProps, } from "@patternfly/react-core"; import Text from "~/components/core/Text"; -import { N_, _ } from "~/i18n"; +import { _ } from "~/i18n"; type StatusFilterProps = { value: string; + options: object; 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. + * statuses. The selected value is passed to the parent via the `onChange` + * callback along with the event originating the action. * * Used as part of the DASD table filtering toolbar. * @@ -61,7 +54,7 @@ const ID = "dasd-status-filter"; * 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 StatusFilter({ value, options, onChange }: StatusFilterProps) { const [isOpen, setIsOpen] = useState(false); const onToggle = () => setIsOpen(!isOpen); diff --git a/web/src/components/storage/dasd/TextinputFilter.tsx b/web/src/components/storage/dasd/TextinputFilter.tsx index 338c07df7f..cb22355b65 100644 --- a/web/src/components/storage/dasd/TextinputFilter.tsx +++ b/web/src/components/storage/dasd/TextinputFilter.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -24,8 +24,8 @@ import React from "react"; import { Button, Flex, + TextInput, TextInputGroup, - TextInputGroupMain, TextInputGroupUtilities, TextInputProps, } from "@patternfly/react-core"; @@ -33,7 +33,9 @@ import Icon from "~/components/layout/Icon"; import Text from "~/components/core/Text"; import { _ } from "~/i18n"; -type TextinputFilterProps = Required>; +type TextinputFilterProps = Required< + Pick +> & { width?: TextInputProps["width"] }; /** * TextinputFilter Component @@ -52,7 +54,13 @@ type TextinputFilterProps = Required { // @ts-expect-error: passing an empty object as a fake event to satisfy // onChange signature. For a fully correct native input event, see @@ -68,13 +76,14 @@ export default function TextinputFilter({ id, label, value, onChange }: Textinpu - - + {value !== "" && ( From cb03a7f1188d716c12824df8097e04f969329c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 24 Feb 2026 14:07:19 +0000 Subject: [PATCH 15/46] feat(web): add utility function for translating object entries Accepts an optional filter predicate to exclude entries from the result. Useful when building filter selectors from translated option maps. --- web/src/utils.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++--- web/src/utils.ts | 43 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 8a3b4185ac..3b3e4306da 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2026] SUSE LLC * * All Rights Reserved. * @@ -20,6 +20,7 @@ * find current contact information at www.suse.com. */ +import { N_ } from "~/i18n"; import { compact, localConnection, @@ -30,11 +31,19 @@ import { sortCollection, mergeSources, extendCollection, + translateEntries, } from "./utils"; + import type { Target as ConfigTarget } from "~/openapi/config/iscsi"; import type { Target as SystemTarget } from "~/openapi/system/iscsi"; -import type { Device as ConfigDevice } from "./openapi/config/dasd"; -import type { Device as SystemDevice } from "./openapi/system/dasd"; +import type { Device as ConfigDevice } from "~/openapi/config/dasd"; +import type { Device as SystemDevice } from "~/openapi/system/dasd"; + +// Mock _() to simulate translation +jest.mock("~/i18n", () => ({ + _: (s: string) => `translated(${s})`, + N_: (s: string) => s, +})); describe("compact", () => { it("removes null and undefined values", () => { @@ -914,4 +923,35 @@ describe("extendCollection", () => { expect(extended[0]).toEqual({ id: 1, value: "a", extra: "b" }); }); }); + + describe("translateEntries", () => { + const OPTIONS = { + active: N_("Active"), + offline: N_("Offline"), + unknown: N_("Unknown"), + }; + + it("returns all entries translated when no filter is provided", () => { + expect(translateEntries(OPTIONS)).toEqual({ + active: "translated(Active)", + offline: "translated(Offline)", + unknown: "translated(Unknown)", + }); + }); + + it("returns only translated entries matching the filter", () => { + expect(translateEntries(OPTIONS, { filter: (key) => key !== "unknown" })).toEqual({ + active: "translated(Active)", + offline: "translated(Offline)", + }); + }); + + it("returns an empty object when filter excludes all entries", () => { + expect(translateEntries(OPTIONS, { filter: () => false })).toEqual({}); + }); + + it("returns an empty object when given an empty record", () => { + expect(translateEntries({})).toEqual({}); + }); + }); }); diff --git a/web/src/utils.ts b/web/src/utils.ts index afb2f7516f..f06817d4e7 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2025] SUSE LLC + * Copyright (c) [2022-2026] SUSE LLC * * All Rights Reserved. * @@ -23,6 +23,9 @@ import { isArray, isPlainObject, mapEntries } from "radashi"; import { generatePath } from "react-router"; import { ISortBy, sort } from "fast-sort"; +import { _ } from "~/i18n"; + +import type { TranslatedString } from "~/i18n"; /** * Generates a new array without null and undefined values. @@ -619,6 +622,43 @@ function extendCollection( }; } +/** Options for translateEntries utility */ +type TranslateEntriesOptions = { + /** Optional predicate to exclude entries by key. */ + filter?: (key: string) => boolean; +}; + +/** + * Translates the values of a string record using `_()`, optionally filtering + * entries by key. + * + * @example + * // Translate all entries + * const FORMAT_OPTIONS = { yes: N_("Yes"), no: N_("No") }; + * translateEntries(FORMAT_OPTIONS) + * + * @example + * // Translate only entries matching a condition + * const STATUS_OPTIONS = { active: N_("Active"), offline: N_("Offline") }; + * translateEntries(STATUS_OPTIONS, { filter: (status) => devices.some((d) => d.status === status) }) + * + * @param entries - A record whose values are translation keys. + * @param options - Optional configuration. + * @param options.filter - Optional predicate to exclude entries by key. + * @returns A new record with the same keys and translated values. + * + */ +const translateEntries = ( + entries: Record, + { filter }: TranslateEntriesOptions = {}, +): Record => + Object.fromEntries( + Object.entries(entries) + .filter(([key]) => filter?.(key) ?? true) + /* eslint-disable agama-i18n/string-literals */ + .map(([key, value]) => [key, _(value)]), + ); + export { compact, hex, @@ -632,4 +672,5 @@ export { sortCollection, mergeSources, extendCollection, + translateEntries, }; From 105a6d2216a4611bef498926cc8d0fcc2b639e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 24 Feb 2026 14:27:32 +0000 Subject: [PATCH 16/46] feat(web): replace FormatFilter and StatusFilter with SimpleSelector Both components were nearly identical. Replace them with the new generic SimpleSelector, removing duplicated code. --- web/src/components/storage/dasd/DASDTable.tsx | 35 ++++--- .../storage/dasd/FormatFilter.test.tsx | 64 ------------ .../components/storage/dasd/FormatFilter.tsx | 98 ------------------- .../storage/dasd/StatusFilter.test.tsx | 64 ------------ .../components/storage/dasd/StatusFilter.tsx | 97 ------------------ 5 files changed, 21 insertions(+), 337 deletions(-) delete mode 100644 web/src/components/storage/dasd/FormatFilter.test.tsx delete mode 100644 web/src/components/storage/dasd/FormatFilter.tsx delete mode 100644 web/src/components/storage/dasd/StatusFilter.test.tsx delete mode 100644 web/src/components/storage/dasd/StatusFilter.tsx diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 13f7125b2b..31924a7383 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -38,13 +38,12 @@ import Icon from "~/components/layout/Icon"; import Text from "~/components/core/Text"; import Popup from "~/components/core/Popup"; import FormatActionHandler from "~/components/storage/dasd/FormatActionHandler"; -import FormatFilter from "~/components/storage/dasd/FormatFilter"; import SelectableDataTable, { SortedBy } from "~/components/core/SelectableDataTable"; -import StatusFilter from "~/components/storage/dasd/StatusFilter"; import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; +import SimpleSelector from "~/components/core/SimpleSelector"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; -import { hex, sortCollection } from "~/utils"; +import { hex, sortCollection, translateEntries } from "~/utils"; import { _, n_, N_ } from "~/i18n"; import type { Device } from "~/model/system/dasd"; @@ -196,10 +195,11 @@ type FiltersToolbarProps = { /** Currently active filter values. */ filters: DASDDevicesFilters; /** - * Status options to show in the status filter, derived from the actual device - * list. - * */ - availableStatuses: object; + * Unique statuses present in the current device list, used to restrict the + * status filter to only relevant options. Does not include the synthetic "all" + * option. + */ + availableStatuses: Device["status"][]; /** Whether any filter differs from its default value. */ hasActiveFilters: boolean; /** Total number of devices before filtering. */ @@ -249,16 +249,23 @@ const FiltersToolbar = ({ - availableStatuses.includes(k), + }), + }} onChange={(_, v) => onFilterChange("status", v)} /> - onFilterChange("formatted", v)} /> @@ -547,9 +554,9 @@ export default function DASDTable({ devices }) { const sortingKey = columns[state.sortedBy.index].sortingKey; const sortedDevices = sortCollection(filteredDevices, state.sortedBy.direction, sortingKey); - const availableStatuses = Object.fromEntries( - Object.entries(STATUS_OPTIONS).filter(([key]) => devices.some((d: Device) => d.status === key)), - ); + const availableStatuses = [ + ...new Set(devices.map((d: Device) => d.status)), + ] as Device["status"][]; return ( diff --git a/web/src/components/storage/dasd/FormatFilter.test.tsx b/web/src/components/storage/dasd/FormatFilter.test.tsx deleted file mode 100644 index 12ee5b77c4..0000000000 --- a/web/src/components/storage/dasd/FormatFilter.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) [2025-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 { N_ } from "~/i18n"; -import FormatFilter from "./FormatFilter"; - -const onChangeFn = jest.fn(); - -const selectOptions = { - all: N_("All"), - yes: N_("Yes"), - no: N_("No"), -}; - -describe("DASD/FormatFilter", () => { - it("renders a select menu to filter DASD devices by format status", async () => { - const { user } = plainRender( - , - ); - // Not using the label name to retrieve the MenuToggle button because a bug - // PF/MenuToggle has, 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: "Yes" }); - within(options).getByRole("option", { name: "No" }); - }); - - it("calls onChange when a format 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: "Yes" }); - await user.click(activeOption); - expect(onChangeFn).toHaveBeenCalledWith(expect.anything(), "yes"); - }); -}); diff --git a/web/src/components/storage/dasd/FormatFilter.tsx b/web/src/components/storage/dasd/FormatFilter.tsx deleted file mode 100644 index 2f0cdd6be0..0000000000 --- a/web/src/components/storage/dasd/FormatFilter.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) [2025-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 { - Flex, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, - SelectProps, -} from "@patternfly/react-core"; -import Text from "~/components/core/Text"; -import { DASDDevicesFilters } from "~/components/storage/dasd/DASDTable"; -import { _ } from "~/i18n"; - -type FormatFilterProps = { - value: DASDDevicesFilters["formatted"]; - options: object; - onChange: SelectProps["onSelect"]; -}; - -const ID = "dasd-format-filter"; - -/** - * Select component for filtering DASD devices by format status. - * - * Renders a PF/Select input that lets users filter DASD devices based on - * whether they are formatted, unformatted, or both. The selected value is - * passed to the parent via the `onChange` callback, along with the event that - * triggered the change. - * - * Used as part of the DASD table filtering toolbar. - * - * @privateRemarks - * There is an issue with a11y label for the PF/MenuToggle, check - * https://github.com/patternfly/patternfly-react/issues/11805 - */ -export default function FormattedFilter({ value, options, onChange }: FormatFilterProps) { - const [isOpen, setIsOpen] = useState(false); - const onToggle = () => setIsOpen(!isOpen); - - const toggle = (toggleRef: React.Ref) => ( - - {/* eslint-disable agama-i18n/string-literals */} - {_(options[value])} - - ); - - return ( - - - - - ); -} diff --git a/web/src/components/storage/dasd/StatusFilter.test.tsx b/web/src/components/storage/dasd/StatusFilter.test.tsx deleted file mode 100644 index 791bc6697f..0000000000 --- a/web/src/components/storage/dasd/StatusFilter.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) [2025-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 { N_ } from "~/i18n"; -import StatusFilter from "./StatusFilter"; - -const onChangeFn = jest.fn(); - -const selectOptions = { - all: N_("All"), - active: N_("Active"), - inactive: N_("Not active"), -}; - -describe("DASD/StatusFilter", () => { - it("renders a select menu with given options", async () => { - const { user } = plainRender( - , - ); - // Not using the label name to retrieve the MenuToggle button because a bug - // PF/MenuToggle has, 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: "Not active" }); - }); - - it("calls onChange when a status 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"); - }); -}); diff --git a/web/src/components/storage/dasd/StatusFilter.tsx b/web/src/components/storage/dasd/StatusFilter.tsx deleted file mode 100644 index 5e69857ba7..0000000000 --- a/web/src/components/storage/dasd/StatusFilter.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (c) [2025-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 { - Flex, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, - SelectProps, -} from "@patternfly/react-core"; -import Text from "~/components/core/Text"; -import { _ } from "~/i18n"; - -type StatusFilterProps = { - value: string; - options: object; - onChange: SelectProps["onSelect"]; -}; - -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. The selected value is passed to the parent via the `onChange` - * callback along with the event originating the action. - * - * Used as part of the DASD table filtering toolbar. - * - * @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, options, onChange }: StatusFilterProps) { - const [isOpen, setIsOpen] = useState(false); - const onToggle = () => setIsOpen(!isOpen); - - const toggle = (toggleRef: React.Ref) => ( - - {/* eslint-disable agama-i18n/string-literals */} - {_(options[value])} - - ); - - return ( - - - - - - ); -} From fbccfcc4355d2997b38cec795f3c50c5428cd30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 24 Feb 2026 14:28:10 +0000 Subject: [PATCH 17/46] fix(web): uses channel instead of id --- web/src/components/storage/dasd/DASDTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 31924a7383..069f8fa998 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -611,14 +611,14 @@ export default function DASDTable({ devices }) { sortedBy={state.sortedBy} updateSorting={onSortingChange} allowSelectAll - itemActions={(d) => + itemActions={(d: Device) => buildActions({ devices: [d], updater, // FIXME dispatcher: dispatch, }) } - itemActionsLabel={(d) => `Actions for ${d.id}`} + itemActionsLabel={(d: Device) => `Actions for ${d.channel}`} emptyState={ Date: Tue, 24 Feb 2026 18:16:20 +0000 Subject: [PATCH 18/46] feat(dasd): adapt DASDTable to config API v2 Replace the old action-based updater pattern with direct use of `useAddOrUpdateDevices`, building `DeviceConfig` objects at the point of use. Simplify `FormatActionHandler` callback contract to `onFormat` and `onClose`. Harden `extendCollection` to handle undefined inputs. Update and extend tests accordingly. --- .../storage/dasd/DASDTable.test.tsx | 94 +++++++++ web/src/components/storage/dasd/DASDTable.tsx | 67 +++--- .../storage/dasd/FormatActionHandler.test.tsx | 77 ++++--- .../storage/dasd/FormatActionHandler.tsx | 88 ++++---- web/src/hooks/model/config/dasd.test.ts | 97 ++++++++- web/src/hooks/model/config/dasd.ts | 26 ++- web/src/utils.test.ts | 198 +++++++++++------- web/src/utils.ts | 79 ++++--- 8 files changed, 523 insertions(+), 203 deletions(-) diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index 66d60cfdda..e5a43ed796 100644 --- a/web/src/components/storage/dasd/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -28,13 +28,19 @@ import DASDTable from "./DASDTable"; import type { Device } from "~/model/system/dasd"; let mockDASDDevices: Device[] = []; +const mockAddOrUpdateDevices = jest.fn(); jest.mock("~/components/storage/dasd/FormatActionHandler", () => () => (
FormatActionHandler Mock
)); +jest.mock("~/hooks/model/config/dasd", () => ({ + useAddOrUpdateDevices: () => mockAddOrUpdateDevices, +})); + describe("DASDTable", () => { beforeEach(() => { + mockAddOrUpdateDevices.mockClear(); mockDASDDevices = [ { channel: "0.0.0160", @@ -236,5 +242,93 @@ describe("DASDTable", () => { expect(screen.queryByRole("button", { name: "Clear all filters" })).toBeNull(); }); }); + + describe("actions", () => { + it("calls addOrUpdateDevices with correct config on activate", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("button", { name: "Activate" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", status: "online" }, + ]); + }); + + it("calls addOrUpdateDevices with correct config on deactivate", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + await user.click(screen.getByRole("button", { name: "Deactivate" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0200", status: "offline" }, + ]); + }); + + it("calls addOrUpdateDevices with correct config on DIAG on", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("button", { name: "Set DIAG on" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([{ channel: "0.0.0160", diag: true }]); + }); + + it("calls addOrUpdateDevices with correct config on DIAG off", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("button", { name: "Set DIAG off" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([{ channel: "0.0.0160", diag: false }]); + }); + }); + + describe("bulk actions", () => { + it("calls addOrUpdateDevices for all selected devices on activate", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + await user.click(screen.getByRole("button", { name: "Activate" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", status: "online" }, + { channel: "0.0.0200", status: "online" }, + ]); + }); + + it("calls addOrUpdateDevices for all selected devices on deactivate", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + await user.click(screen.getByRole("button", { name: "Deactivate" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", status: "offline" }, + { channel: "0.0.0200", status: "offline" }, + ]); + }); + + it("calls addOrUpdateDevices for all selected devices on DIAG on", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + await user.click(screen.getByRole("button", { name: "Set DIAG on" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", diag: true }, + { channel: "0.0.0200", diag: true }, + ]); + }); + + it("calls addOrUpdateDevices for all selected devices on DIAG off", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + await user.click(screen.getByRole("button", { name: "Set DIAG off" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", diag: false }, + { channel: "0.0.0200", diag: false }, + ]); + }); + + it("mounts FormatActionHandler when Format is clicked for selected devices", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + await user.click(screen.getByRole("button", { name: "Format" })); + screen.getByText("FormatActionHandler Mock"); + }); + }); }); }); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 069f8fa998..8978198e5f 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -21,6 +21,8 @@ */ import React, { useReducer } from "react"; +import { isEmpty } from "radashi"; +import { sprintf } from "sprintf-js"; import { Button, Content, @@ -41,12 +43,12 @@ import FormatActionHandler from "~/components/storage/dasd/FormatActionHandler"; import SelectableDataTable, { SortedBy } from "~/components/core/SelectableDataTable"; import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; import SimpleSelector from "~/components/core/SimpleSelector"; -import { isEmpty } from "radashi"; -import { sprintf } from "sprintf-js"; +import { useAddOrUpdateDevices } from "~/hooks/model/config/dasd"; import { hex, sortCollection, translateEntries } from "~/utils"; import { _, n_, N_ } from "~/i18n"; import type { Device } from "~/model/system/dasd"; +import type { Device as ConfigDevice } from "~/model/config/dasd"; /** * Filter options for narrowing down DASD devices shown in the table. @@ -77,15 +79,21 @@ type DASDDeviceCondition = (device: Device) => boolean; /** * Props shared by `buildActions` and `BulkActionsToolbar`. * - * Used for both single-device row actions and multi-device bulk actions. + * Covers both single-device row actions and multi-device bulk actions. */ type DASDActionsProps = { - /** The list of DASD devices to act on. */ + /** Devices to act on. */ devices: Device[]; - /** Triggers a backend operation (activate, deactivate, DIAG toggle, etc.). */ - updater: (options: unknown) => void; // FIXME: adapt to API v2 equivalent - /** State dispatcher for in-component actions such as requesting a format. */ - dispatcher: (action: DASDTableAction) => void; + /** + * Persists device config changes to the backend. + * Used for activate, deactivate, DIAG toggle, etc. + */ + addOrUpdateDevices: ReturnType; + /** + * Dispatcher for local UI state changes, such as opening the format + * confirmation dialog. + */ + dispatcher: React.Dispatch; }; /** @@ -98,7 +106,7 @@ type DASDActionsProps = { * const statusLabel = _(STATUS_OPTIONS[device.status]); * ``` */ -export const STATUS_OPTIONS = { +const STATUS_OPTIONS = { active: N_("Active"), offline: N_("Offline"), }; @@ -113,7 +121,7 @@ export const STATUS_OPTIONS = { * const formatLabel = _(FORMAT_OPTIONS[device.formatted]); * ``` */ -export const FORMAT_OPTIONS = { +const FORMAT_OPTIONS = { yes: N_("Yes"), no: N_("No"), }; @@ -155,27 +163,28 @@ const filterDevices = (devices: Device[], filters: DASDDevicesFilters): Device[] * Returns an array of action objects, each with a label and an `onClick` * handler. */ -const buildActions = ({ devices, updater, dispatcher }: DASDActionsProps) => { - const ids = devices.map((d) => d.channel); +const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsProps) => { return [ { title: _("Activate"), - onClick: () => updater({ action: "enable", devices: ids }), + onClick: () => + addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, status: "online" }))), }, { title: _("Deactivate"), - onClick: () => updater({ action: "disable", devices: ids }), + onClick: () => + addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, status: "offline" }))), }, { isSeparator: true, }, { title: _("Set DIAG on"), - onClick: () => updater({ action: "diagOn", devices: ids }), + onClick: () => addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, diag: true }))), }, { title: _("Set DIAG off"), - onClick: () => updater({ action: "diagOff", devices: ids }), + onClick: () => addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, diag: false }))), }, { isSeparator: true, @@ -312,7 +321,7 @@ const FiltersToolbar = ({ * When no devices are selected an instructional hint is shown instead. * Reuses `DASDActionsProps` since it needs the same dependencies as `buildActions`. */ -const BulkActionsToolbar = ({ devices, updater, dispatcher }: DASDActionsProps) => { +const BulkActionsToolbar = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsProps) => { const applyText = sprintf( n_( // TRANSLATORS: message shown in bulk action toolbar when just one device @@ -338,7 +347,7 @@ const BulkActionsToolbar = ({ devices, updater, dispatcher }: DASDActionsProps)
- {buildActions({ devices, updater, dispatcher }) + {buildActions({ devices, addOrUpdateDevices, dispatcher }) .filter((a) => !a.isSeparator) .map(({ onClick, title }, i) => ( @@ -514,13 +523,6 @@ const createColumns = () => [ }, ]; -/** - * Dispatches a DASD config requrest - * - * @fixme implement equivalent for API v2 - */ -const updater = (options) => console.log("FIXME: implement equivalente for new API", options); - /** * Displays a filterable, sortable, selectable table of DASD storage devices. * @@ -529,6 +531,7 @@ const updater = (options) => console.log("FIXME: implement equivalente for new A */ export default function DASDTable({ devices }) { const [state, dispatch] = useReducer(reducer, initialState); + const addOrUpdateDevices = useAddOrUpdateDevices(); const columns = createColumns(); @@ -584,7 +587,7 @@ export default function DASDTable({ devices }) { @@ -594,10 +597,14 @@ export default function DASDTable({ devices }) { {!isEmpty(state.devicesToFormat) && ( { - dispatch({ type: "CANCEL_FORMAT_REQUEST" }); + onFormat={() => { + addOrUpdateDevices( + state.devicesToFormat.map( + (d): ConfigDevice => ({ channel: d.channel, format: true }), + ), + ); }} - onCancel={() => dispatch({ type: "CANCEL_FORMAT_REQUEST" })} + onClose={() => dispatch({ type: "CANCEL_FORMAT_REQUEST" })} /> )} @@ -614,7 +621,7 @@ export default function DASDTable({ devices }) { itemActions={(d: Device) => buildActions({ devices: [d], - updater, // FIXME + addOrUpdateDevices, dispatcher: dispatch, }) } diff --git a/web/src/components/storage/dasd/FormatActionHandler.test.tsx b/web/src/components/storage/dasd/FormatActionHandler.test.tsx index cc7984898a..1696bc3bcc 100644 --- a/web/src/components/storage/dasd/FormatActionHandler.test.tsx +++ b/web/src/components/storage/dasd/FormatActionHandler.test.tsx @@ -28,8 +28,6 @@ import type { Device } from "~/model/system/dasd"; import FormatActionHandler from "./FormatActionHandler"; -const formatDASDMutationMock = jest.fn(); - const offlineDasdMock: Device = { channel: "0.0.0191", active: false, @@ -61,7 +59,7 @@ const anotherOnlineDasdMock: Device = { deviceName: "dasdk", formatted: true, diag: false, - status: "read_only", + status: "active", // former "read_only" type: "ECKD", accessType: "rw", partitionInfo: "", @@ -69,9 +67,7 @@ const anotherOnlineDasdMock: Device = { let consoleErrorSpy: jest.SpyInstance; -// FIXME: migrate to equivalent APIV2 -// Skipped during migration to v2 -describe.skip("DASD/FormatActionHandler", () => { +describe("DASD/FormatActionHandler", () => { beforeAll(() => { consoleErrorSpy = jest.spyOn(console, "error"); consoleErrorSpy.mockImplementation(); @@ -82,55 +78,74 @@ describe.skip("DASD/FormatActionHandler", () => { }); it("does nothing and logs an error when devices is empty", () => { - const { container } = plainRender(); + const { container } = plainRender( + , + ); expect(container).toBeEmptyDOMElement(); - expect(consoleErrorSpy).toHaveBeenCalledWith("FormatActionHnalder called without devices"); + expect(consoleErrorSpy).toHaveBeenCalledWith("FormatActionHandler called without devices"); }); - it("shows confirmation for a single online device", () => { - plainRender(); + it("renders confirmation for a single online device", () => { + plainRender( + , + ); screen.getByRole("heading", { name: "Format device 0.0.0160", level: 1 }); }); - it("shows offline warning for a single offline device", () => { - plainRender(); + it("renders offline warning for a single offline device", () => { + plainRender( + , + ); screen.getByRole("heading", { name: "Cannot format 0.0.0191", level: 1 }); screen.getByText(/It is offline/i); }); - it("shows offline list warning for multiple devices with one offline", () => { - plainRender(); + it("renders offline list warning for multiple devices with one offline", () => { + plainRender( + , + ); screen.getByRole("heading", { name: /Cannot format all the selected devices/, level: 1 }); screen.getByText(/devices are offline/i); screen.getByText(/191/i); }); - it("shows bulk confirmation for multiple online devices", () => { - plainRender(); + it("renders bulk confirmation for multiple online devices", () => { + plainRender( + , + ); screen.getByText("Format selected devices?"); screen.getByText(/destroy any data stored on the devices/); - screen.getByText(onlineDasdMock.channel); - screen.getByText(anotherOnlineDasdMock.channel); + screen.getByText(/0\.0\.0160/); + screen.getByText(/0\.0\.0592/); }); - it("calls formatDASD and onAccept on user confirmation", async () => { - const onAccept = jest.fn(); + it("calls onFormat and onClose on user confirmation", async () => { + const onFormat = jest.fn(); + const onClose = jest.fn(); const { user } = plainRender( - , + , ); - const confirmButton = screen.getByRole("button", { name: "Format now" }); - await user.click(confirmButton); - expect(formatDASDMutationMock).toHaveBeenCalledWith([onlineDasdMock.channel]); - expect(onAccept).toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Format now" })); + expect(onFormat).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); }); - it("calls onCancel if user cancels", async () => { - const onCancel = jest.fn(); + it("calls onClose when user cancels", async () => { + const onFormat = jest.fn(); + const onClose = jest.fn(); const { user } = plainRender( - , + , ); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelButton); - expect(onCancel).toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onFormat).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); }); }); diff --git a/web/src/components/storage/dasd/FormatActionHandler.tsx b/web/src/components/storage/dasd/FormatActionHandler.tsx index 0fceb4761d..e17422921b 100644 --- a/web/src/components/storage/dasd/FormatActionHandler.tsx +++ b/web/src/components/storage/dasd/FormatActionHandler.tsx @@ -39,10 +39,33 @@ type CommonFormatDASDProps = { device: Device; /** An array of DASD devices selected for formatting. */ devices: Device[]; - /** Callback triggered when the user confirms the format operation. */ - onCancel?: () => void; - /** Callback triggered when the user cancels the operation. */ + /** + * Callback triggered when the user confirms the format operation. + */ onAccept?: () => void; + /** + * Callback triggered when the dialog is dismissed, either after confirming + * or cancelling. Always called last, regardless of the outcome. + */ + onClose?: () => void; +}; + +/** + * Props for the {@link FormatActionHandler} controller component. + */ +type FormatActionHandlerProps = { + /** Devices selected for formatting. */ + devices: Device[]; + /** + * Callback triggered when the user confirms the format operation. + * Called before onClose. + */ + onFormat: () => void; + /** + * Callback triggered when the dialog is dismissed, either after confirming + * or cancelling. Always called last, regardless of the outcome. + */ + onClose: () => void; }; /** @@ -62,17 +85,14 @@ const DevicesList = ({ devices }: Pick) => ( * Renders a popup indicating a specific device is offline. * Used when attempting to format a single device that is disabled. */ -const DeviceOffline = ({ - device, - onCancel, -}: Pick) => { +const DeviceOffline = ({ device, onClose }: Pick) => { return ( {_("It is offline and must be activated before formatting it.")} - {_("Accept")} + {_("Accept")} ); @@ -84,8 +104,8 @@ const DeviceOffline = ({ */ const SomeDevicesOffline = ({ devices, - onCancel, -}: Pick) => { + onClose, +}: Pick) => { const offlineDevices = devices.filter((d) => d.status === "offline"); const totalOffline = offlineDevices.length; @@ -99,7 +119,7 @@ const SomeDevicesOffline = ({
- {_("Accept")} + {_("Accept")}
); @@ -111,8 +131,8 @@ const SomeDevicesOffline = ({ const DeviceFormatConfirmation = ({ device, onAccept, - onCancel, -}: Pick) => { + onClose, +}: Pick) => { return ( @@ -123,7 +143,7 @@ const DeviceFormatConfirmation = ({ {_("Format now")} - + ); @@ -135,8 +155,8 @@ const DeviceFormatConfirmation = ({ const MultipleDevicesFormatConfirmation = ({ devices, onAccept, - onCancel, -}: Pick) => { + onClose, +}: Pick) => { return ( @@ -150,7 +170,7 @@ const MultipleDevicesFormatConfirmation = ({ {_("Format now")} - + ); @@ -164,47 +184,45 @@ const MultipleDevicesFormatConfirmation = ({ * - Whether devices are online or offline. * - Whether a single or multiple devices are selected. * - * On user confirmation, the component triggers the `formatDASD` mutation with - * all selected device IDs, then calls the `onAccept` callback. + * On user confirmation, the component triggers the `onFormat` callback. * * @remarks * * This component assumes the format operation succeeds and calls `onAccept()` * immediately after triggering the mutation. It does not handle or display - * errors. + * errors nor any kind of progresses, responsability of other components in the + * UI. */ export default function FormatActionHandler({ devices, - onAccept, - onCancel, -}: Pick) { - // TODO: adapt former mutation to its API v2 counterpart - // const { mutate: formatDASD } = useFormatDASDMutation(); - const format = () => { - // formatDASD(devices.map((d) => d.id)); - onAccept(); - }; - + onFormat, + onClose, +}: FormatActionHandlerProps) { if (isEmpty(devices)) { - console.error("FormatActionHnalder called without devices"); + console.error("FormatActionHandler called without devices"); return; } + const format = () => { + onFormat(); + onClose(); + }; + if (devices.length === 1) { const device = devices[0]; if (device.status === "active") { - return ; + return ; } else { - return ; + return ; } } if (devices.some((d) => d.status === "offline")) { - return ; + return ; } else { return ( - + ); } } diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts index 86d4628dff..41627ea659 100644 --- a/web/src/hooks/model/config/dasd.test.ts +++ b/web/src/hooks/model/config/dasd.test.ts @@ -24,7 +24,12 @@ import { act, renderHook } from "@testing-library/react"; // NOTE: check notes about mockConfigQuery in its documentation import { clearMockedQueries, mockConfigQuery } from "~/test-utils/tanstack-query"; import { patchConfig } from "~/api"; -import { useConfig, useAddDevice, useRemoveDevice } from "~/hooks/model/config/dasd"; +import { + useConfig, + useAddDevice, + useAddOrUpdateDevices, + useRemoveDevice, +} from "~/hooks/model/config/dasd"; import type { Device } from "~/model/config/dasd"; const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; @@ -119,6 +124,96 @@ describe("hooks/model/storage/dasd", () => { }); }); + describe("useAddOrUpdateDevices", () => { + describe("when there is not a DASD config yet", () => { + it("calls API#patchConfig with a new config including the given devices", async () => { + mockConfigQuery(null); + + const { result } = renderHook(() => useAddOrUpdateDevices()); + + await act(async () => { + result.current([mockDeviceActive]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ devices: [mockDeviceActive] }), + }); + }); + }); + + describe("when there is an existing DASD config without devices", () => { + it("calls API#patchConfig with the given devices added", async () => { + mockConfigQuery({ dasd: {} }); + + const { result } = renderHook(() => useAddOrUpdateDevices()); + + await act(async () => { + result.current([mockDeviceActive]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ devices: [mockDeviceActive] }), + }); + }); + }); + + describe("when there is an existing DASD config with devices", () => { + it("adds new devices to existing ones", async () => { + mockConfigQuery({ dasd: { devices: [mockDeviceOffline] } }); + + const { result } = renderHook(() => useAddOrUpdateDevices()); + + await act(async () => { + result.current([mockDeviceActive]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ + devices: [mockDeviceOffline, mockDeviceActive], + }), + }); + }); + + it("updates an existing device when channel matches", async () => { + mockConfigQuery({ + dasd: { devices: [mockDeviceOffline, mockDeviceActive] }, + }); + + const updatedDevice: Device = { channel: mockDeviceActive.channel, state: "offline" }; + const { result } = renderHook(() => useAddOrUpdateDevices()); + + await act(async () => { + result.current([updatedDevice]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ + devices: [mockDeviceOffline, updatedDevice], + }), + }); + }); + + it("handles a mix of new and updated devices", async () => { + mockConfigQuery({ + dasd: { devices: [mockDeviceOffline, mockDeviceActive] }, + }); + + const updatedDevice: Device = { channel: mockDeviceActive.channel, state: "offline" }; + const { result } = renderHook(() => useAddOrUpdateDevices()); + + await act(async () => { + result.current([updatedDevice, mockDeviceToBeFormmated]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + dasd: expect.objectContaining({ + devices: [mockDeviceOffline, updatedDevice, mockDeviceToBeFormmated], + }), + }); + }); + }); + }); + describe("useRemoveDevice", () => { describe("when there is not a DASD config yet", () => { it("calls API#patchConfig with an empty config for DASD", async () => { diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts index c306bebcb7..df00b03bd6 100644 --- a/web/src/hooks/model/config/dasd.ts +++ b/web/src/hooks/model/config/dasd.ts @@ -26,8 +26,10 @@ import { patchConfig, Response } from "~/api"; import dasdConfig from "~/model/config/dasd"; import type { Config, DASD } from "~/model/config"; +import { extendCollection } from "~/utils"; type addDeviceFn = (device: DASD.Device) => Response; +type addOrUpdateDevicesFn = (devices: DASD.Device[]) => Response; type removeDeviceFn = (name: DASD.Device["channel"]) => Response; /** @@ -65,6 +67,26 @@ function useConfig(): DASD.Config | undefined { return data; } +function useAddOrUpdateDevices(): addOrUpdateDevicesFn { + // FIXME: useConfig should return an empty object instead of falling back + // to an empty object all the time + const config = useConfig() || {}; + + return (devices: DASD.Device[]) => { + const { all: newDevicesConfig } = extendCollection(config.devices, { + with: devices, + matching: "channel", + precedence: "extensionWins", + }); + + console.log("newDevicesConfig", newDevicesConfig); + + return patchConfig({ + dasd: { ...(config || {}), devices: newDevicesConfig }, + }); + }; +} + /** * Add a device to DASD configuration. * @@ -104,5 +126,5 @@ function useRemoveDevice(): removeDeviceFn { }); } -export { useConfig, useAddDevice, useRemoveDevice }; -export type { addDeviceFn, removeDeviceFn }; +export { useConfig, useAddDevice, useAddOrUpdateDevices, useRemoveDevice }; +export type { addDeviceFn, addOrUpdateDevicesFn, removeDeviceFn }; diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 3b3e4306da..0500285bd9 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -651,12 +651,12 @@ describe("extendCollection", () => { }, ]; - const { extended, unmatched } = extendCollection(configDevices, { + const { extended, unmatched, all } = extendCollection(configDevices, { with: systemDevices, matching: "channel", }); - expect(extended).toEqual([ + const expectedExtended = [ { channel: "0.0.0160", diag: false, // from config (baseWins / default precedence) @@ -670,8 +670,11 @@ describe("extendCollection", () => { accessType: "rw", partitionInfo: "1", }, - ]); + ]; + + expect(extended).toEqual(expectedExtended); expect(unmatched).toEqual([]); + expect(all).toEqual(expectedExtended); }); it("respects 'extensionWins' precedence", () => { @@ -691,13 +694,13 @@ describe("extendCollection", () => { }, ]; - const { extended, unmatched } = extendCollection(configDevices, { + const { extended, unmatched, all } = extendCollection(configDevices, { with: systemDevices, matching: "channel", precedence: "extensionWins", }); - expect(extended).toEqual([ + const expectedExtended = [ { channel: "0.0.0160", diag: true, // from system (extensionWins precedence) @@ -710,8 +713,11 @@ describe("extendCollection", () => { accessType: "rw", partitionInfo: "1", }, - ]); + ]; + + expect(extended).toEqual(expectedExtended); expect(unmatched).toEqual([]); + expect(all).toEqual(expectedExtended); }); it("keeps items without matches unchanged", () => { @@ -734,7 +740,7 @@ describe("extendCollection", () => { }, ]; - const { extended, unmatched } = extendCollection(configDevices, { + const { extended, unmatched, all } = extendCollection(configDevices, { with: systemDevices, matching: "channel", }); @@ -744,11 +750,24 @@ describe("extendCollection", () => { expect(extended[0].deviceName).toBe("dasda"); expect(extended[1]).toEqual({ channel: "0.0.0200", format: true }); // unchanged expect(unmatched).toEqual([]); + expect(all).toEqual(extended); }); it("returns unmatched items from extension collection", () => { const configDevices: ConfigDevice[] = [{ channel: "0.0.0160", diag: false }]; + const unmatchedDevice: SystemDevice = { + channel: "0.0.0200", + active: true, + deviceName: "dasdb", + type: "fba", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + }; + const systemDevices: SystemDevice[] = [ { channel: "0.0.0160", @@ -761,20 +780,10 @@ describe("extendCollection", () => { accessType: "rw", partitionInfo: "1", }, - { - channel: "0.0.0200", - active: true, - deviceName: "dasdb", - type: "fba", - formatted: false, - diag: false, - status: "active", - accessType: "rw", - partitionInfo: "1", - }, + unmatchedDevice, ]; - const { extended, unmatched } = extendCollection(configDevices, { + const { extended, unmatched, all } = extendCollection(configDevices, { with: systemDevices, matching: "channel", }); @@ -782,17 +791,11 @@ describe("extendCollection", () => { expect(extended).toHaveLength(1); expect(extended[0].channel).toBe("0.0.0160"); expect(unmatched).toHaveLength(1); - expect(unmatched[0]).toEqual({ - channel: "0.0.0200", - active: true, - deviceName: "dasdb", - type: "fba", - formatted: false, - diag: false, - status: "active", - accessType: "rw", - partitionInfo: "1", - }); + expect(unmatched[0]).toEqual(unmatchedDevice); + // all: extended items first (base order), unmatched appended at the end + expect(all).toHaveLength(2); + expect(all[0].channel).toBe("0.0.0160"); + expect(all[1]).toEqual(unmatchedDevice); }); }); @@ -803,18 +806,20 @@ describe("extendCollection", () => { { channel: "0.0.0160", state: "active", diag: false }, ]; + const unmatchedDevice: SystemDevice = { + channel: "0.0.0150", + status: "offline", + active: false, + deviceName: "dasdc", + type: "eckd", + formatted: false, + diag: true, + accessType: "rw", + partitionInfo: "1", + }; + const systemDevices: SystemDevice[] = [ - { - channel: "0.0.0150", - status: "offline", - active: false, - deviceName: "dasdc", - type: "eckd", - formatted: false, - diag: true, - accessType: "rw", - partitionInfo: "1", - }, + unmatchedDevice, { channel: "0.0.0160", status: "offline", @@ -828,12 +833,12 @@ describe("extendCollection", () => { }, ]; - const { extended, unmatched } = extendCollection(configDevices, { + const { extended, unmatched, all } = extendCollection(configDevices, { with: systemDevices, matching: ["channel", "diag"], }); - expect(extended).toEqual([ + const expectedExtended = [ { channel: "0.0.0150", state: "offline", diag: false }, { channel: "0.0.0160", @@ -847,43 +852,56 @@ describe("extendCollection", () => { accessType: "rw", partitionInfo: "1", }, - ]); + ]; + + expect(extended).toEqual(expectedExtended); expect(unmatched).toHaveLength(1); expect(unmatched[0].channel).toBe("0.0.0150"); + // all: base order preserved, unmatched appended at the end + expect(all).toHaveLength(3); + expect(all[0]).toEqual(expectedExtended[0]); + expect(all[1]).toEqual(expectedExtended[1]); + expect(all[2]).toEqual(unmatchedDevice); }); }); describe("edge cases", () => { it("handles empty base collection", () => { - const { extended, unmatched } = extendCollection([], { - with: [{ id: 1, name: "test" }], + const unmatchedDevice = { id: 1, name: "test" }; + + const { extended, unmatched, all } = extendCollection([], { + with: [unmatchedDevice], matching: "id", }); expect(extended).toEqual([]); - expect(unmatched).toEqual([{ id: 1, name: "test" }]); + expect(unmatched).toEqual([unmatchedDevice]); + // all contains unmatched items even when base collection is empty + expect(all).toEqual([unmatchedDevice]); }); it("handles empty extension collection", () => { const items = [{ channel: "0.0.0160", diag: false }]; - const { extended, unmatched } = extendCollection(items, { + const { extended, unmatched, all } = extendCollection(items, { with: [], matching: "channel", }); expect(extended).toEqual(items); expect(unmatched).toEqual([]); + expect(all).toEqual(items); }); it("handles both collections empty", () => { - const { extended, unmatched } = extendCollection([], { + const { extended, unmatched, all } = extendCollection([], { with: [], matching: "id", }); expect(extended).toEqual([]); expect(unmatched).toEqual([]); + expect(all).toEqual([]); }); it("does not mutate original collections", () => { @@ -915,43 +933,81 @@ describe("extendCollection", () => { const items = [{ id: 1, value: "a" }]; const extension = [{ id: 1, extra: "b" }]; - const { extended } = extendCollection(items, { + const { extended, all } = extendCollection(items, { with: extension, matching: "id", }); expect(extended[0]).toEqual({ id: 1, value: "a", extra: "b" }); + expect(all).toEqual(extended); }); }); - describe("translateEntries", () => { - const OPTIONS = { - active: N_("Active"), - offline: N_("Offline"), - unknown: N_("Unknown"), - }; + it("handles undefined base collection", () => { + const extension = [{ id: 1, extra: "b" }]; - it("returns all entries translated when no filter is provided", () => { - expect(translateEntries(OPTIONS)).toEqual({ - active: "translated(Active)", - offline: "translated(Offline)", - unknown: "translated(Unknown)", - }); + const { extended, unmatched, all } = extendCollection(undefined, { + with: extension, + matching: "id", }); - it("returns only translated entries matching the filter", () => { - expect(translateEntries(OPTIONS, { filter: (key) => key !== "unknown" })).toEqual({ - active: "translated(Active)", - offline: "translated(Offline)", - }); + expect(extended).toEqual([]); + expect(unmatched).toEqual(extension); + expect(all).toEqual(extension); + }); + + it("handles undefined extension collection", () => { + const items = [{ channel: "0.0.0160", diag: false }]; + + const { extended, unmatched, all } = extendCollection(items, { + matching: "channel", }); - it("returns an empty object when filter excludes all entries", () => { - expect(translateEntries(OPTIONS, { filter: () => false })).toEqual({}); + expect(extended).toEqual(items); + expect(unmatched).toEqual([]); + expect(all).toEqual(items); + }); + + it("handles missing matching field, defaulting to 'id'", () => { + const items = [{ id: 1, value: "a" }]; + const extension = [{ id: 1, extra: "b" }]; + + const { extended, all } = extendCollection(items, { + with: extension, }); - it("returns an empty object when given an empty record", () => { - expect(translateEntries({})).toEqual({}); + expect(extended[0]).toEqual({ id: 1, value: "a", extra: "b" }); + expect(all).toEqual(extended); + }); +}); + +describe("translateEntries", () => { + const OPTIONS = { + active: N_("Active"), + offline: N_("Offline"), + unknown: N_("Unknown"), + }; + + it("returns all entries translated when no filter is provided", () => { + expect(translateEntries(OPTIONS)).toEqual({ + active: "translated(Active)", + offline: "translated(Offline)", + unknown: "translated(Unknown)", + }); + }); + + it("returns only translated entries matching the filter", () => { + expect(translateEntries(OPTIONS, { filter: (key) => key !== "unknown" })).toEqual({ + active: "translated(Active)", + offline: "translated(Offline)", }); }); + + it("returns an empty object when filter excludes all entries", () => { + expect(translateEntries(OPTIONS, { filter: () => false })).toEqual({}); + }); + + it("returns an empty object when given an empty record", () => { + expect(translateEntries({})).toEqual({}); + }); }); diff --git a/web/src/utils.ts b/web/src/utils.ts index f06817d4e7..53297d44e5 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -483,7 +483,7 @@ interface ExtendCollectionOptions { * Items in this collection will be matched against the base collection * according to the `matching` fields. */ - with: U[]; + with?: U[]; /** * Field name or array of field names used to match items between the base @@ -491,7 +491,7 @@ interface ExtendCollectionOptions { * - Single field: matches on that field. * - Multiple fields: all fields are combined to determine a match. */ - matching: keyof T | (keyof T)[]; + matching?: keyof T | (keyof T)[]; /** * Determines which collection's properties take priority when merging: @@ -516,6 +516,11 @@ interface ExtendCollectionResult { * Items from the extension collection that did not match any base item. */ unmatched: U[]; + /** + * All items: base collection (extended where matched) + unmatched extension + * items appended at the end, preserving base collection order. + */ + all: (T & U)[]; } /** @@ -528,7 +533,11 @@ interface ExtendCollectionResult { * 2. Merge the matched item's properties into the base item according to * `precedence`. * - * @returns Object containing `extended` items and `unmatched` extension items. + * @returns Object containing: + * - `extended`: base collection items, merged where a match was found. + * - `unmatched`: extension items that had no matching base item. + * - `all`: `extended` items followed by `unmatched` items, preserving base + * collection order with unmatched extension items appended at the end. * * @example * // Single field matching with default precedence (base wins) @@ -536,16 +545,23 @@ interface ExtendCollectionResult { * { channel: "0.0.0160", diag: false, format: true } * ]; * const systemDevices = [ - * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: true } + * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: true }, + * { channel: "0.0.0999", deviceName: "extra", type: "fba" } * ]; - * const { extended, unmatched } = extendCollection(configDevices, { + * const { extended, unmatched, all } = extendCollection(configDevices, { * with: systemDevices, * matching: 'channel' * }); * // extended: [ * // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true } * // ] - * // unmatched: [] + * // unmatched: [ + * // { channel: "0.0.0999", deviceName: "extra", type: "fba" } + * // ] + * // all: [ + * // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true }, + * // { channel: "0.0.0999", deviceName: "extra", type: "fba" } + * // ] * * @example * // Multiple field matching with extensionWins precedence @@ -556,7 +572,7 @@ interface ExtendCollectionResult { * { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }, * { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } * ]; - * const { extended, unmatched } = extendCollection(configDevices, { + * const { extended, unmatched, all } = extendCollection(configDevices, { * with: systemDevices, * matching: ['channel', 'state'], * precedence: "extensionWins" @@ -567,16 +583,19 @@ interface ExtendCollectionResult { * // unmatched: [ * // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } * // ] + * // all: [ + * // { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }, + * // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } + * // ] */ function extendCollection( - collection: T[], + collection: T[] = [], options: ExtendCollectionOptions, ): ExtendCollectionResult { - const { with: extension, matching, precedence = "baseWins" } = options; + const { with: extension = [], matching = "id", precedence = "baseWins" } = options; // Normalize matching field(s) to array const keys = Array.isArray(matching) ? matching : [matching]; - // Create a composite key from item values // For single field: returns the value directly (e.g., "0.0.0160") // For multiple fields: joins values with pipe separator (e.g., "0.0.0160|offline") @@ -586,40 +605,34 @@ function extendCollection( // Build a lookup map from extension items keyed by their matching fields const extensionLookup = new Map(extension.map((item) => [getKey(item), item])); - // Prepare result arrays const extended: (T & U)[] = []; const unmatched: U[] = []; + const all: (T & U)[] = []; // Extend each item in the base collection for (const item of collection) { const key = getKey(item); const match = extensionLookup.get(key); - if (!match) { - // No match found - return item unchanged - extended.push(item as T & U); - } else { - // Remove from lookup for easing tracking of unmatched later - extensionLookup.delete(key); - - // Merge matched items based on precedence - const merged = ( - precedence === "baseWins" - ? { ...match, ...item } // Base item properties take priority - : { ...item, ...match } - ) as T & U; // Extension item properties take priority - - extended.push(merged); - } + const resolved = ( + !match + ? item + : (extensionLookup.delete(key), + precedence === "baseWins" ? { ...match, ...item } : { ...item, ...match }) + ) as T & U; + + extended.push(resolved); + all.push(resolved); } - // Remaining items in lookup are unmatched - unmatched.push(...extensionLookup.values()); + // Remaining items in lookup are unmatched — append to all to preserve + // base collection order with unmatched extension items at the end + for (const item of extensionLookup.values()) { + unmatched.push(item); + all.push(item as unknown as T & U); + } - return { - extended, - unmatched, - }; + return { all, extended, unmatched }; } /** Options for translateEntries utility */ From 7910c55c1e020abf6fb9cb1f73f1bd9762f6da2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 24 Feb 2026 18:22:11 +0000 Subject: [PATCH 19/46] fix(web): drop mokcing data --- web/src/components/storage/dasd/DASDPage.tsx | 27 +------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index 2d8c791fb2..eeef5bbf13 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -47,32 +47,7 @@ const NoDevicesAvailable = () => { * Reads the device list and renders the content based on it. */ const DASDPageContent = () => { - // const { devices = [] } = useSystem() || {}; - console.log(useSystem); - const devices = [ - { - channel: "0.0.0160", - active: false, - deviceName: "", - type: "", - formatted: false, - diag: false, - status: "offline", - accessType: "", - partitionInfo: "", - }, - { - channel: "0.0.0200", - active: true, - deviceName: "dasda", - type: "eckd", - formatted: false, - diag: false, - status: "active", - accessType: "rw", - partitionInfo: "1", - }, - ]; + const { devices = [] } = useSystem() || {}; if (isEmpty(devices)) { return ; From 68e3cdff20cd7fbf2859b79384e8a0c657683902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 24 Feb 2026 18:22:33 +0000 Subject: [PATCH 20/46] feat(web): add missing files --- .../components/core/SimpleSelector.test.tsx | 125 ++++++++++++++++++ web/src/components/core/SimpleSelector.tsx | 95 +++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 web/src/components/core/SimpleSelector.test.tsx create mode 100644 web/src/components/core/SimpleSelector.tsx 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/core/SimpleSelector.tsx b/web/src/components/core/SimpleSelector.tsx new file mode 100644 index 0000000000..b39a7bc6d2 --- /dev/null +++ b/web/src/components/core/SimpleSelector.tsx @@ -0,0 +1,95 @@ +/* + * 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, { useId, useState } from "react"; +import { + Flex, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + SelectProps, +} from "@patternfly/react-core"; + +import Text from "~/components/core/Text"; + +import type { TranslatedString } from "~/i18n"; + +type SimpleSelectorProps = { + label: TranslatedString; + value: string; + options: Record; + onChange: SelectProps["onSelect"]; +}; + +/** + * Wrapper component for simplifying PF/Select usage for simple dropdowns. + * + * 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 SimpleSelector({ label, value, options, onChange }: SimpleSelectorProps) { + const id = useId(); + const [isOpen, setIsOpen] = useState(false); + const onToggle = () => setIsOpen(!isOpen); + + const toggle = (toggleRef: React.Ref) => ( + + {options[value]} + + ); + + return ( + + + + + + ); +} From b8a72f01bd10a6da9313289106f8900dd32c1d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:13:09 +0000 Subject: [PATCH 21/46] fix(web): drop finally not used code To avoid leaving dead code just because it was initially added. --- web/src/hooks/model/config/dasd.test.ts | 117 +----------------------- web/src/hooks/model/config/dasd.ts | 56 ++---------- web/src/model/config/dasd.test.ts | 106 --------------------- web/src/model/config/dasd.ts | 20 ---- 4 files changed, 11 insertions(+), 288 deletions(-) delete mode 100644 web/src/model/config/dasd.test.ts diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts index 41627ea659..f1a7ed6f5e 100644 --- a/web/src/hooks/model/config/dasd.test.ts +++ b/web/src/hooks/model/config/dasd.test.ts @@ -24,12 +24,7 @@ import { act, renderHook } from "@testing-library/react"; // NOTE: check notes about mockConfigQuery in its documentation import { clearMockedQueries, mockConfigQuery } from "~/test-utils/tanstack-query"; import { patchConfig } from "~/api"; -import { - useConfig, - useAddDevice, - useAddOrUpdateDevices, - useRemoveDevice, -} from "~/hooks/model/config/dasd"; +import { useConfig, useAddOrUpdateDevices } from "~/hooks/model/config/dasd"; import type { Device } from "~/model/config/dasd"; const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; @@ -86,44 +81,6 @@ describe("hooks/model/storage/dasd", () => { }); }); - describe("useAddDevice", () => { - describe("when there is not a DASD config yet", () => { - it("calls API#patchConfig with a new config for DASD including added device", async () => { - mockConfigQuery(null); - - const { result } = renderHook(() => useAddDevice()); - - await act(async () => { - result.current(mockDeviceActive); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith({ - dasd: expect.objectContaining({ devices: [mockDeviceActive] }), - }); - }); - }); - - describe("when there is an existing DASD config", () => { - it("calls API#patchConfig with updated config including added device", async () => { - mockConfigQuery({ - dasd: { devices: [mockDeviceOffline] }, - }); - - const { result } = renderHook(() => useAddDevice()); - - await act(async () => { - result.current(mockDeviceActive); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith({ - dasd: expect.objectContaining({ - devices: [mockDeviceOffline, mockDeviceActive], - }), - }); - }); - }); - }); - describe("useAddOrUpdateDevices", () => { describe("when there is not a DASD config yet", () => { it("calls API#patchConfig with a new config including the given devices", async () => { @@ -213,76 +170,4 @@ describe("hooks/model/storage/dasd", () => { }); }); }); - - describe("useRemoveDevice", () => { - describe("when there is not a DASD config yet", () => { - it("calls API#patchConfig with an empty config for DASD", async () => { - mockConfigQuery(null); - - const channelToRemove = "0.0.0190"; - const { result } = renderHook(() => useRemoveDevice()); - - await act(async () => { - result.current(channelToRemove); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith({ - dasd: {}, - }); - }); - }); - - describe("when there is an existing DASD config without devices", () => { - it("calls API#patchConfig with the same config", async () => { - const initialConfig = { dasd: {} }; - mockConfigQuery(initialConfig); - - const { result } = renderHook(() => useRemoveDevice()); - - await act(async () => { - result.current(mockDeviceActive.channel); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith(initialConfig); - }); - }); - - describe("when there is an existing DASD config with devices", () => { - it("calls API#patchConfig with device removed from config", async () => { - mockConfigQuery({ - dasd: { devices: [mockDeviceOffline, mockDeviceActive, mockDeviceToBeFormmated] }, - }); - - const { result } = renderHook(() => useRemoveDevice()); - - await act(async () => { - result.current(mockDeviceActive.channel); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith({ - dasd: expect.objectContaining({ - devices: [mockDeviceOffline, mockDeviceToBeFormmated], - }), - }); - }); - - it("calls API#patchConfig with unchanged config when removing non-existent device", async () => { - const initialConfig = { - dasd: { - devices: [mockDeviceOffline, mockDeviceActive, mockDeviceToBeFormmated], - }, - }; - mockConfigQuery(initialConfig); - - const nonExistentChannel = "0.0.9999"; - const { result } = renderHook(() => useRemoveDevice()); - - await act(async () => { - result.current(nonExistentChannel); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith(initialConfig); - }); - }); - }); }); diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts index df00b03bd6..e34f43a6ea 100644 --- a/web/src/hooks/model/config/dasd.ts +++ b/web/src/hooks/model/config/dasd.ts @@ -23,14 +23,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { configQuery } from "~/hooks/model/config"; import { patchConfig, Response } from "~/api"; -import dasdConfig from "~/model/config/dasd"; import type { Config, DASD } from "~/model/config"; import { extendCollection } from "~/utils"; -type addDeviceFn = (device: DASD.Device) => Response; type addOrUpdateDevicesFn = (devices: DASD.Device[]) => Response; -type removeDeviceFn = (name: DASD.Device["channel"]) => Response; /** * Extract DASD config from a config object. @@ -67,6 +64,14 @@ function useConfig(): DASD.Config | undefined { return data; } +/** + * Update or add if does not exist yet given devices to DASD configuration. + * + * @remarks + * Falls back to empty config when useConfig returns undefined. + * + * @todo Remove fallback once useConfig returns empty object by default + */ function useAddOrUpdateDevices(): addOrUpdateDevicesFn { // FIXME: useConfig should return an empty object instead of falling back // to an empty object all the time @@ -79,52 +84,11 @@ function useAddOrUpdateDevices(): addOrUpdateDevicesFn { precedence: "extensionWins", }); - console.log("newDevicesConfig", newDevicesConfig); - return patchConfig({ dasd: { ...(config || {}), devices: newDevicesConfig }, }); }; } -/** - * Add a device to DASD configuration. - * - * @remarks - * Falls back to empty config when useConfig returns undefined. - * - * @todo Remove fallback once useConfig returns empty object by default - */ -function useAddDevice(): addDeviceFn { - const config = useConfig(); - - return (device: DASD.Device) => { - return patchConfig({ - // FIXME: useConfig should return an empty object instead of falling back - // to an empty object all the time - dasd: dasdConfig.addDevice(config || {}, device), - }); - }; -} - -/** - * Remove a device from DASD configuration by channel. - * - * @remarks - * Falls back to empty config when useConfig returns undefined. - * - * @todo Remove fallback once useConfig returns empty object by default - */ -function useRemoveDevice(): removeDeviceFn { - const config = useConfig(); - - return (channel: string) => - patchConfig({ - // FIXME: useConfig should return an empty object instead of falling back - // to an empty object all the time - dasd: dasdConfig.removeDevice(config || {}, channel), - }); -} - -export { useConfig, useAddDevice, useAddOrUpdateDevices, useRemoveDevice }; -export type { addDeviceFn, addOrUpdateDevicesFn, removeDeviceFn }; +export type { addOrUpdateDevicesFn }; +export { useConfig, useAddOrUpdateDevices }; diff --git a/web/src/model/config/dasd.test.ts b/web/src/model/config/dasd.test.ts deleted file mode 100644 index 97be41c9dc..0000000000 --- a/web/src/model/config/dasd.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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 dasdModel from "~/model/config/dasd"; -import type { Config, Device } from "~/model/config/dasd"; - -type ConfigStructurePreservationTest = Config & { - futureProperty: "must_be_preserved"; -}; - -const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; -const mockDeviceActive: Device = { channel: "0.0.0160", state: "active" as const }; -const mockInitialConfig: Config = { devices: [mockDeviceActive] }; - -describe("model/config/dasd", () => { - describe("#addDevice", () => { - it("preserves existing config properties while adding the device", () => { - const deviceToAdd = { channel: "0.0.0150", state: "active" as const }; - - const initialConfig = { - ...mockInitialConfig, - futureProperty: "must_be_preserved", - } as ConfigStructurePreservationTest; - - const newConfig = dasdModel.addDevice( - initialConfig, - deviceToAdd, - ) as ConfigStructurePreservationTest; - - expect(newConfig).not.toBe(initialConfig); - expect(newConfig.futureProperty).toBe("must_be_preserved"); - }); - - describe("when config already has devices", () => { - it("appends the new device to the existing list", () => { - const newConfig = dasdModel.addDevice(mockInitialConfig, mockDeviceOffline); - expect(newConfig).not.toBe(mockInitialConfig); - expect(newConfig.devices).toContain(mockDeviceActive); - expect(newConfig.devices).toContain(mockDeviceOffline); - }); - }); - - describe("when config devices are empty or undefined", () => { - it("returns a config containing only the new device", () => { - expect(dasdModel.addDevice({}, mockDeviceOffline)).toEqual({ - devices: [mockDeviceOffline], - }); - - expect(dasdModel.addDevice({ devices: [] }, mockDeviceOffline)).toEqual({ - devices: [mockDeviceOffline], - }); - - expect(dasdModel.addDevice({ devices: undefined }, mockDeviceOffline)).toEqual({ - devices: [mockDeviceOffline], - }); - - expect(dasdModel.addDevice({ devices: null }, mockDeviceOffline)).toEqual({ - devices: [mockDeviceOffline], - }); - }); - }); - }); - - describe("#removeDevice", () => { - it("preserves existing config properties while removing the device", () => { - const initialConfig = { - ...mockInitialConfig, - futureProperty: "must_be_preserved", - } as ConfigStructurePreservationTest; - - const newConfig = dasdModel.removeDevice( - initialConfig, - mockDeviceActive.channel, - ) as ConfigStructurePreservationTest; - - expect(newConfig).not.toBe(initialConfig); - expect(newConfig.futureProperty).toBe("must_be_preserved"); - }); - - it("returns a new config with the specified device removed", () => { - const toRemove = mockInitialConfig.devices[0]; - const newConfig = dasdModel.removeDevice(mockInitialConfig, toRemove.channel); - expect(newConfig).not.toBe(mockInitialConfig); - expect(newConfig.devices).not.toContain(toRemove); - }); - }); -}); diff --git a/web/src/model/config/dasd.ts b/web/src/model/config/dasd.ts index f5f45f966d..9f4d791d8d 100644 --- a/web/src/model/config/dasd.ts +++ b/web/src/model/config/dasd.ts @@ -20,24 +20,4 @@ * find current contact information at www.suse.com. */ -import { concat, isEmpty } from "radashi"; -import { Config, Device } from "~/openapi/config/dasd"; - -function addDevice(config: Config, device: Device): Config { - return { - ...config, - devices: concat(config.devices, device), - }; -} - -function removeDevice(config: Config, channel: string): Config { - return isEmpty(config.devices) - ? { ...config } - : { - ...config, - devices: config.devices.filter((d) => d.channel !== channel), - }; -} - -export default { addDevice, removeDevice }; export type * from "~/openapi/config/dasd"; From ef92cd843a67c52bdef0715dacff64079eeb4424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:15:29 +0000 Subject: [PATCH 22/46] fix(web): drop dead code related to former DASD jobs --- web/src/types/job.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 web/src/types/job.ts diff --git a/web/src/types/job.ts b/web/src/types/job.ts deleted file mode 100644 index b9ee08c6ce..0000000000 --- a/web/src/types/job.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of 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. - */ - -type Job = { - id: string; - running: boolean; - exitCode: number; -}; - -export type { Job }; From a8f3155d21df33dfe38c406dd06fc06848da0283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:19:42 +0000 Subject: [PATCH 23/46] Fix CI schema validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: José Iván López González --- rust/agama-lib/share/README.md | 1 + rust/agama-lib/share/package.json | 2 +- rust/share/system.dasd.schema.json | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) 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/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/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", From b20e400d5270cb05bf1b250fcb0c8453ee024f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:23:43 +0000 Subject: [PATCH 24/46] feat(web): subscribe DASDPage to its progress --- web/src/components/storage/dasd/DASDPage.tsx | 5 ++++- web/src/model/status.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index eeef5bbf13..b3a19aa9e2 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -65,7 +65,10 @@ const DASDPageContent = () => { */ export default function DASDPage() { return ( - + diff --git a/web/src/model/status.ts b/web/src/model/status.ts index 12880e8dd0..a8f0b86014 100644 --- a/web/src/model/status.ts +++ b/web/src/model/status.ts @@ -42,9 +42,10 @@ type Scope = | "l10n" | "product" | "software" - | "storage" | "network" + | "storage" | "iscsi" + | "dasd" | "users"; type Progress = { index: number; From 9e2297d8ec5d737386e677dff39e7021e64778a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:28:04 +0000 Subject: [PATCH 25/46] fix(web): drop more Jobs dead code Related to commit ef92cd843a67c52bdef0715dacff64079eeb4424 --- web/src/api.ts | 7 ------- 1 file changed, 7 deletions(-) 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 }; From 2b3452037e82256fe63e89f810d5b78bedf3f830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:29:30 +0000 Subject: [PATCH 26/46] Fix dasd schema Related to a8f3155d21df33dfe38c406dd06fc06848da0283 --- rust/agama-lib/share/dasd.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/share/dasd.schema.json b/rust/agama-lib/share/dasd.schema.json index 37b3b6ac3e..91e81a4439 100644 --- a/rust/agama-lib/share/dasd.schema.json +++ b/rust/agama-lib/share/dasd.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/iscsi.schema.json", + "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/dasd.schema.json", "title": "Config", "description": "DASD config.", "type": "object", From ce81e488f38352aee6ace99f0e288d15dff1d7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 09:47:34 +0000 Subject: [PATCH 27/46] Fix dasd schema --- rust/agama-lib/share/dasd.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/share/dasd.schema.json b/rust/agama-lib/share/dasd.schema.json index 91e81a4439..8b026b9a6c 100644 --- a/rust/agama-lib/share/dasd.schema.json +++ b/rust/agama-lib/share/dasd.schema.json @@ -19,7 +19,7 @@ "required": ["channel"], "properties": { "channel": { - "decription": "DASD device channel.", + "description": "DASD device channel.", "type": "string" }, "state": { From 8ca7ec93e19fec130bbbb2709665be6658431d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 13:36:47 +0000 Subject: [PATCH 28/46] Do not report partition info of disconnected DASD - The partition info of disconnected DASD could contain unmeaningful content. --- service/lib/agama/dbus/storage/dasd.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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? From 1f28a50d44a9f776b07eaff616799db4d825f45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 13:38:38 +0000 Subject: [PATCH 29/46] Allow activating DASD after failing diag activation --- service/lib/agama/storage/dasd/enable_operation.rb | 3 +++ 1 file changed, 3 insertions(+) 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: From a75a7e0cde06252029b40a4a1a382725e3df47eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 13:39:20 +0000 Subject: [PATCH 30/46] Enable button to configure DASD --- web/src/components/storage/ConnectedDevicesMenu.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/ConnectedDevicesMenu.tsx b/web/src/components/storage/ConnectedDevicesMenu.tsx index 6324c133bd..a7862c0b74 100644 --- a/web/src/components/storage/ConnectedDevicesMenu.tsx +++ b/web/src/components/storage/ConnectedDevicesMenu.tsx @@ -26,12 +26,13 @@ import { activateStorageAction } from "~/api"; import { STORAGE } from "~/routes/paths"; import Icon from "~/components/layout/Icon"; import MenuButton from "~/components/core/MenuButton"; +import { useSystem as useDASDSystem } from "~/hooks/model/system/dasd"; import { _ } from "~/i18n"; export default function ConnectedDevicesMenu() { const navigate = useNavigate(); const isZFCPSupported = false; - const isDASDSupported = false; + const dasdSystem = useDASDSystem(); return ( ), - isDASDSupported && ( + dasdSystem && ( navigate(STORAGE.dasd)} From 28fcd91a13f50e00a14bb8264eb4164ddfbb05b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 13:39:44 +0000 Subject: [PATCH 31/46] Add missing DASD status --- web/src/components/storage/dasd/DASDTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 8978198e5f..e321139284 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -109,6 +109,7 @@ type DASDActionsProps = { const STATUS_OPTIONS = { active: N_("Active"), offline: N_("Offline"), + read_only: N_("Read only"), }; /** @@ -477,7 +478,7 @@ const createColumns = () => [ { // TRANSLATORS: table header for a DASD devices table name: _("Status"), - value: (d: Device) => d.status, + value: (d: Device) => STATUS_OPTIONS[d.status], sortingKey: "status", }, { From bb53732c36f4a27ba9ddc20777ba40ae0c3d2351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 13:40:07 +0000 Subject: [PATCH 32/46] Fix DASD configuration --- web/src/components/storage/dasd/DASDTable.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index e321139284..e9a9a7f6d8 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -169,23 +169,37 @@ const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsPr { title: _("Activate"), onClick: () => - addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, status: "online" }))), + addOrUpdateDevices( + devices.map( + (d): ConfigDevice => ({ channel: d.channel, state: "active", diag: undefined }), + ), + ), }, { title: _("Deactivate"), onClick: () => - addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, status: "offline" }))), + addOrUpdateDevices( + devices.map( + (d): ConfigDevice => ({ channel: d.channel, state: "offline", diag: undefined }), + ), + ), }, { isSeparator: true, }, { title: _("Set DIAG on"), - onClick: () => addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, diag: true }))), + onClick: () => + addOrUpdateDevices( + devices.map((d): ConfigDevice => ({ channel: d.channel, state: "active", diag: true })), + ), }, { title: _("Set DIAG off"), - onClick: () => addOrUpdateDevices(devices.map((d) => ({ channel: d.channel, diag: false }))), + onClick: () => + addOrUpdateDevices( + devices.map((d): ConfigDevice => ({ channel: d.channel, state: "active", diag: false })), + ), }, { isSeparator: true, From b83d94b58c73a105a529dba4adecfa42f89fc8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 17:33:36 +0000 Subject: [PATCH 33/46] fix(web): enhance actions behavior By rendering contextual actions per device and hiding row actions during device selection. --- .../storage/dasd/DASDTable.test.tsx | 62 ++++++++++----- web/src/components/storage/dasd/DASDTable.tsx | 78 ++++++++++++++----- 2 files changed, 99 insertions(+), 41 deletions(-) diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index e5a43ed796..a31211fb2e 100644 --- a/web/src/components/storage/dasd/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -88,8 +88,8 @@ describe("DASDTable", () => { screen.getByText("0.0.0300"); // Status values are rendered - expect(screen.queryAllByText("offline").length).toBe(2); - screen.getByText("active"); + expect(screen.queryAllByText("Offline").length).toBe(2); + screen.getByText("Active"); // Device name is shown for the active device screen.getByText("dasda"); @@ -246,34 +246,54 @@ describe("DASDTable", () => { describe("actions", () => { it("calls addOrUpdateDevices with correct config on activate", async () => { const { user } = installerRender(); - await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); - await user.click(screen.getByRole("button", { name: "Activate" })); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + await user.click(screen.getByRole("menuitem", { name: "Activate" })); expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ - { channel: "0.0.0160", status: "online" }, + { channel: "0.0.0160", state: "active", diag: undefined }, ]); }); it("calls addOrUpdateDevices with correct config on deactivate", async () => { const { user } = installerRender(); - await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); - await user.click(screen.getByRole("button", { name: "Deactivate" })); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0200" })); + await user.click(screen.getByRole("menuitem", { name: "Deactivate" })); expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ - { channel: "0.0.0200", status: "offline" }, + { channel: "0.0.0200", state: "offline", diag: undefined }, ]); }); it("calls addOrUpdateDevices with correct config on DIAG on", async () => { const { user } = installerRender(); - await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); - await user.click(screen.getByRole("button", { name: "Set DIAG on" })); - expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([{ channel: "0.0.0160", diag: true }]); + // 0.0.0160 has diag: false so "Set DIAG on" is available + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + await user.click(screen.getByRole("menuitem", { name: "Set DIAG on" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", state: "active", diag: true }, + ]); }); it("calls addOrUpdateDevices with correct config on DIAG off", async () => { + mockDASDDevices[0] = { ...mockDASDDevices[0], diag: true }; + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0160" })); + await user.click(screen.getByRole("menuitem", { name: "Set DIAG off" })); + expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ + { channel: "0.0.0160", state: "active", diag: false }, + ]); + }); + + it("hides row actions when devices are selected", async () => { const { user } = installerRender(); await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); - await user.click(screen.getByRole("button", { name: "Set DIAG off" })); - expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([{ channel: "0.0.0160", diag: false }]); + expect(screen.queryByRole("button", { name: "Actions for 0.0.0160" })).toBeNull(); + }); + + it("filters irrelevant actions for a single device", async () => { + const { user } = installerRender(); + // 0.0.0200 is already active — Activate should not appear + await user.click(screen.getByRole("button", { name: "Actions for 0.0.0200" })); + expect(screen.queryByRole("menuitem", { name: "Activate" })).toBeNull(); + screen.getByRole("menuitem", { name: "Deactivate" }); }); }); @@ -284,8 +304,8 @@ describe("DASDTable", () => { await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); await user.click(screen.getByRole("button", { name: "Activate" })); expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ - { channel: "0.0.0160", status: "online" }, - { channel: "0.0.0200", status: "online" }, + { channel: "0.0.0160", state: "active", diag: undefined }, + { channel: "0.0.0200", state: "active", diag: undefined }, ]); }); @@ -295,8 +315,8 @@ describe("DASDTable", () => { await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); await user.click(screen.getByRole("button", { name: "Deactivate" })); expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ - { channel: "0.0.0160", status: "offline" }, - { channel: "0.0.0200", status: "offline" }, + { channel: "0.0.0160", state: "offline", diag: undefined }, + { channel: "0.0.0200", state: "offline", diag: undefined }, ]); }); @@ -306,8 +326,8 @@ describe("DASDTable", () => { await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); await user.click(screen.getByRole("button", { name: "Set DIAG on" })); expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ - { channel: "0.0.0160", diag: true }, - { channel: "0.0.0200", diag: true }, + { channel: "0.0.0160", state: "active", diag: true }, + { channel: "0.0.0200", state: "active", diag: true }, ]); }); @@ -317,8 +337,8 @@ describe("DASDTable", () => { await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); await user.click(screen.getByRole("button", { name: "Set DIAG off" })); expect(mockAddOrUpdateDevices).toHaveBeenCalledWith([ - { channel: "0.0.0160", diag: false }, - { channel: "0.0.0200", diag: false }, + { channel: "0.0.0160", state: "active", diag: false }, + { channel: "0.0.0200", state: "active", diag: false }, ]); }); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index e9a9a7f6d8..0b2d39b2e3 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -94,6 +94,16 @@ type DASDActionsProps = { * confirmation dialog. */ dispatcher: React.Dispatch; + /** + * When true, filters the returned actions to only those relevant to the + * current state of the first device in `devices`. Intended for single-device + * row actions where showing irrelevant options (e.g. "Activate" for an + * already active device) would be noisy. + * + * If false, returns the full set of actions regardless of device state, + * intented for bulk operations over a mixed selection. + */ + filterByDevice?: boolean; }; /** @@ -159,14 +169,26 @@ const filterDevices = (devices: Device[], filters: DASDDevicesFilters): Device[] }; /** - * Builds the list of available actions for a set of DASD devices. + * Builds the list of actions available for the given devices. + * + * When `filterByDevice` is true, only actions relevant to the current state of + * `devices[0]` are included (e.g. "Activate" is omitted if the device is + * already active). This is intended for single-device row actions where showing + * irrelevant options would be noisy. * - * Returns an array of action objects, each with a label and an `onClick` - * handler. + * When false (default), the full set of actions is returned regardless of + * device state, which is the right behavior for bulk operations where the + * selection may contain devices in mixed states. */ -const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsProps) => { - return [ +const buildActions = ({ + devices, + addOrUpdateDevices, + dispatcher, + filterByDevice = false, +}: DASDActionsProps) => { + const actions = [ { + id: "activate", title: _("Activate"), onClick: () => addOrUpdateDevices( @@ -176,6 +198,7 @@ const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsPr ), }, { + id: "deactivate", title: _("Deactivate"), onClick: () => addOrUpdateDevices( @@ -185,9 +208,7 @@ const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsPr ), }, { - isSeparator: true, - }, - { + id: "diagOn", title: _("Set DIAG on"), onClick: () => addOrUpdateDevices( @@ -195,6 +216,7 @@ const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsPr ), }, { + id: "diagOff", title: _("Set DIAG off"), onClick: () => addOrUpdateDevices( @@ -202,9 +224,7 @@ const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsPr ), }, { - isSeparator: true, - }, - { + id: "format", title: _("Format"), isDanger: true, onClick: () => { @@ -212,6 +232,21 @@ const buildActions = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsPr }, }, ]; + + if (filterByDevice) { + const [device] = devices; + const keptActions = { + activate: !device.active, + deactivate: device.active, + diagOn: !device.diag, + diagOff: device.diag, + format: !device.formatted, + }; + + return actions.filter((a) => keptActions[a.id]); + } + + return actions; }; /** Props for `FiltersToolbar`. */ @@ -362,15 +397,15 @@ const BulkActionsToolbar = ({ devices, addOrUpdateDevices, dispatcher }: DASDAct - {buildActions({ devices, addOrUpdateDevices, dispatcher }) - .filter((a) => !a.isSeparator) - .map(({ onClick, title }, i) => ( + {buildActions({ devices, addOrUpdateDevices, dispatcher }).map( + ({ onClick, title }, i) => ( - ))} + ), + )} ) : ( @@ -634,11 +669,14 @@ export default function DASDTable({ devices }) { updateSorting={onSortingChange} allowSelectAll itemActions={(d: Device) => - buildActions({ - devices: [d], - addOrUpdateDevices, - dispatcher: dispatch, - }) + isEmpty(state.selectedDevices.length) + ? buildActions({ + devices: [d], + addOrUpdateDevices, + dispatcher: dispatch, + filterByDevice: true, + }) + : [] } itemActionsLabel={(d: Device) => `Actions for ${d.channel}`} emptyState={ From a6f3542c9404750db68446f318e814fe6037f4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 25 Feb 2026 17:42:07 +0000 Subject: [PATCH 34/46] web: fix small detail found during self review --- web/src/components/storage/dasd/DASDTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 0b2d39b2e3..9982a1ec9b 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -669,7 +669,7 @@ export default function DASDTable({ devices }) { updateSorting={onSortingChange} allowSelectAll itemActions={(d: Device) => - isEmpty(state.selectedDevices.length) + isEmpty(state.selectedDevices) ? buildActions({ devices: [d], addOrUpdateDevices, From a954be1bd800916cbeb2dfff77a6fb39a8f1a9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 07:08:11 +0000 Subject: [PATCH 35/46] Fix bulk selection --- web/src/components/storage/dasd/DASDTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 9982a1ec9b..7b12e939af 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -661,6 +661,7 @@ export default function DASDTable({ devices }) { Date: Thu, 26 Feb 2026 07:08:53 +0000 Subject: [PATCH 36/46] Fix DASD link in the empty state --- web/src/components/storage/ProposalPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 9ac4f1f22d..8db25ef8af 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -61,6 +61,7 @@ import { _, n_ } from "~/i18n"; import { useProgress, useProgressChanges } from "~/queries/progress"; import { useNavigate, useLocation } from "react-router"; import { useStorageUiState } from "~/context/storage-ui-state"; +import { useSystem as useDASDSystem } from "~/hooks/model/system/dasd"; import type { Issue } from "~/model/issue"; @@ -137,7 +138,7 @@ function UnknownConfigEmptyState(): React.ReactNode { function UnavailableDevicesEmptyState(): React.ReactNode { const isZFCPSupported = false; - const isDASDSupported = false; + const dasdSystem = useDASDSystem(); const description = _( "There are not disks available for the installation. You may need to configure some device.", @@ -164,7 +165,7 @@ function UnavailableDevicesEmptyState(): React.ReactNode { )} - {isDASDSupported && ( + {dasdSystem && ( {_("Manage DASD devices")} From 994b1aaa34615edfadb0907ad4b44e03518e3fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 07:09:37 +0000 Subject: [PATCH 37/46] Fix tests --- .../storage/ConnectedDevicesMenu.test.tsx | 34 +++++-------------- .../components/storage/ProposalPage.test.tsx | 33 +++++++++--------- 2 files changed, 24 insertions(+), 43 deletions(-) diff --git a/web/src/components/storage/ConnectedDevicesMenu.test.tsx b/web/src/components/storage/ConnectedDevicesMenu.test.tsx index dd7e98eae8..52cf4cdbe4 100644 --- a/web/src/components/storage/ConnectedDevicesMenu.test.tsx +++ b/web/src/components/storage/ConnectedDevicesMenu.test.tsx @@ -26,31 +26,17 @@ import { installerRender, mockNavigateFn } from "~/test-utils"; import ConnectedDevicesMenu from "./ConnectedDevicesMenu"; import { STORAGE as PATHS } from "~/routes/paths"; -/*const mockUseZFCPSupported = jest.fn(); -jest.mock("~/queries/storage/zfcp", () => ({ - ...jest.requireActual("~/queries/storage/zfcp"), - useZFCPSupported: () => mockUseZFCPSupported(), -})); - -const mockUseDASDSupported = jest.fn(); -jest.mock("~/queries/storage/dasd", () => ({ - ...jest.requireActual("~/queries/storage/dasd"), - useDASDSupported: () => mockUseDASDSupported(), -})); -*/ - const mockReactivateSystem = jest.fn(); jest.mock("~/api", () => ({ ...jest.requireActual("~/api"), activateStorageAction: () => mockReactivateSystem(), })); -/* -beforeEach(() => { - mockUseZFCPSupported.mockReturnValue(false); - mockUseDASDSupported.mockReturnValue(false); -}); -*/ +const mockUseDASDSystem = jest.fn(); +jest.mock("~/hooks/model/system/dasd", () => ({ + ...jest.requireActual("~/hooks/model/system/dasd"), + useSystem: () => mockUseDASDSystem(), +})); async function openMenu() { const { user } = installerRender(); @@ -111,11 +97,9 @@ describe.skip("if zFCP is supported", () => { }); describe("if DASD is not supported", () => { - /* beforeEach(() => { - mockUseDASDSupported.mockReturnValue(false); + mockUseDASDSystem.mockReturnValue(undefined); }); - */ it("does not allow users to configure DASD", async () => { const { menu } = await openMenu(); @@ -124,12 +108,10 @@ describe("if DASD is not supported", () => { }); }); -describe.skip("if DASD is supported", () => { - /* +describe("if DASD is supported", () => { beforeEach(() => { - mockUseDASDSupported.mockReturnValue(true); + mockUseDASDSystem.mockReturnValue({}); }); - */ it("allows users to configure DASD", async () => { const { user, menu } = await openMenu(); diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index ae11761315..21359d4914 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -64,6 +64,7 @@ const mockUseReset = jest.fn(); const mockUseConfigModel = jest.fn(); const mockUseProposal = jest.fn(); const mockUseIssues = jest.fn(); +const mockUseDASDSystem = jest.fn(); jest.mock("~/hooks/model/system/storage", () => ({ ...jest.requireActual("~/hooks/model/system/storage"), @@ -90,14 +91,11 @@ jest.mock("~/hooks/model/issue", () => ({ useIssues: () => mockUseIssues(), })); -const mockUseZFCPSupported = jest.fn(); -jest.mock("~/queries/storage/zfcp", () => ({ - ...jest.requireActual("~/queries/storage/zfcp"), - useZFCPSupported: () => mockUseZFCPSupported(), +jest.mock("~/hooks/model/system/dasd", () => ({ + ...jest.requireActual("~/hooks/model/system/dasd"), + useSystem: () => mockUseDASDSystem(), })); -const mockUseDASDSupported = jest.fn(); - jest.mock("./ProposalFailedInfo", () => () =>
proposal failed info
); jest.mock("./UnsupportedModelInfo", () => () =>
unsupported model info
); jest.mock("./FixableConfigInfo", () => () =>
fixable config info
); @@ -105,6 +103,7 @@ jest.mock("./ProposalResultSection", () => () =>
result
); jest.mock("./ConfigEditor", () => () =>
installation devices
); jest.mock("./EncryptionSection", () => () =>
encryption section
); jest.mock("./BootSection", () => () =>
boot section
); +jest.mock("./ConnectedDevicesMenu", () => () =>
connected devices menu
); beforeEach(() => { mockUseReset.mockReturnValue(jest.fn()); @@ -134,9 +133,9 @@ describe("if there are no devices", () => { }); describe("if zFCP is not supported", () => { - beforeEach(() => { - mockUseZFCPSupported.mockReturnValue(false); - }); + // beforeEach(() => { + // mockUseZFCPSupported.mockReturnValue(false); + // }); it("does not render an option for activating zFCP", () => { installerRender(); @@ -146,7 +145,7 @@ describe("if there are no devices", () => { describe("if DASD is not supported", () => { beforeEach(() => { - mockUseDASDSupported.mockReturnValue(false); + mockUseDASDSystem.mockReturnValue(undefined); }); it("does not render an option for activating DASD", () => { @@ -155,12 +154,12 @@ describe("if there are no devices", () => { }); }); - describe("if zFCP is supported", () => { - beforeEach(() => { - mockUseZFCPSupported.mockReturnValue(true); - }); + describe.skip("if zFCP is supported", () => { + // beforeEach(() => { + // mockUseZFCPSupported.mockReturnValue(true); + // }); - xit("renders an option for activating zFCP", () => { + it("renders an option for activating zFCP", () => { installerRender(); expect(screen.queryByRole("link", { name: /zFCP/ })).toBeInTheDocument(); }); @@ -168,10 +167,10 @@ describe("if there are no devices", () => { describe("if DASD is supported", () => { beforeEach(() => { - mockUseDASDSupported.mockReturnValue(true); + mockUseDASDSystem.mockReturnValue({}); }); - xit("renders an option for activating DASD", () => { + it("renders an option for activating DASD", () => { installerRender(); expect(screen.queryByRole("link", { name: /DASD/ })).toBeInTheDocument(); }); From 687d9d548f1c672658c30cd56b5bf044bc072aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 12:32:52 +0000 Subject: [PATCH 38/46] Ensure issues is refetched --- web/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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(); From 635a5b09577820345756399f2fdd9b98194522d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 12:33:21 +0000 Subject: [PATCH 39/46] Avoid rendering device editor if there is no device --- web/src/components/storage/DriveEditor.tsx | 2 +- web/src/hooks/model/system/storage.ts | 3 ++- web/src/model/system/storage.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index f337c51def..7385fe41e7 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -104,7 +104,7 @@ export default function DriveEditor({ index }: DriveEditorProps) { * @fixme Make DriveEditor to work when the device is not found (e.g., after disabling * a iSCSI device). */ - if (drive === undefined) return null; + if (!drive) return null; return ( }> diff --git a/web/src/hooks/model/system/storage.ts b/web/src/hooks/model/system/storage.ts index ac8882c6d1..294585ef0c 100644 --- a/web/src/hooks/model/system/storage.ts +++ b/web/src/hooks/model/system/storage.ts @@ -170,7 +170,8 @@ function useDevice(name: string): Storage.Device | null { ...systemQuery, select: useCallback( (data: System | null): Storage.Device | null => { - return data?.storage ? findDeviceByName(data.storage, name) : null; + if (data?.storage) return findDeviceByName(data.storage, name) || null; + return null; }, [name], ), diff --git a/web/src/model/system/storage.ts b/web/src/model/system/storage.ts index 660df48aee..d80fc4e636 100644 --- a/web/src/model/system/storage.ts +++ b/web/src/model/system/storage.ts @@ -40,8 +40,8 @@ function findDevices(system: System, sids: number[]): Device[] { return sids.map((sid) => findDevice(system, sid)).filter((d) => d); } -function findDeviceByName(system: System, name: string): Device | null { - return flatDevices(system).find((d) => d.name === name) || null; +function findDeviceByName(system: System, name: string): Device | undefined { + return flatDevices(system).find((d) => d.name === name); } export { flatDevices, findDevice, findDevices, findDeviceByName }; From 4a56d68e743a4288883bcc8c0245b74077c36f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 12:35:36 +0000 Subject: [PATCH 40/46] Do not use old progress --- web/src/components/storage/ProposalPage.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 8db25ef8af..9a731a90ff 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -58,8 +58,7 @@ import { useProposal } from "~/hooks/model/proposal/storage"; import { STORAGE_MODEL_KEY, useConfigModel } from "~/hooks/model/storage/config-model"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; -import { useProgress, useProgressChanges } from "~/queries/progress"; -import { useNavigate, useLocation } from "react-router"; +import { useLocation } from "react-router"; import { useStorageUiState } from "~/context/storage-ui-state"; import { useSystem as useDASDSystem } from "~/hooks/model/system/dasd"; @@ -304,19 +303,11 @@ function ProposalPageContent(): React.ReactNode { * and test them individually. The proposal page should simply mount all those components. */ export default function ProposalPage(): React.ReactNode { - const progress = useProgress("storage"); - const navigate = useNavigate(); const location = useLocation(); // Hopefully this could be removed in the future. See rationale at UseStorageUiState const [resetNeeded, setResetNeeded] = useState(location.state?.resetStorageUiState); const { setUiState } = useStorageUiState(); - useProgressChanges(); - - React.useEffect(() => { - if (progress && !progress.finished) navigate(PATHS.progress); - }, [progress, navigate]); - React.useEffect(() => { if (resetNeeded) { setResetNeeded(false); From 846c64ffc1f45942bfdf33cd8fdfb8905d0ba40d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 12:33:34 +0000 Subject: [PATCH 41/46] refactor(web): revert hiding per-row actions on selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial rollback of changes done in commit b83d94b58c73a105a529dba4adecfa42f89fc8a8. Row actions are always available and act only on their own device, while the bulk toolbar acts on the current selection. Previously, row actions were hidden when devices were selected in order to avoid confusion between single-device and bulk actions. However, this would cause accessibility issues for keyboard and screen reader users who would lose their interaction point mid-navigation with no clear guidance on where actions had moved. Alternatives were explored but discarded: - Disabling the toggle: while keyboard navigation is preserved, the "more" icon (three horizontal dots) is already rendered in a muted gray style making it practically indistinguishable from its disabled state. Combined with PatternFly's Tooltip not triggering on disabled elements, sighted keyboard users would have no visual feedback about why the toggle is inactive and where to go instead. - Visually hiding the toggle: sighted keyboard users would tab onto an invisible element with no feedback. - Per-row skip links: semantically wrong and fatiguing for screen reader users. A hidden "Skip to bulk actions" link per row would be announced on every row, adding repetitive noise. Skip links should be stable, always-present, and bypass navigation blocks instead of replace contextual controls. Replacing per-row action toggles with a skip link conflates navigation with action and increases cognitive load. - Split interaction models for sighted vs screen reader users: rejected on principle. Accessibility best practice is "same model, different presentation", not different interaction models per user type. Creating AT-only affordances or hidden interaction paths is not accessibility — it is a hidden compensation for unstable UI. The core a11y principle at play is: don't remove contextual affordances when context changes, clarify the context instead. Keeping both row actions and bulk toolbar always available is the safest baseline that respects this principle. Further iterations will explore a smoother solution that works for all users without altering the DOM abruptly when selection changes. --- web/src/components/storage/dasd/DASDTable.test.tsx | 5 +++-- web/src/components/storage/dasd/DASDTable.tsx | 14 ++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index a31211fb2e..120c066ff8 100644 --- a/web/src/components/storage/dasd/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -282,10 +282,11 @@ describe("DASDTable", () => { ]); }); - it("hides row actions when devices are selected", async () => { + // Test for a rollback change, see commit message + it("does not hide row actions when devices are selected", async () => { const { user } = installerRender(); await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); - expect(screen.queryByRole("button", { name: "Actions for 0.0.0160" })).toBeNull(); + screen.queryByRole("button", { name: "Actions for 0.0.0160" }); }); it("filters irrelevant actions for a single device", async () => { diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 7b12e939af..de55147477 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -670,14 +670,12 @@ export default function DASDTable({ devices }) { updateSorting={onSortingChange} allowSelectAll itemActions={(d: Device) => - isEmpty(state.selectedDevices) - ? buildActions({ - devices: [d], - addOrUpdateDevices, - dispatcher: dispatch, - filterByDevice: true, - }) - : [] + buildActions({ + devices: [d], + addOrUpdateDevices, + dispatcher: dispatch, + filterByDevice: true, + }) } itemActionsLabel={(d: Device) => `Actions for ${d.channel}`} emptyState={ From 6d2584dfdb69b356e01e2d562636ff94ae84c650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 13:32:10 +0000 Subject: [PATCH 42/46] fix(web): improve bulk toolbar announcements for screen reader users Add an aria-live="polite" region to BulkActionsToolbar so that selection state changes are announced as they happen. Two sets of strings are used: concise visual text for sighted users and more descriptive sr-only text providing fuller context. The visual text is marked aria-hidden to prevent double announcements. --- .../storage/dasd/DASDTable.test.tsx | 18 ++++ web/src/components/storage/dasd/DASDTable.tsx | 88 ++++++++++++------- 2 files changed, 72 insertions(+), 34 deletions(-) diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index 120c066ff8..db22a49389 100644 --- a/web/src/components/storage/dasd/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -106,6 +106,24 @@ describe("DASDTable", () => { screen.getByRole("button", { name: "Activate" }); }); + it("announces selection state changes to screen readers", async () => { + const { user } = installerRender(); + screen.getByText("No devices selected. Select one or more devices to perform bulk actions."); + + // Select one device + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + screen.getByText("1 device selected. Use the actions toolbar to apply changes."); + + // Select another device + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + screen.getByText("2 devices selected. Use the actions toolbar to apply changes."); + + // Deselect all + await user.click(screen.getByRole("checkbox", { name: "Select row 0" })); + await user.click(screen.getByRole("checkbox", { name: "Select row 1" })); + screen.getByText("No devices selected. Select one or more devices to perform bulk actions."); + }); + it("mounts FormatActionHandler on format action request", async () => { const { user } = installerRender(); const selection = screen.getByRole("checkbox", { name: "Select row 1" }); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index de55147477..a91bb550f8 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -372,44 +372,64 @@ const FiltersToolbar = ({ * Reuses `DASDActionsProps` since it needs the same dependencies as `buildActions`. */ const BulkActionsToolbar = ({ devices, addOrUpdateDevices, dispatcher }: DASDActionsProps) => { - const applyText = sprintf( - n_( - // TRANSLATORS: message shown in bulk action toolbar when just one device - // is selected - "Actions for the selected device:", - // TRANSLATORS: message shown in bulk action toolbar when some devices are - // selected. %s is replaced with the amount of devices - "Actions for %s selected devices:", - devices.length, - ), - devices.length, - ); + const devicesQty = devices.length; + const applyText = + devicesQty > 0 + ? sprintf( + n_( + // TRANSLATORS: message shown in bulk action toolbar when just one device + // is selected + "Actions for the selected device:", + // TRANSLATORS: message shown in bulk action toolbar when some devices are + // selected. %s is replaced with the amount of devices + "Actions for %s selected devices:", + devicesQty, + ), + devicesQty, + ) + : // TRANSLATORS: hint shown in the bulk actions toolbar when no devices are selected. + _("Select devices to perform bulk actions"); + + const screenReadersText = + devicesQty > 0 + ? sprintf( + n_( + // TRANSLATORS: message announced to screen reader users when just one + // device is selected. + "1 device selected. Use the actions toolbar to apply changes.", + // TRANSLATORS: message announced to screen reader users when some + // devices are selected. %s is replaced with the amount of devices. + "%s devices selected. Use the actions toolbar to apply changes.", + devicesQty, + ), + devicesQty, + ) + : // TRANSLATORS: message announced to screen reader users when no devices + // are selected. + _("No devices selected. Select one or more devices to perform bulk actions."); return ( - {devices.length ? ( - <> - - - {applyText} - - - - - {buildActions({ devices, addOrUpdateDevices, dispatcher }).map( - ({ onClick, title }, i) => ( - - - - ), - )} - - - ) : ( - {_("Select devices to perform bulk actions")} + + + {screenReadersText} + {applyText} + + + + {devicesQty > 0 && ( + + {buildActions({ devices, addOrUpdateDevices, dispatcher }).map( + ({ onClick, title }, i) => ( + + + + ), + )} + )} From 70b0bd766e07082b6bcb254db4b983ab573e243f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 14:09:30 +0000 Subject: [PATCH 43/46] feat(web): add and use SimpleDropdown Created in the context of adding labeled action menus to DASDTable rows Add SimpleDropdown, a plain dropdown menu with a "more actions" toggle and a labeled group header. Intended as a tiny 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, improving accessibility for screen reader users navigating action menus in tables. Add `itemActionsComponent` prop to SelectableDataTable to allow callers to provide a custom component for rendering the actions cell, falling back to PatternFly's ActionsColumn when not provided. Last but not least, use SimpleDropdown in DASDTable making the channel identifier shown as a group label when the actions menu is open, making it clear which device the actions belong to. --- .../core/SelectableDataTable.test.tsx | 15 ++ .../components/core/SelectableDataTable.tsx | 53 ++++--- .../components/core/SimpleDropdown.test.tsx | 106 ++++++++++++++ web/src/components/core/SimpleDropdown.tsx | 133 ++++++++++++++++++ web/src/components/storage/dasd/DASDTable.tsx | 2 + 5 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 web/src/components/core/SimpleDropdown.test.tsx create mode 100644 web/src/components/core/SimpleDropdown.tsx diff --git a/web/src/components/core/SelectableDataTable.test.tsx b/web/src/components/core/SelectableDataTable.test.tsx index 463f316bbe..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"); diff --git a/web/src/components/core/SelectableDataTable.tsx b/web/src/components/core/SelectableDataTable.tsx index d1ff99ec6f..38d6eb1dde 100644 --- a/web/src/components/core/SelectableDataTable.tsx +++ b/web/src/components/core/SelectableDataTable.tsx @@ -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" > >; @@ -465,6 +479,7 @@ export default function SelectableDataTable({ allowSelectAll = false, itemActions, itemActionsLabel, + itemActionsComponent, emptyState, ...tableProps }: SelectableDataTableProps) { @@ -564,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 ( @@ -577,21 +595,23 @@ export default function SelectableDataTable({ ))} {!isEmpty(actions) && ( - ( - - - - )} - /> + {ItemActionsComponent ? ( + + ) : ( + ( + + + + )} + /> + )} )} @@ -609,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, 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/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index a91bb550f8..ccc0094577 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -43,6 +43,7 @@ import FormatActionHandler from "~/components/storage/dasd/FormatActionHandler"; import SelectableDataTable, { SortedBy } from "~/components/core/SelectableDataTable"; import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; import SimpleSelector from "~/components/core/SimpleSelector"; +import SimpleDropdown from "~/components/core/SimpleDropdown"; import { useAddOrUpdateDevices } from "~/hooks/model/config/dasd"; import { hex, sortCollection, translateEntries } from "~/utils"; import { _, n_, N_ } from "~/i18n"; @@ -698,6 +699,7 @@ export default function DASDTable({ devices }) { }) } itemActionsLabel={(d: Device) => `Actions for ${d.channel}`} + itemActionsComponent={SimpleDropdown} emptyState={ Date: Thu, 26 Feb 2026 16:14:04 +0000 Subject: [PATCH 44/46] fix(web): drop fixmes and return null insteaed of undefined Related to https://github.com/agama-project/agama/pull/3143#discussion_r2859905366 --- web/src/hooks/model/config/dasd.ts | 8 +------- web/src/hooks/model/system/dasd.ts | 13 ++----------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts index e34f43a6ea..6352876226 100644 --- a/web/src/hooks/model/config/dasd.ts +++ b/web/src/hooks/model/config/dasd.ts @@ -38,10 +38,6 @@ type addOrUpdateDevicesFn = (devices: DASD.Device[]) => Response; * * @see {@link https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#select TanStack Query Select} * @see {@link https://tkdodo.eu/blog/react-query-selectors-supercharged#what-is-select Query Selectors Supercharged} - * - * FIXME: Read todo note below. - * @todo Consider returning an empty object ({}) instead of undefined to simplify - * consuming code and eliminate the need for fallback checks throughout the codebase. */ const dasdSelector = (data: Config | undefined): DASD.Config => data?.dasd; @@ -73,8 +69,6 @@ function useConfig(): DASD.Config | undefined { * @todo Remove fallback once useConfig returns empty object by default */ function useAddOrUpdateDevices(): addOrUpdateDevicesFn { - // FIXME: useConfig should return an empty object instead of falling back - // to an empty object all the time const config = useConfig() || {}; return (devices: DASD.Device[]) => { @@ -85,7 +79,7 @@ function useAddOrUpdateDevices(): addOrUpdateDevicesFn { }); return patchConfig({ - dasd: { ...(config || {}), devices: newDevicesConfig }, + dasd: { ...config, devices: newDevicesConfig }, }); }; } diff --git a/web/src/hooks/model/system/dasd.ts b/web/src/hooks/model/system/dasd.ts index aa2e3e8dc0..fefe1f4763 100644 --- a/web/src/hooks/model/system/dasd.ts +++ b/web/src/hooks/model/system/dasd.ts @@ -33,22 +33,13 @@ import type { System, DASD } from "~/model/system"; * * @see {@link https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#select TanStack Query Select} * @see {@link https://tkdodo.eu/blog/react-query-selectors-supercharged#what-is-select Query Selectors Supercharged} - * - * FIXME: Read todo note below. - * @todo Consider returning an empty object ({}) instead of undefined to - * simplify consuming code and eliminate the need for fallback checks throughout - * the codebase. */ -const dasdSelector = (data: System | undefined): DASD.System => data?.dasd; +const dasdSelector = (data: System | null): DASD.System => data?.dasd; /** * Retrieve DASD system information. - * - * @todo Returning an empty object by default would eliminate null checks and - * simplify all consuming code. This pattern would be more consistent with having - * a "no system data" state represented as an empty object rather than undefined. */ -function useSystem(): DASD.System | undefined { +function useSystem(): DASD.System | null { const { data } = useSuspenseQuery({ ...systemQuery, select: dasdSelector, From d4201f5f74a6fb5f9f820a78010f196b457984a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 16:42:28 +0000 Subject: [PATCH 45/46] fix(web): drop passive voice --- web/src/components/storage/dasd/DASDPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index b3a19aa9e2..9a0ba9c275 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -36,7 +36,7 @@ import { _ } from "~/i18n"; const NoDevicesAvailable = () => { return ( - {_("No DASD devices were found in this machine.")} + {_("No DASD devices found in this machine.")} ); }; From af83e3a4887bfc403c28708e43e2c0c03b0ff821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Feb 2026 16:48:25 +0000 Subject: [PATCH 46/46] doc: update changes files --- rust/package/agama.changes | 5 +++++ service/package/rubygem-agama-yast.changes | 6 ++++++ web/package/agama-web-ui.changes | 6 ++++++ 3 files changed, 17 insertions(+) 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/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