diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index f5bf3d8d3b..e03aa7c80c 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Aug 20 10:27:07 UTC 2025 - David Diaz + +- Refactor DASD page to make it more usable and improve its + performance (related to bsc#1247444, gh#agama-project/agama#2648). + ------------------------------------------------------------------- Wed Aug 20 10:20:22 UTC 2025 - Ancor Gonzalez Sosa diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 8826719701..f5e2ab6318 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -498,6 +498,10 @@ label.pf-m-disabled + .pf-v6-c-check__description { } } +.pf-v6-c-table__sort-indicator { + --pf-v6-c-table__sort-indicator--MarginInlineStart: var(--pf-t--global--spacer--xs); +} + // Some utilities not found at PF .w-14ch { inline-size: 14ch; diff --git a/web/src/components/core/Popup.test.tsx b/web/src/components/core/Popup.test.tsx index 36e063b38f..0ce8ad30ef 100644 --- a/web/src/components/core/Popup.test.tsx +++ b/web/src/components/core/Popup.test.tsx @@ -180,6 +180,15 @@ describe("Popup.AncillaryAction", () => { }); }); +describe("Popup.DangerousAction", () => { + it("renders a 'danger' button with given children as content", async () => { + installerRender(Format everything); + + const button = screen.queryByRole("button", { name: "Format everything" }); + expect(button.classList.contains("pf-m-danger")).toBe(true); + }); +}); + describe("Popup.Confirm", () => { describe("when holding no children", () => { it("renders a 'primary' button using 'Confirm' text as content", async () => { diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 30a3978454..83c2584d76 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -164,6 +164,22 @@ const AncillaryAction = ({ children, ...actionsProps }: PredefinedAction) => ( ); +/** + * A Popup action with danger variant + * + * It always set `variant` {@link https://www.patternfly.org/components/button PF/Button} + * prop to "danger", no matter what given in `props`. + * + * @example Simple usage + * Format + * + */ +const DangerousAction = ({ children, ...actionProps }: PredefinedAction) => ( + + {children} + +); + /** * Agama component for displaying a popup * @@ -245,6 +261,7 @@ const Popup = ({ Popup.Actions = Actions; Popup.PrimaryAction = PrimaryAction; +Popup.DangerousAction = DangerousAction; Popup.Confirm = Confirm; Popup.SecondaryAction = SecondaryAction; Popup.Cancel = Cancel; diff --git a/web/src/components/core/SelectableDataTable.test.tsx b/web/src/components/core/SelectableDataTable.test.tsx index e800cbdf7c..5ecb7e51b0 100644 --- a/web/src/components/core/SelectableDataTable.test.tsx +++ b/web/src/components/core/SelectableDataTable.test.tsx @@ -120,7 +120,7 @@ const vg = { const columns: SelectableDataTableColumn[] = [ // FIXME: do not use any but the right types once storage part is rewritten. // Or even better, write a test not coupled to storage - { name: "Device", value: (item: any) => item.name }, + { name: "Device", value: (item: any) => item.name, sortingKey: "name" }, { name: "Content", value: (item: any) => { @@ -130,7 +130,7 @@ const columns: SelectableDataTableColumn[] = [ return item.content; }, }, - { name: "Size", value: (item: any) => item.size }, + { name: "Size", value: (item: any) => item.size, sortingKey: "size" }, ]; const onChangeFn = jest.fn(); @@ -192,6 +192,31 @@ describe("SelectableDataTable", () => { within(table).getByRole("row", { name: /Personal Data/ }); }); + it("renders actions column when itemActions is provided", async () => { + const editFn = jest.fn(); + const formatFn = jest.fn(); + + const { user } = plainRender( + [ + { title: `Edit ${d.name}`, onClick: editFn }, + { title: `Format ${d.name}`, onClick: formatFn }, + ]} + itemActionsLabel="Actions" + />, + ); + + const table = screen.getByRole("grid"); + const sda = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaActions = within(sda).getByRole("button", { name: "Actions" }); + await user.click(sdaActions); + const menu = screen.getByRole("menu"); + const edit = within(menu).getByRole("menuitem", { name: "Edit /dev/sda" }); + await user.click(edit); + expect(editFn).toHaveBeenCalled(); + }); + it("renders a expand toggler in items with children", () => { plainRender(); const table = screen.getByRole("grid"); @@ -259,6 +284,130 @@ describe("SelectableDataTable", () => { expect(sdaChild).not.toBeNull(); }); + describe("when not providing a custom item equality function", () => { + const onSelectionChange = jest.fn(); + + beforeEach(() => onSelectionChange.mockClear()); + + describe("and items has the given id property", () => { + it("selects an item correctly", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaCheckbox = within(sdaRow).getByRole("checkbox"); + await user.click(sdaCheckbox); + expect(onSelectionChange).toHaveBeenCalledWith([sda]); + }); + + it("unselects an item correctly", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaCheckbox = within(sdaRow).getByRole("checkbox"); + await user.click(sdaCheckbox); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + }); + + describe("but items does not have the given id property", () => { + it("selects an item correctly", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaCheckbox = within(sdaRow).getByRole("checkbox"); + await user.click(sdaCheckbox); + expect(onSelectionChange).toHaveBeenCalledWith([sda]); + }); + + it("unselects an item correctly", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaCheckbox = within(sdaRow).getByRole("checkbox"); + await user.click(sdaCheckbox); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + }); + }); + + describe("when providing a custom item equality function", () => { + const onSelectionChange = jest.fn(); + const itemEqualityFn = (a, b) => a.type === b.type && a.name === b.name; + + beforeEach(() => onSelectionChange.mockClear()); + + it("selects an item correctly", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaCheckbox = within(sdaRow).getByRole("checkbox"); + await user.click(sdaCheckbox); + expect(onSelectionChange).toHaveBeenCalledWith([sda]); + }); + + it("unselects an item correctly", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ }); + const sdaCheckbox = within(sdaRow).getByRole("checkbox"); + await user.click(sdaCheckbox); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + }); + describe("when `itemsSelected` is given", () => { it("renders nothing as checked if value is an empty array", () => { plainRender(); @@ -432,7 +581,7 @@ describe("SelectableDataTable", () => { describe("and user selects an already selected item", () => { it("triggers the `onSelectionChange` callback with a collection not including the item", async () => { const { user } = plainRender( - , + , ); const sda1row = screen.getByRole("row", { name: /dev\/sda1/ }); const sda1radio = within(sda1row).getByRole("checkbox"); @@ -451,4 +600,173 @@ describe("SelectableDataTable", () => { }); }); }); + + describe("sorting", () => { + const updateSorting = jest.fn(); + + beforeEach(() => updateSorting.mockClear()); + + it("calls updateSorting with correct next direction when clicking the currently sorted column", async () => { + const { user } = plainRender( + , + ); + + const deviceHeader = screen.getByRole("columnheader", { name: "Device" }); + const deviceHeaderButton = within(deviceHeader).getByRole("button", { name: "Device" }); + await user.click(deviceHeaderButton); + + expect(updateSorting).toHaveBeenCalledWith({ + index: 0, + direction: "desc", + }); + }); + + it("does not call updateSorting when clicking on a non-sortable column", async () => { + const { user } = plainRender( + , + ); + + const contentHeader = screen.getByRole("columnheader", { name: "Content" }); + expect(within(contentHeader).queryByRole("button", { name: "Content" })).toBeNull(); + await user.click(contentHeader); + + expect(updateSorting).not.toHaveBeenCalled(); + }); + + it("calls updateSorting with ascending direction when clicking a new sortable column", async () => { + const { user } = plainRender( + , + ); + + const sizeHeader = screen.getByRole("columnheader", { name: "Size" }); + const sizeHeaderButton = within(sizeHeader).getByRole("button", { name: "Size" }); + await user.click(sizeHeaderButton); + + expect(updateSorting).toHaveBeenCalledWith({ + index: 2, + direction: "asc", + }); + }); + }); + + describe("select/unselect all checkbox", () => { + const onSelectionChange = jest.fn(); + + beforeEach(() => onSelectionChange.mockClear()); + + it("renders it only when selectionMode is multiple and allowSelectAll is true and there are items to show", () => { + const { rerender } = plainRender( + , + ); + + expect(screen.queryByRole("checkbox", { name: /select all/i })).toBeNull(); + + rerender(); + expect(screen.queryByRole("checkbox", { name: /select all/i })).toBeNull(); + + rerender( + , + ); + expect(screen.queryByRole("checkbox", { name: /select all/i })).toBeNull(); + + rerender(); + screen.getByRole("checkbox", { name: "Select all rows" }); + }); + + it("selects all items if it is clicked and not all items are selected", async () => { + const { user, rerender } = plainRender( + , + ); + + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select all rows" }); + await user.click(selectAllCheckbox); + + expect(onSelectionChange).toHaveBeenCalledWith(props.items); + + rerender( + , + ); + + await user.click(selectAllCheckbox); + + expect(onSelectionChange).toHaveBeenCalledWith(props.items); + }); + + it("unselects all items if it is clicked when all items are selected", async () => { + const { user } = plainRender( + , + ); + + // FIXME: label should be "Unselect" + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select all rows" }); + await user.click(selectAllCheckbox); + + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + }); + + describe("EmptyState support", () => { + it("renders no tbody when items is empty and no emptyState is provided", () => { + plainRender( + , + ); + const table = screen.getByRole("grid"); + expect(table.querySelectorAll("tbody").length).toBe(0); + }); + + it("renders emptyState when items is empty", () => { + plainRender( + Resources not found} + />, + ); + + expect(screen.getByText("Resources not found")).toBeInTheDocument(); + }); + + it("does not render emptyState when items are present", () => { + plainRender(Resources not found} />); + + expect(screen.queryByText("Resources not found")).toBeNull(); + const table = screen.getByRole("grid"); + within(table).getByRole("row", { name: /dev\/sda 1024/ }); + }); + }); }); diff --git a/web/src/components/core/SelectableDataTable.tsx b/web/src/components/core/SelectableDataTable.tsx index 7a66c9f450..39b32a6d00 100644 --- a/web/src/components/core/SelectableDataTable.tsx +++ b/web/src/components/core/SelectableDataTable.tsx @@ -21,6 +21,7 @@ */ import React, { useState } from "react"; +import { Bullseye, MenuToggle } from "@patternfly/react-core"; import { Table, TableProps, @@ -33,22 +34,27 @@ import { RowSelectVariant, ThProps, TdProps, + IAction, + ActionsColumn, } from "@patternfly/react-table"; +import { isEmpty, isFunction } from "radashi"; +import Icon from "~/components/layout/Icon"; +import { _ } from "~/i18n"; /* eslint-disable @typescript-eslint/no-explicit-any */ /** - * An object for sharing data across nested maps - * - * Since function arguments are always passed by value, an object passed by - * sharing is needed for sharing data that might be mutated from different - * places, as it is the case of the rowIndex prop here. - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions#passing_arguments + * Represents the current sorting configuration. */ - -type SharedData = { - rowIndex: number; +export type SortedBy = { + /** + * Index of the column being sorted (given by PatternFly/TableTypes#onShort). + */ + index?: number; + /** + * Direction of the sort: ascending ("asc") or descending ("desc"). + */ + direction?: "asc" | "desc"; }; /** @@ -80,6 +86,12 @@ export type SelectableDataTableColumn = { */ value: (item: object) => React.ReactNode; + /** + * If defined, marks the column as sortable and specifies the key used for + * sorting. + */ + sortingKey?: string; + /** * A space-separated string of additional CSS class names to apply to the column's cells. * Useful for custom styling or conditional formatting. @@ -159,6 +171,44 @@ export type SelectableDataTableProps = { */ itemClassNames?: (item: T) => string | undefined; + /** + * A function used to determine if two items are equal. + * + * It helps correctly identify and manipulate selected items, especially when + * items are objects that may not be referentially equal. The function should + * return `true` if `a` and `b` are considered the same item; `false` + * otherwise. + */ + itemEqualityFn?: (a: T, b: T) => boolean; + + /** + * Function to generate a list of actions for a given row. + * + * If provided, an additional column is rendered to display contextual actions. + * + * @example + * itemActions={(item) => [ + * { + * title: 'Edit', + * onClick: () => handleEdit(item), + * }, + * { + * title: 'Delete', + * onClick: () => handleDelete(item), + * isDanger: true, + * } + * ]} + */ + itemActions?: (d: T) => IAction[]; + + /** + * Accessible label for the actions menu toggle button. + * + * Used as the `aria-label` for the row action menu's trigger to improve + * accessibility. + */ + itemActionsLabel?: (d: T) => string | string; + /** * Array of currently selected items. */ @@ -169,30 +219,174 @@ export type SelectableDataTableProps = { */ initialExpandedKeys?: any[]; + /** + * Current column index and direction used for sorting. + */ + sortedBy?: SortedBy; + + /** + * Optional callback to update sorting. If not provided, sorting is disabled. + * Called when a sortable column header is clicked. + */ + updateSorting?: (v: SortedBy) => void; + /** * Callback fired when the selection changes. * * Receives the updated array of selected items. */ onSelectionChange?: (selection: T[]) => void; + + /** + * If true, allows the user to select all rows via the header checkbox. + * + * Only applicable when `allowMultiple` is also true. When enabled, a checkbox + * will appear in the header row, letting users select or deselect all rows in + * one action. + * + * When toggled, `onSelectionChange` will be called with either: + * - the full `items` array (when selecting all), or + * - an empty array (when deselecting all). + * + */ + allowSelectAll?: boolean; + + /** + * Renders a custom empty state when `items` is empty. + * + * If not provided, the table will simply render no rows. When provided, this + * content will be shown inside a full-width row spanning all columns + * (including selection and action columns, if enabled). + */ + emptyState?: React.ReactNode; } & TableProps; +/** + * An internal utility object used to pass context and state between deeply + * nested render functions, such as those involved in building table rows and + * headers. + * + * JavaScript function arguments are passed by value, but objects are passed + * by reference. This allows `SharedData` to expose shared and optionally mutable + * data across nested calls. + * + * - `rowIndex` is intentionally mutable and is updated as rows are rendered. + * - `sortedBy` and `updateSorting` are read-only references to external sorting state. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions#passing_arguments + */ +type SharedData = { + /** + * Mutable counter tracking the current row index during rendering. Updated + * in-place to avoid recomputation or prop drilling. + */ + rowIndex: number; + + /** + * Whether multiple item selection is allowed. + */ + readonly allowMultiple: boolean; + + /** + * Whether the select all checkbox is rendered or not. + */ + readonly showSelectAll: boolean; + + /** + * Whether all items in the table are currently selected. + * Used to reflect checkbox state in the header. + */ + readonly isAllSelected: boolean; + + /** + * Handles toggling selection of all items in the table. + * If `isSelecting` is `true`, all items are passed to `onSelectionChange`. + * If `false`, an empty array is passed instead (deselect all). + */ + readonly selectAll: (isSelecting: boolean) => void; +} & Readonly< + Pick< + SelectableDataTableProps, + "sortedBy" | "updateSorting" | "allowSelectAll" | "itemActions" | "itemActionsLabel" + > +>; + +/** + * Build sorting props for a given column header, enabling PatternFly table + * sorting. + */ +const buildSorting = ( + columnIndex: number, + column: SelectableDataTableColumn, + sharedData: SharedData, +): ThProps["sort"] | undefined => { + const { sortedBy, updateSorting } = sharedData; + const { sortingKey } = column; + + if (!sortedBy || !isFunction(updateSorting)) return undefined; + + if (!sortingKey) { + process.env.NODE_ENV === "development" && + console.error( + `Column ${column.name} (index ${columnIndex}) does not provide 'sortingKey', skipping sorting props`, + ); + return undefined; + } + + return { + sortBy: { + ...sortedBy, + defaultDirection: "asc", + }, + onSort: (_event, index, direction) => { + updateSorting({ index, direction }); + }, + columnIndex, + }; +}; + /** * Internal component for building the table header */ -const TableHeader = ({ columns }: { columns: SelectableDataTableColumn[] }) => ( - - - - - {columns?.map((c, i) => ( - - {c.name} - - ))} - - -); +const TableHeader = ({ + columns, + sharedData, +}: { + columns: SelectableDataTableColumn[]; + sharedData: SharedData; +}) => { + const { allowMultiple, allowSelectAll, isAllSelected, showSelectAll, selectAll, itemActions } = + sharedData; + + const selectAllProps = + allowMultiple && allowSelectAll && showSelectAll + ? { + onSelect: (_event, isSelecting: boolean) => selectAll(isSelecting), + isSelected: isAllSelected, + } + : undefined; + + // TODO: extract header ariaLabel to props instead of harconding them here. + return ( + + + + + {columns?.map((c, i) => { + const sortProp = + sharedData.sortedBy && c.sortingKey ? buildSorting(i, c, sharedData) : undefined; + + return ( + + {c.name} + + ); + })} + {itemActions && } + + + ); +}; /** * Helper function to sanitize the `itemsSelected` prop value for the @@ -236,6 +430,13 @@ const sanitizeSelection = ( * For consistency, the selection API (`itemsSelected` and `onSelectionChange`) * always uses arrays, even when `selectionMode` is set to `"single"`. * + * By default, item equality is determined by comparing each item's `itemIdKey` + * property (which defaults to `"id"`). If the item does not have that key, a + * strict equality check (`===`) between the two items is performed. + * + * Such comparison can be overridden by providing a custom `itemEqualityFn` + * prop, for example, to perform deep comparison or other alternative logic. + * * @note It only accepts one nesting level. */ export default function SelectableDataTable({ @@ -246,9 +447,21 @@ export default function SelectableDataTable({ itemChildren = () => [], itemSelectable = () => true, itemClassNames = () => "", + itemEqualityFn = (a, b) => { + if (Object.hasOwn(a, itemIdKey)) { + return a[itemIdKey] === b[itemIdKey]; + } + return a === b; + }, itemsSelected = [], initialExpandedKeys = [], onSelectionChange, + sortedBy = {}, + updateSorting = undefined, + allowSelectAll = false, + itemActions, + itemActionsLabel, + emptyState, ...tableProps }: SelectableDataTableProps) { const [expandedItemsKeys, setExpandedItemsKeys] = useState(initialExpandedKeys); @@ -256,9 +469,7 @@ export default function SelectableDataTable({ const allowMultiple = selectionMode === "multiple"; const isItemSelected = (item: object) => { const selected = selection.find((selectionItem) => { - return ( - Object.hasOwn(selectionItem, itemIdKey) && selectionItem[itemIdKey] === item[itemIdKey] - ); + return itemEqualityFn(item, selectionItem); }); return selected !== undefined || selection.includes(item); @@ -279,7 +490,7 @@ export default function SelectableDataTable({ } if (isItemSelected(item)) { - onSelectionChange(selection.filter((i) => i !== item)); + onSelectionChange(selection.filter((i) => !itemEqualityFn(i, item))); } else { onSelectionChange([...selection, item]); } @@ -356,6 +567,25 @@ export default function SelectableDataTable({ {c.value(item)} ))} + {itemActions && ( + + ( + + + + )} + /> + + )} {renderChildren()} @@ -363,13 +593,45 @@ export default function SelectableDataTable({ }; // @see SharedData - const sharedData = { rowIndex: 0 }; + const sharedData = { + rowIndex: 0, + sortedBy, + updateSorting, + allowMultiple, + allowSelectAll, + itemActions, + itemActionsLabel, + // FIXME: drop showSelectAll once items is part of SharedData + showSelectAll: allowSelectAll && items.length > 0, + isAllSelected: items.length > 0 && items.length === itemsSelected.length, + selectAll: (isSelecting: boolean) => + isSelecting ? onSelectionChange(items) : onSelectionChange([]), + }; - const TableBody = () => items?.map((item) => renderItem(item, sharedData)); + // 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)); + }; return ( - +
); diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index 9963a2a4d6..ceacd9ad8a 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -54,6 +54,7 @@ import NetworkWifi from "@icons/network_wifi.svg?component"; import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; import Translate from "@icons/translate.svg?component"; +import SearchOff from "@icons/search_off.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; import UnfoldLess from "@icons/unfold_less.svg?component"; import UnfoldMore from "@icons/unfold_more.svg?component"; @@ -92,6 +93,7 @@ const icons = { network_wifi: NetworkWifi, network_wifi_1_bar: NetworkWifi1Bar, network_wifi_3_bar: NetworkWifi3Bar, + search_off: SearchOff, settings_ethernet: SettingsEthernet, translate: Translate, unfold_less: UnfoldLess, diff --git a/web/src/components/storage/dasd/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx index 7f47a3c489..81a2276682 100644 --- a/web/src/components/storage/dasd/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { Content, Stack } from "@patternfly/react-core"; +import { Content } from "@patternfly/react-core"; import { Page } from "~/components/core"; import DASDTable from "./DASDTable"; import DASDFormatProgress from "./DASDFormatProgress"; @@ -40,12 +40,7 @@ export default function DASDPage() { - {/** TRANSLATORS: DASD devices selection table */} - - - - - + diff --git a/web/src/components/storage/dasd/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx index 78d765527b..8d38180762 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] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,20 +20,38 @@ * find current contact information at www.suse.com. */ -import React from "react"; +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[] = []; +let eventCallback; +const mockClient = { + onEvent: jest.fn().mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }), +}; + +jest.mock("~/context/installer", () => ({ + ...jest.requireActual("~/context/installer"), + useInstallerClient: () => mockClient, +})); jest.mock("~/queries/storage/dasd", () => ({ useDASDDevices: () => mockDASDDevices, - useDASDMutation: () => jest.fn(), + useDASDMutation: () => ({ + mutate: jest.fn(), + }), useFormatDASDMutation: () => jest.fn(), })); +jest.mock("~/components/storage/dasd/FormatActionHandler", () => () => ( +
FormatActionHandler Mock
+)); + describe("DASDTable", () => { describe("when there is some DASD devices available", () => { beforeEach(() => { @@ -70,50 +88,83 @@ describe("DASDTable", () => { screen.getByText("active"); }); - it("does not allow to perform any action if not selected any device", () => { - installerRender(); - const button = screen.getByRole("button", { name: "Perform an action" }); - expect(button).toHaveAttribute("disabled"); + it("does not offer bulk actions until a device is selected", async () => { + const { user } = installerRender(); + screen.getByText("Select devices to enable 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(); + screen.getByRole("button", { name: "Activate" }); + }); + + it("mounts FormatActionHandler on format action request", async () => { + 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("when there are some DASD selected", () => { - it("allows to perform a set of actions over them", async () => { + 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: "Perform an action" }); - expect(button).not.toHaveAttribute("disabled"); + const button = screen.getByRole("button", { name: "Activate" }); await user.click(button); - screen.getByRole("menuitem", { name: "Format" }); + screen.getByRole("dialog", { name: "Applying changes" }); + expect(screen.queryByRole("checkbox", { name: "Select row 1" })).toBeNull(); }); + }); - describe("and the user click on format", () => { - it("shows a confirmation dialog if all the devices are online", async () => { - const { user } = installerRender(); - const selection = screen.getByRole("checkbox", { name: "Select row 1" }); - await user.click(selection); - const button = screen.getByRole("button", { name: "Perform an action" }); - expect(button).not.toHaveAttribute("disabled"); - await user.click(button); - const format = screen.getByRole("menuitem", { name: "Format" }); - await user.click(format); - screen.getByRole("dialog", { name: "Format selected devices?" }); - }); + 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(); - it("shows a warning dialog if some device is offline", async () => { - const { user } = installerRender(); - let selection = screen.getByRole("checkbox", { name: "Select row 0" }); - await user.click(selection); - selection = screen.getByRole("checkbox", { name: "Select row 1" }); - await user.click(selection); - const button = screen.getByRole("button", { name: "Perform an action" }); - expect(button).not.toHaveAttribute("disabled"); - await user.click(button); - const format = screen.getByRole("menuitem", { name: "Format" }); - await user.click(format); - screen.getByRole("dialog", { name: "Cannot format all selected devices" }); + // 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("DASDTable/DASDTableEmptyState", () => { + describe("when there are no devices in the system", () => { + beforeEach(() => { + mockDASDDevices = []; + }); + + 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("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" }); + // }); + }); }); }); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index e0363814d6..1698851c5a 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -20,368 +20,563 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useEffect, useReducer } from "react"; import { Button, Content, - Divider, - Dropdown, - DropdownItem, - DropdownList, - List, - ListItem, - MenuToggle, - Stack, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, } from "@patternfly/react-core"; -import { Page, Popup } from "~/components/core"; -import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { Icon } from "~/components/layout"; -import { _ } from "~/i18n"; -import { hex } from "~/utils"; -import { sort } from "fast-sort"; +import Icon from "~/components/layout/Icon"; +import Popup from "~/components/core/Popup"; +import FormatActionHandler from "~/components/storage/dasd/FormatActionHandler"; +import FormatFilter from "~/components/storage/dasd/FormatFilter"; +import SelectableDataTable from "~/components/core/SelectableDataTable"; +import StatusFilter from "~/components/storage/dasd/StatusFilter"; +import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; import { DASDDevice } from "~/types/dasd"; -import { useDASDDevices, useDASDMutation, useFormatDASDMutation } from "~/queries/storage/dasd"; - -// FIXME: please, note that this file still requiring refinements until reach a -// reasonable stable version -const columnData = (device: DASDDevice, column: { id: string; sortId?: string; label: string }) => { - let data = device[column.id]; - - switch (column.id) { - case "formatted": - case "diag": - if (!device.enabled) data = ""; - break; - case "partitionInfo": - data = data.split(",").map((d: string) =>
{d}
); - break; +import type { SortedBy } from "~/components/core/SelectableDataTable"; +import { + DASDMutationFn, + DASDMutationFnProps, + useDASDDevices, + useDASDMutation, +} from "~/queries/storage/dasd"; +import { sort } from "fast-sort"; +import { isEmpty } from "radashi"; +import { hex } from "~/utils"; +import { _, n_ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { useInstallerClient } from "~/context/installer"; + +/** + * Filter options for narrowing down DASD devices shown in the table. + * + * All filters are optional and may be combined. + */ +export type DASDDevicesFilters = { + /** Lower bound for channel ID filtering (inclusive). */ + minChannel?: DASDDevice["id"]; + /** 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 + * "all" (all devices). */ + formatted?: "all" | "yes" | "no"; +}; + +/** + * Predicate function for evaluating whether a DASD device meets a given + * condition. + * + * Used internally to compose filter logic when narrowing down the list of + * devices shown in the DASD table. + */ +type DASDDeviceCondition = (device: DASDDevice) => boolean; + +/** + * Props required to generate bulk actions for selected DASD devices. + */ +type DASDActionsBuilderProps = { + /** The list of selected DASD devices. */ + devices: DASDDevice[]; + /** Mutation function used to trigger backend updates (e.g. enable, disable). */ + updater: DASDMutationFn; + /** State dispatcher for triggering actions */ + dispatcher: (props: DASDTableAction) => void; +}; + +/** + * Filters an array of devices based on given filters. + * + * @param devices - The array of DASDDevice objects to filter. + * @param filters - The filters to apply. + * @returns The filtered array of DASDDevice objects matching all conditions. + */ +const filterDevices = (devices: DASDDevice[], filters: DASDDevicesFilters): DASDDevice[] => { + const { minChannel, maxChannel, status, formatted } = filters; + + const conditions: DASDDeviceCondition[] = []; + + if (minChannel || maxChannel) { + const allChannels = devices.map((d) => d.hexId); + const min = hex(minChannel) || Math.min(...allChannels); + const max = hex(maxChannel) || Math.max(...allChannels); + + conditions.push((d) => d.hexId >= min && d.hexId <= max); + } + + if (status && status !== "all") { + conditions.push((d) => d.status === status); } - if (typeof data === "boolean") { - return data ? _("Yes") : _("No"); + if (formatted === "yes" || formatted === "no") { + conditions.push((d) => (formatted === "yes" ? d.formatted : !d.formatted)); } - return data; + return devices.filter((device) => conditions.every((conditionFn) => conditionFn(device))); }; -const columns = [ - { id: "id", sortId: "hexId", label: _("Channel ID") }, - { id: "status", label: _("Status") }, - { id: "deviceName", label: _("Device") }, - { id: "deviceType", label: _("Type") }, - // TRANSLATORS: table header, the column contains "Yes"/"No" values - // for the DIAG access mode (special disk access mode on IBM mainframes), - // usually keep untranslated - { id: "diag", label: _("DIAG") }, - { id: "formatted", label: _("Formatted") }, - { id: "partitionInfo", label: _("Partition Info") }, -]; -const DevicesList = ({ devices }) => ( - - {devices.map((d: DASDDevice) => ( - {d.id} - ))} - -); -const FormatNotPossible = ({ devices, onAccept }) => ( - - - - {_( - "Offline devices must be activated before formatting them. Please, unselect or activate the devices listed below and try it again", - )} - - - - - {_("Accept")} - - -); +/** + * Builds the list of available actions for given 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`. + */ +const buildActions = ({ devices, updater, dispatcher }: DASDActionsBuilderProps) => { + const ids = devices.map((d) => d.id); + return [ + { + title: _("Activate"), + onClick: () => updater({ action: "enable", devices: ids }), + }, + { + title: _("Deactivate"), + onClick: () => updater({ action: "disable", devices: ids }), + }, + { + isSeparator: true, + }, + { + title: _("Set DIAG on"), + onClick: () => updater({ action: "diagOn", devices: ids }), + }, + { + title: _("Set DIAG off"), + onClick: () => updater({ action: "diagOff", devices: ids }), + }, + { + isSeparator: true, + }, + { + title: _("Format"), + isDanger: true, + onClick: () => { + dispatcher({ type: "REQUEST_FORMAT", payload: devices }); + }, + }, + ]; +}; -const FormatConfirmation = ({ devices, onCancel, onConfirm }) => ( - - - - {_( - "This action could destroy any data stored on the devices listed below. Please, confirm that you really want to continue.", - )} - - - - - - - - +/** + * Props for the FiltersToolbar component used in the DASD table. + */ +type FiltersToolbarProps = { + /** Current filter state */ + filters: DASDDevicesFilters; + /** Callback invoked when a filter value changes. */ + onFilterChange: (filter: keyof DASDDevicesFilters, value: string | number) => void; +}; + +/** + * Renders the toolbar used to filter DASD devices. + */ +const FiltersToolbar = ({ filters, onFilterChange }: FiltersToolbarProps) => ( + + + + + onFilterChange("status", v)} /> + + + onFilterChange("formatted", v)} + /> + + + onFilterChange("minChannel", v)} + /> + + + onFilterChange("maxChannel", v)} + /> + + + + ); -const Actions = ({ devices, isDisabled }: { devices: DASDDevice[]; isDisabled: boolean }) => { - const { mutate: updateDASD } = useDASDMutation(); - const { mutate: formatDASD } = useFormatDASDMutation(); - const [isOpen, setIsOpen] = useState(false); - const [requestFormat, setRequestFormat] = useState(false); - - const onToggle = () => setIsOpen(!isOpen); - const onSelect = () => setIsOpen(false); - const cancelFormatRequest = () => setRequestFormat(false); - - const deviceIds = devices.map((d) => d.id); - const offlineDevices = devices.filter((d) => !d.enabled); - const offlineDevicesSelected = offlineDevices.length > 0; - const activate = () => updateDASD({ action: "enable", devices: deviceIds }); - const deactivate = () => updateDASD({ action: "disable", devices: deviceIds }); - const setDiagOn = () => updateDASD({ action: "diagOn", devices: deviceIds }); - const setDiagOff = () => updateDASD({ action: "diagOff", devices: deviceIds }); - const format = () => formatDASD(devices.map((d) => d.id)); - - const Action = ({ children, ...props }) => ( - - {children} - +/** + * 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`. + */ +const BulkActionsToolbar = ({ devices, updater, dispatcher }: DASDActionsBuilderProps) => { + const applyText = sprintf( + n_( + // TRANSLATORS: message shown in bulk action toolbar when just one device + // is selected + "Apply to 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", + devices.length, + ), + devices.length, ); return ( - <> - {requestFormat && offlineDevicesSelected && ( - - )} - - {requestFormat && !offlineDevicesSelected && ( - { - cancelFormatRequest(); - format(); - }} - /> - )} - ( - - {/* TRANSLATORS: drop down menu label */} - {_("Perform an action")} - - )} - > - - {/** TRANSLATORS: drop down menu action, activate the device */} - - {_("Activate")} - - {/** TRANSLATORS: drop down menu action, deactivate the device */} - - {_("Deactivate")} - - - {/** TRANSLATORS: drop down menu action, enable DIAG access method */} - - {_("Set DIAG On")} - - {/** TRANSLATORS: drop down menu action, disable DIAG access method */} - - {_("Set DIAG Off")} - - - {/** TRANSLATORS: drop down menu action, format the disk */} - setRequestFormat(true)}> - {_("Format")} - - - - + + + + {devices.length ? ( + <> + {applyText}{" "} + {buildActions({ devices, updater, dispatcher }) + .filter((a) => !a.isSeparator) + .map(({ onClick, title }, i) => ( + + + + ))} + + ) : ( + _("Select devices to enable bulk actions.") + )} + + + ); }; -const filterDevices = (devices: DASDDevice[], from: string, to: string): DASDDevice[] => { - const allChannels = devices.map((d) => d.hexId); - const min = hex(from) || Math.min(...allChannels); - const max = hex(to) || Math.max(...allChannels); +/** + * 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.")} + + + + + + + ); + } + } +}; - return devices.filter((d) => d.hexId >= min && d.hexId <= max); +/** + * Encapsulates all state used by the DASD table component, including filters, + * sorting configuration, current selection, and devices to be format. + */ +type DASDTableState = { + /** Current sorting state */ + sortedBy: SortedBy; + /** Current active filters applied to the device list */ + filters: DASDDevicesFilters; + /** Currently selected devices in the UI */ + selectedDevices: DASDDevice[]; + /** Devices selected for formatting */ + devicesToFormat: DASDDevice[]; + /** Device IDs currently undergoing an async operation */ + waitingFor: DASDDevice["id"][]; }; -type FilterOptions = { - minChannel?: string; - maxChannel?: string; +/** + * Defines the initial state used by the DASD table reducer. + */ +const initialState: DASDTableState = { + sortedBy: { index: 0, direction: "asc" }, + filters: { + status: "all", + formatted: "all", + minChannel: "", + maxChannel: "", + }, + selectedDevices: [], + devicesToFormat: [], + waitingFor: [], }; -type SelectionOptions = { - unselect?: boolean; - device?: DASDDevice; - devices?: DASDDevice[]; + +/** + * Action types for updating the DASD table state via the reducer. + */ +type DASDTableAction = + | { type: "UPDATE_SORTING"; payload: DASDTableState["sortedBy"] } + | { type: "UPDATE_FILTERS"; payload: DASDTableState["filters"] } + | { type: "RESET_FILTERS" } + | { type: "UPDATE_SELECTION"; payload: DASDTableState["selectedDevices"] } + | { type: "RESET_SELECTION" } + | { type: "REQUEST_FORMAT"; payload: DASDTableState["devicesToFormat"] } + | { type: "CANCEL_FORMAT_REQUEST" } + | { type: "START_WAITING"; payload: DASDDevice["id"][] } + | { type: "UPDATE_WAITING"; payload: DASDDevice["id"] }; + +/** + * Reducer function that handles all DASD table state transitions. + */ +const reducer = (state: DASDTableState, action: DASDTableAction): DASDTableState => { + switch (action.type) { + case "UPDATE_SORTING": { + return { ...state, sortedBy: action.payload }; + } + + case "UPDATE_FILTERS": { + return { ...state, filters: { ...state.filters, ...action.payload } }; + } + + case "RESET_FILTERS": { + return { ...state, filters: initialState.filters }; + } + + case "UPDATE_SELECTION": { + return { ...state, selectedDevices: action.payload }; + } + + case "RESET_SELECTION": { + return { ...state, selectedDevices: initialState.selectedDevices }; + } + + case "REQUEST_FORMAT": { + return { ...state, devicesToFormat: action.payload }; + } + + case "CANCEL_FORMAT_REQUEST": { + 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 }; + } + } }; +/** + * 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. + */ +const columns = [ + { + // TRANSLATORS: table header for a DASD devices table + name: _("Channel ID"), + value: (d: DASDDevice) => d.id, + 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", + }, + { + // 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 ""; + + 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", + }, +]; + export default function DASDTable() { + const client = useInstallerClient(); const devices = useDASDDevices(); - const [selectedDASD, setSelectedDASD] = useState([]); - const [{ minChannel, maxChannel }, setFilters] = useState({ - minChannel: "", - maxChannel: "", - }); - - const [sortingColumn, setSortingColumn] = useState(columns[0]); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - - const sortColumnIndex = () => columns.findIndex((c) => c.id === sortingColumn.id); - const filteredDevices = filterDevices(devices, minChannel, maxChannel); - const selectedDevicesIds = selectedDASD.map((d) => d.id); - - const changeSelected = (newSelection: SelectionOptions) => { - setSelectedDASD((prevSelection) => { - if (newSelection.unselect) { - if (newSelection.device) - return prevSelection.filter((d) => d.id !== newSelection.device.id); - if (newSelection.devices) return []; - } else { - if (newSelection.device) return [...prevSelection, newSelection.device]; - if (newSelection.devices) return newSelection.devices; + const { mutate: updateDASD } = useDASDMutation(); + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "DASDDeviceChanged") { + dispatch({ type: "UPDATE_WAITING", payload: event.device.id }); } }); + }, [client, dispatch]); + + const onSortingChange = (sortedBy: SortedBy) => { + dispatch({ type: "UPDATE_SORTING", payload: sortedBy }); }; - // Selecting - const selectAll = (isSelecting = true) => { - changeSelected({ unselect: !isSelecting, devices: filteredDevices }); + const onFilterChange = (filter: keyof DASDDevicesFilters, value) => { + dispatch({ type: "UPDATE_FILTERS", payload: { [filter]: value } }); + dispatch({ type: "RESET_SELECTION" }); }; - const selectDevice = (device, isSelecting = true) => { - changeSelected({ unselect: !isSelecting, device }); + const onSelectionChange = (devices: DASDDevice[]) => { + dispatch({ type: "UPDATE_SELECTION", payload: devices }); }; + const resetFilters = () => dispatch({ type: "RESET_FILTERS" }); + + // Filtering + const filteredDevices = filterDevices(devices, state.filters); + // Sorting // See https://github.com/snovakovic/fast-sort - const sortBy = sortingColumn.sortId || sortingColumn.id; - const sortedDevices = sort(filteredDevices)[sortDirection]((d) => d[sortBy]); - - // FIXME: this can be improved and even extracted to be used with other tables. - const getSortParams = (columnIndex) => { - return { - sortBy: { index: sortColumnIndex(), direction: sortDirection }, - onSort: (_event, index, direction) => { - setSortingColumn(columns[index]); - setSortDirection(direction); - }, - columnIndex, - }; - }; - - const updateFilter = (newFilters: FilterOptions) => { - setFilters((currentFilters) => ({ ...currentFilters, ...newFilters })); - }; + const sortedDevices = sort(filteredDevices)[state.sortedBy.direction]( + (d) => d[columns[state.sortedBy.index].sortingKey], + ); - const PageContent = () => { - return ( - - - - - ))} - - - - {sortedDevices.map((device, rowIndex) => ( - - - ))} - - ))} - -
selectAll(isSelecting), - isSelected: filteredDevices.length === selectedDASD.length, - }} - /> - {columns.map((column, index) => ( - - {column.label} -
selectDevice(device, isSelecting), - isSelected: selectedDevicesIds.includes(device.id), - isDisabled: false, - }} - /> - {columns.map((column) => ( - - {columnData(device, column)} -
- ); + // 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 = (mutation: DASDMutationFnProps) => { + updateDASD(mutation); + dispatch({ type: "START_WAITING", payload: mutation.devices }); }; return ( - <> - - - - - - updateFilter({ minChannel })} - /> - {minChannel !== "" && ( - -