Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions web/package/agama-web-ui.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Tue Sep 2 08:24:38 UTC 2025 - David Diaz <dgonzalez@suse.com>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you were cleaning surplus spaces. Great!
But I was actually expecting two spaces between "Sep" and "2" (I think that's the default in this format for days with just one digit). But I don't think that really matters.

Copy link
Copy Markdown
Contributor Author

@dgdavid dgdavid Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I was actually expecting two spaces between "Sep" and "2"

Me too.

Originally I "kept" it. In fact, I added it because I solved the conflict manually using the Github web interface... but CI complained about wrong date and the only thing I found the extra space. I can add it back, though.


- 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 <igonzalezsosa@suse.com>

Expand All @@ -12,7 +18,7 @@ Thu Aug 28 05:26:38 UTC 2025 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>
-------------------------------------------------------------------
Wed Aug 27 08:39:36 UTC 2025 - Knut Anderssen <kanderssen@suse.com>

- Invalidate zFCP controllers query after some controller change
- Invalidate zFCP controllers query after some controller change
is notified (bsc#1247445).

-------------------------------------------------------------------
Expand All @@ -26,7 +32,7 @@ Wed Aug 20 10:27:07 UTC 2025 - David Diaz <dgonzalez@suse.com>

- 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 <ancor@suse.com>

Expand Down Expand Up @@ -56,7 +62,7 @@ Thu Jul 31 09:13:09 UTC 2025 - David Diaz <dgonzalez@suse.com>

- 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 <dgonzalez@suse.com>

Expand Down Expand Up @@ -92,7 +98,7 @@ Thu Jul 17 19:26:17 UTC 2025 - David Diaz <dgonzalez@suse.com>
- 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 <jreidinger@suse.com>

Expand Down
46 changes: 45 additions & 1 deletion web/src/components/storage/DeviceSelectorModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
<DeviceSelectorModal
devices={[sda, sdb]}
title="Select a device"
onCancel={onCancelMock}
onConfirm={onConfirmMock}
/>,
);

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(
<DeviceSelectorModal
devices={[sda, sdb]}
title="Select a device"
onCancel={onCancelMock}
onConfirm={onConfirmMock}
/>,
);

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(
<DeviceSelectorModal
Expand Down
40 changes: 29 additions & 11 deletions web/src/components/storage/DeviceSelectorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@
*/

import React, { useState } from "react";
import { SelectableDataTable, Popup } from "~/components/core/";
import { ButtonProps, Flex, Label } from "@patternfly/react-core";
import Popup, { PopupProps } from "~/components/core/Popup";
import SelectableDataTable, {
SortedBy,
SelectableDataTableProps,
} from "~/components/core/SelectableDataTable";
import { StorageDevice } from "~/types/storage";
import { SelectableDataTableProps } from "../core/SelectableDataTable";
import {
typeDescription,
contentDescription,
filesystemLabels,
} from "~/components/storage/utils/device";
import { deviceSize } from "~/components/storage/utils";
import { sortCollection } from "~/utils";
import { _ } from "~/i18n";
import { PopupProps } from "../core/Popup";
import { ButtonProps, Flex, Label } from "@patternfly/react-core";

type DeviceSelectorProps = {
devices: StorageDevice[];
Expand Down Expand Up @@ -76,20 +79,35 @@ const DeviceSelector = ({
onSelectionChange,
selectionMode = "single",
}: DeviceSelectorProps) => {
const [sortedBy, setSortedBy] = useState<SortedBy>({ 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 (
<>
<SelectableDataTable
columns={[
{ name: _("Device"), value: (device: StorageDevice) => 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}
/>
</>
);
Expand Down
14 changes: 5 additions & 9 deletions web/src/components/storage/dasd/DASDTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion web/src/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 `<table>` 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,
Expand All @@ -228,4 +251,5 @@ export {
mockRoutes,
mockUseRevalidator,
resetLocalStorage,
getColumnValues,
};
83 changes: 82 additions & 1 deletion web/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading