-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(images): Re-use Site Manager Image design MAASENG-4200 (#5574)
- 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
1 parent
c628849
commit d25a45b
Showing
38 changed files
with
2,797 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
src/app/base/components/GenericTable/GenericTable.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
33 changes: 33 additions & 0 deletions
33
src/app/base/components/GenericTable/GroupRowActions/GroupRowActions.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); |
28 changes: 28 additions & 0 deletions
28
src/app/base/components/GenericTable/GroupRowActions/GroupRowActions.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from "./GroupRowActions"; |
14 changes: 14 additions & 0 deletions
14
src/app/base/components/GenericTable/SortingIndicator/SortingIndicator.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from "./SortingIndicator"; |
Oops, something went wrong.