diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 339ca87176..bafb96624e 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Tue Sep 2 08:24:38 UTC 2025 - David Diaz + +- Allow sorting devices by name or size in storage selector + (gh#agama-project/agama#2672). + ------------------------------------------------------------------- Mon Sep 1 18:22:38 UTC 2025 - Imobach Gonzalez Sosa @@ -12,7 +18,7 @@ Thu Aug 28 05:26:38 UTC 2025 - Imobach Gonzalez Sosa ------------------------------------------------------------------- Wed Aug 27 08:39:36 UTC 2025 - Knut Anderssen -- Invalidate zFCP controllers query after some controller change +- Invalidate zFCP controllers query after some controller change is notified (bsc#1247445). ------------------------------------------------------------------- @@ -26,7 +32,7 @@ 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 @@ -56,7 +62,7 @@ Thu Jul 31 09:13:09 UTC 2025 - David Diaz - Block UI if storage is configured by any other client (gh#agama-project/agama#2640). - + ------------------------------------------------------------------- Fri Jul 25 19:42:18 UTC 2025 - David Diaz @@ -92,7 +98,7 @@ Thu Jul 17 19:26:17 UTC 2025 - David Diaz - Replaced all usage of generatePath with generateEncodedPath to ensure proper URL encoding of route parameters and prevent issues with special characters (bsc#1246551). - + ------------------------------------------------------------------- Thu Jul 17 15:32:42 UTC 2025 - Josef Reidinger diff --git a/web/src/components/storage/DeviceSelectorModal.test.tsx b/web/src/components/storage/DeviceSelectorModal.test.tsx index 6b18d36dfd..8ab4315c48 100644 --- a/web/src/components/storage/DeviceSelectorModal.test.tsx +++ b/web/src/components/storage/DeviceSelectorModal.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { getColumnValues, plainRender } from "~/test-utils"; import { StorageDevice } from "~/types/storage"; import DeviceSelectorModal from "./DeviceSelectorModal"; @@ -105,6 +105,50 @@ describe("DeviceSelectorModal", () => { it.todo("renders type, name, content, and filesystems of each device"); it.todo("renders corresponding control (radio or checkbox) as checked for given selected device"); + it("allows sorting by device name", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sortByDeviceButton = within(table).getByRole("button", { name: "Device" }); + + expect(getColumnValues(table, "Device")).toEqual(["/dev/sda", "/dev/sdb"]); + + await user.click(sortByDeviceButton); + + expect(getColumnValues(table, "Device")).toEqual(["/dev/sdb", "/dev/sda"]); + }); + + it("allows sorting by device size", async () => { + const { user } = plainRender( + , + ); + + const table = screen.getByRole("grid"); + const sortBySizeButton = within(table).getByRole("button", { name: "Size" }); + + // By default, table is sorted by device name. Switch sorting to size in asc direction + await user.click(sortBySizeButton); + + expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]); + + // Now keep sorting by size, but in desc direction + await user.click(sortBySizeButton); + + expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]); + }); + it("triggers onCancel callback when users selects `Cancel` action", async () => { const { user } = plainRender( { + const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" }); + + const columns = [ + { name: _("Device"), value: (device: StorageDevice) => device.name, sortingKey: "name" }, + { + name: _("Size"), + value: size, + sortingKey: "size", + pfTdProps: { style: { width: "10ch" } }, + }, + { name: _("Description"), value: description }, + { name: _("Current content"), value: details }, + ]; + + // Sorting + const sortingKey = columns[sortedBy.index].sortingKey; + const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey); + return ( <> device.name }, - { name: _("Size"), value: size, pfTdProps: { style: { width: "10ch" } } }, - { name: _("Description"), value: description }, - { name: _("Current content"), value: details }, - ]} - items={devices} + columns={columns} + items={sortedDevices} itemIdKey="sid" itemsSelected={selectedDevices} onSelectionChange={onSelectionChange} selectionMode={selectionMode} + sortedBy={sortedBy} + updateSorting={setSortedBy} /> ); diff --git a/web/src/components/storage/dasd/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx index 1698851c5a..0af1423999 100644 --- a/web/src/components/storage/dasd/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -37,23 +37,21 @@ 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 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 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"; +import { hex, sortCollection } from "~/utils"; +import { _, n_ } from "~/i18n"; /** * Filter options for narrowing down DASD devices shown in the table. @@ -512,10 +510,8 @@ export default function DASDTable() { const filteredDevices = filterDevices(devices, state.filters); // Sorting - // See https://github.com/snovakovic/fast-sort - const sortedDevices = sort(filteredDevices)[state.sortedBy.direction]( - (d) => d[columns[state.sortedBy.index].sortingKey], - ); + 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; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index bd4fb144e3..ab1204e53a 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -32,7 +32,7 @@ import React from "react"; import { MemoryRouter, useParams } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; -import { render } from "@testing-library/react"; +import { render, within } from "@testing-library/react"; import { createClient } from "~/client/index"; import { InstallerClientProvider } from "~/context/installer"; import { InstallerL10nProvider } from "~/context/installerL10n"; @@ -219,6 +219,29 @@ const resetLocalStorage = (initialState?: { [key: string]: string }) => { }); }; +/** + * Extracts all cell values from a specific column in an HTML table, + * based on the column's `data-label` attribute. + * + * Skips the header row and returns trimmed text content for each matching cell. + * + * @param table - The `` element to extract data from. + * @param columnName - The value of the `data-label` attribute identifying the column (e.g., "Device", "Size"). + * @returns An array of strings containing the text content of each cell in the specified column. + * + * @example + * ```ts + * const table = screen.getByRole("table"); + * const deviceNames = getColumnValues(table, "Device"); + * expect(deviceNames).toEqual(["/dev/sda", "/dev/sdb", "/dev/sdc"]); + * ``` + */ +const getColumnValues = (table: HTMLElement | HTMLTableElement, columnName: string) => + within(table) + .getAllByRole("row") + .slice(1) // Skip header + .map((row) => row.querySelector(`[data-label="${columnName}"]`)?.textContent?.trim()); + export { plainRender, installerRender, @@ -228,4 +251,5 @@ export { mockRoutes, mockUseRevalidator, resetLocalStorage, + getColumnValues, }; diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 6d3daeacc2..f3e7730a4f 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,15 @@ * find current contact information at www.suse.com. */ -import { compact, localConnection, hex, mask, timezoneTime, generateEncodedPath } from "./utils"; +import { + compact, + localConnection, + hex, + mask, + timezoneTime, + generateEncodedPath, + sortCollection, +} from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -231,3 +239,76 @@ describe("generateEncodedPath", () => { expect(() => generateEncodedPath(path, {})).toThrow(); }); }); + +describe("simpleFastSort", () => { + const fakeDevices = [ + { sid: 100, name: "/dev/sdz", size: 5 }, + { sid: 2, name: "/dev/sdb", size: 10 }, + { sid: 3, name: "/dev/sdc", size: 2 }, + { sid: 10, name: "/dev/sda", size: 5 }, + ]; + + it("sorts by a string key in ascending order", () => { + expect(sortCollection(fakeDevices, "asc", "size")).toEqual([ + { sid: 3, name: "/dev/sdc", size: 2 }, + { sid: 100, name: "/dev/sdz", size: 5 }, + { sid: 10, name: "/dev/sda", size: 5 }, + { sid: 2, name: "/dev/sdb", size: 10 }, + ]); + }); + + it("sorts by a string key in descending order", () => { + expect(sortCollection(fakeDevices, "desc", "size")).toEqual([ + { sid: 2, name: "/dev/sdb", size: 10 }, + { sid: 100, name: "/dev/sdz", size: 5 }, + { sid: 10, name: "/dev/sda", size: 5 }, + { sid: 3, name: "/dev/sdc", size: 2 }, + ]); + }); + + it("sorts by ISortBy functions in ascending order", () => { + const sortingFunctions = [(d) => d.size, (d) => d.name]; + + expect(sortCollection(fakeDevices, "asc", sortingFunctions)).toEqual([ + { sid: 3, name: "/dev/sdc", size: 2 }, + { sid: 10, name: "/dev/sda", size: 5 }, + { sid: 100, name: "/dev/sdz", size: 5 }, + { sid: 2, name: "/dev/sdb", size: 10 }, + ]); + }); + + it("sorts by ISortBy functions in descending order", () => { + const sortingFunctions = [(d) => d.size, (d) => d.name]; + + expect(sortCollection(fakeDevices, "desc", sortingFunctions)).toEqual([ + { sid: 2, name: "/dev/sdb", size: 10 }, + { sid: 100, name: "/dev/sdz", size: 5 }, + { sid: 10, name: "/dev/sda", size: 5 }, + { sid: 3, name: "/dev/sdc", size: 2 }, + ]); + }); + + it("sorts by ISortBy function for a computed value in ascending order", () => { + expect(sortCollection(fakeDevices, "asc", (d) => d.sid + d.size)).toEqual([ + { sid: 3, name: "/dev/sdc", size: 2 }, // 5 + { sid: 2, name: "/dev/sdb", size: 10 }, // 12 + { sid: 10, name: "/dev/sda", size: 5 }, // 15 + { sid: 100, name: "/dev/sdz", size: 5 }, // 105 + ]); + }); + + it("sorts by ISortBy function for a computed value in descending order", () => { + expect(sortCollection(fakeDevices, "desc", (d) => d.sid + d.size)).toEqual([ + { sid: 100, name: "/dev/sdz", size: 5 }, // 105 + { sid: 10, name: "/dev/sda", size: 5 }, // 15 + { sid: 2, name: "/dev/sdb", size: 10 }, // 12 + { sid: 3, name: "/dev/sdc", size: 2 }, // 5 + ]); + }); + + it("does not mutate the original array", () => { + const original = [...fakeDevices]; + sortCollection(fakeDevices, "asc", "size"); + expect(fakeDevices).toEqual(original); + }); +}); diff --git a/web/src/utils.ts b/web/src/utils.ts index 54498fa1b0..3b0865998e 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -22,6 +22,7 @@ import { mapEntries } from "radashi"; import { generatePath } from "react-router-dom"; +import { ISortBy, sort } from "fast-sort"; /** * Generates a new array without null and undefined values. @@ -178,6 +179,32 @@ const generateEncodedPath = (...args: Parameters) => { ); }; +/** + * A lightweight wrapper around `fast-sort`. + * + * Rather than using `fast-sort`'s method-chaining syntax, this function accepts + * the sort direction (`"asc"` or `"desc"`) as a direct argument, resulting in a + * cleaner and more declarative API for Agama components, where sorting is often + * built dynamically. + * + * @example + * ```ts + * sortCollection(devices, 'asc', size'); + * sortCollection(devices, 'desc', d => d.sid + d.size); + * sortCollection(devices, 'asc', [d => d.size, d => d.name]); + * ``` + * @param collection - The array of items to be sorted. + * @param direction - The direction of the sort. Use "asc" for ascending or + * "desc" for descending. + * @param key - The key (as a string) to sort by, or a custom function + * compatible with `fast-sort`'s ISortBy. + * + * @returns A new array sorted based on the given key and direction. + * + */ +const sortCollection = (collection: T[], direction: "asc" | "desc", key: string | ISortBy) => + sort(collection)[direction](key as ISortBy); + export { compact, hex, @@ -187,4 +214,5 @@ export { timezoneTime, mask, generateEncodedPath, + sortCollection, };