Skip to content

Commit

Permalink
feat(images): Re-use Site Manager Image design MAASENG-4200 (#5574)
Browse files Browse the repository at this point in the history
- Added `@tanstack/react-table` to dependencies
- Created a generic table component wrapping `maas-react-components/DynamicTable` in the visual style of Site Manager's grouping image table (`GenericTable`)
- Added table action components to be used in GenericTable.tsx columns (`GroupRowActions`, `SortingIndicator`, `TableCheckbox`)
- Created `DeleteImages` to support multiple image deletion
- Created `DownloadImages` to replace the update selection form with a side panel form in the style of Site Manager, with accompanying `DownloadImagesSelect`
- Added `DELETE_MULTIPLE_IMAGES` and `DOWNLOAD_IMAGE` to `ImageSidePanelViews` as actions in `ImageForms`
- Created `ImagesTableHeader` to display the Delete, Stop import/Download images, Change source action buttons 
- Created `SMImagesTable` (wrapper component for `GenericTable`), displaying the grouped images table with custom rows
- Created `useImageTableColumns` to provide the column definitions for `SMImagesTable`
- Created `Image` type

Resolves [MAASENG-4215](https://warthogs.atlassian.net/browse/MAASENG-4215)
Resolves [MAASENG-4200](https://warthogs.atlassian.net/browse/MAASENG-4200)
  • Loading branch information
abuyukyi101198 authored Jan 13, 2025
1 parent c628849 commit d25a45b
Show file tree
Hide file tree
Showing 38 changed files with 2,797 additions and 36 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@sentry/browser": "5.30.0",
"@tanstack/react-table": "8.20.6",
"@vitejs/plugin-react-swc": "3.6.0",
"classnames": "2.5.1",
"clone-deep": "4.0.1",
Expand Down
128 changes: 128 additions & 0 deletions src/app/base/components/GenericTable/GenericTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { vi } from "vitest";

import GenericTable from "./GenericTable";

import type { Image } from "@/app/images/types";
import type { UtcDatetime } from "@/app/store/types/model";
import * as factory from "@/testing/factories";
import { userEvent, screen, render } from "@/testing/utils";

describe("GenericTable", () => {
const columns = [
{
id: "release",
accessorKey: "release",
enableSorting: true,
header: () => "Release title",
},
{
id: "architecture",
accessorKey: "architecture",
enableSorting: false,
header: () => "Architecture",
},
{
id: "size",
accessorKey: "size",
enableSorting: false,
header: () => "Size",
},
];
const data: Image[] = [
{
id: 0,
release: "16.04 LTS",
architecture: "amd64",
name: "Ubuntu",
size: "1.3 MB",
lastSynced: "Mon, 06 Jan. 2025 10:45:24",
canDeployToMemory: true,
status: "Synced",
lastDeployed: "Thu, 15 Aug. 2019 06:21:39" as UtcDatetime,
machines: 2,
resource: factory.bootResource({
name: "ubuntu/xenial",
arch: "amd64",
title: "16.04 LTS",
}),
},
{
id: 1,
release: "18.04 LTS",
architecture: "arm64",
name: "Ubuntu",
size: "1.3 MB",
lastSynced: "Mon, 06 Jan. 2025 10:45:24",
canDeployToMemory: true,
status: "Synced",
lastDeployed: "Thu, 15 Aug. 2019 06:21:39" as UtcDatetime,
machines: 2,
resource: factory.bootResource({
name: "ubuntu/bionic",
arch: "amd64",
title: "18.04 LTS",
}),
},
];

const mockFilterCells = vi.fn(() => true);
const mockFilterHeaders = vi.fn(() => true);
const mockGetRowId = vi.fn((row) => row.id.toString());

it("renders table with headers and rows", () => {
render(
<GenericTable
columns={columns}
data={data}
filterCells={mockFilterCells}
filterHeaders={mockFilterHeaders}
getRowId={mockGetRowId}
rowSelection={{}}
setRowSelection={vi.fn}
/>
);

expect(screen.getByText("Release title")).toBeInTheDocument();
expect(screen.getByText("Architecture")).toBeInTheDocument();

expect(screen.getByText("16.04 LTS")).toBeInTheDocument();
expect(screen.getByText("18.04 LTS")).toBeInTheDocument();
});

it('displays "No data" when the data array is empty', () => {
render(
<GenericTable
columns={columns}
data={[]}
filterCells={mockFilterCells}
filterHeaders={mockFilterHeaders}
getRowId={mockGetRowId}
noData={<span>No data</span>}
rowSelection={{}}
setRowSelection={vi.fn}
/>
);

expect(screen.getByText("No data")).toBeInTheDocument();
});

it("applies sorting when a sortable header is clicked", () => {
render(
<GenericTable
columns={columns}
data={data}
filterCells={mockFilterCells}
filterHeaders={mockFilterHeaders}
getRowId={mockGetRowId}
rowSelection={{}}
setRowSelection={vi.fn}
/>
);

userEvent.click(screen.getByText("Release title"));

const rows = screen.getAllByRole("row");
expect(rows[1]).toHaveTextContent("16.04 LTS");
expect(rows[2]).toHaveTextContent("18.04 LTS");
});
});
179 changes: 179 additions & 0 deletions src/app/base/components/GenericTable/GenericTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useMemo, useState } from "react";

import { DynamicTable } from "@canonical/maas-react-components";
import { Button } from "@canonical/react-components";
import type {
Column,
ColumnDef,
ColumnSort,
ExpandedState,
GroupingState,
Header,
Row,
RowSelectionState,
SortingState,
} from "@tanstack/react-table";
import {
getCoreRowModel,
getExpandedRowModel,
getGroupedRowModel,
useReactTable,
flexRender,
} from "@tanstack/react-table";
import classNames from "classnames";

import "./_index.scss";
import SortingIndicator from "./SortingIndicator";

type GenericTableProps<T> = {
ariaLabel?: string;
columns: ColumnDef<T, Partial<T>>[];
data: T[];
filterCells: (row: Row<T>, column: Column<T>) => boolean;
filterHeaders: (header: Header<T, unknown>) => boolean;
getRowId: (
originalRow: T,
index: number,
parent?: Row<T> | undefined
) => string;
groupBy?: string[];
noData?: ReactNode;
sortBy?: ColumnSort[];
rowSelection: RowSelectionState;
setRowSelection?: Dispatch<SetStateAction<RowSelectionState>>;
};

const GenericTable = <T,>({
ariaLabel,
columns,
data,
filterCells,
filterHeaders,
getRowId,
groupBy,
sortBy,
noData,
rowSelection,
setRowSelection,
}: GenericTableProps<T>) => {
const [grouping, setGrouping] = useState<GroupingState>(groupBy ?? []);
const [expanded, setExpanded] = useState<ExpandedState>(true);
const [sorting, setSorting] = useState<SortingState>(sortBy ?? []);

const sortedData = useMemo(() => {
return [...data].sort((a, b) => {
for (const { id, desc } of sorting) {
const aValue = a[id as keyof typeof a];
const bValue = b[id as keyof typeof b];
if (aValue < bValue) {
return desc ? 1 : -1;
}
if (aValue > bValue) {
return desc ? -1 : 1;
}
}
return 0;
});
}, [data, sorting]);

const table = useReactTable<T>({
data: sortedData,
columns,
state: {
grouping,
expanded,
sorting,
rowSelection,
},
manualPagination: true,
autoResetExpanded: false,
onExpandedChange: setExpanded,
onSortingChange: setSorting,
onGroupingChange: setGrouping,
onRowSelectionChange: setRowSelection,
manualSorting: true,
enableSorting: true,
enableExpanding: true,
getExpandedRowModel: getExpandedRowModel(),
getCoreRowModel: getCoreRowModel(),
getGroupedRowModel: getGroupedRowModel(),
groupedColumnMode: false,
enableRowSelection: true,
enableMultiRowSelection: true,
getRowId,
});

return (
<DynamicTable
aria-label={ariaLabel}
className="p-table-dynamic--with-select generic-table"
variant={"full-height"}
>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.filter(filterHeaders).map((header) => (
<th className={classNames(`${header.column.id}`)} key={header.id}>
{header.column.getCanSort() ? (
<Button
appearance="link"
className="p-button--table-header"
onClick={header.column.getToggleSortingHandler()}
type="button"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
<SortingIndicator header={header} />
</Button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
</th>
))}
</tr>
))}
</thead>
{table.getRowModel().rows.length < 1 ? (
noData
) : (
<DynamicTable.Body>
{table.getRowModel().rows.map((row) => {
const { getIsGrouped, id, index, getVisibleCells } = row;
const isIndividualRow = !getIsGrouped();
return (
<tr
className={classNames({
"individual-row": isIndividualRow,
"group-row": !isIndividualRow,
})}
key={id + index}
>
{getVisibleCells()
.filter((cell) => filterCells(row, cell.column))
.map((cell) => {
const { column, id: cellId } = cell;
return (
<td
className={classNames(`${cell.column.id}`)}
key={cellId}
>
{flexRender(column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</DynamicTable.Body>
)}
</DynamicTable>
);
};

export default GenericTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import GroupRowActions from "./GroupRowActions";

import { render, userEvent, screen } from "@/testing/utils";

const getMockRow = (isExpanded: boolean) => {
return {
getIsExpanded: vi.fn(() => isExpanded),
toggleExpanded: vi.fn(),
};
};

it("calls toggleExpanded when button is clicked", async () => {
const mockRow = getMockRow(true);
// @ts-ignore
render(<GroupRowActions row={mockRow} />);

await userEvent.click(screen.getByRole("button"));
expect(mockRow.toggleExpanded).toHaveBeenCalled();
});

it('displays "Collapse" when expanded', () => {
const mockRow = getMockRow(true);
// @ts-ignore
render(<GroupRowActions row={mockRow} />);
expect(screen.getByText("Collapse")).toBeInTheDocument();
});

it('displays "Expand" when not expanded', () => {
const mockRow = getMockRow(false);
// @ts-ignore
render(<GroupRowActions row={mockRow} />);
expect(screen.getByText("Expand")).toBeInTheDocument();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Button, Icon } from "@canonical/react-components";
import type { Row } from "@tanstack/react-table";

type GroupRowActionsProps<T> = {
row: Row<T>;
};

const GroupRowActions = <T,>({ row }: GroupRowActionsProps<T>) => {
return (
<Button
appearance="base"
dense
hasIcon
onClick={() => {
row.toggleExpanded();
}}
type="button"
>
{row.getIsExpanded() ? (
<Icon name="minus">Collapse</Icon>
) : (
<Icon name="plus">Expand</Icon>
)}
</Button>
);
};

export default GroupRowActions;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./GroupRowActions";
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Icon } from "@canonical/react-components";
import type { Header } from "@tanstack/react-table";

type SortingIndicatorProps<T> = {
header: Header<T, unknown>;
};

const SortingIndicator = <T,>({ header }: SortingIndicatorProps<T>) =>
({
asc: <Icon name={"chevron-up"}>ascending</Icon>,
desc: <Icon name={"chevron-down"}>descending</Icon>,
})[header?.column?.getIsSorted() as string] ?? null;

export default SortingIndicator;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./SortingIndicator";
Loading

0 comments on commit d25a45b

Please sign in to comment.