feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955)

This commit is contained in:
nickl-portainer
2026-03-02 09:12:13 +13:00
committed by GitHub
parent 1f5762b8c8
commit 2ad0a65613
13 changed files with 423 additions and 100 deletions
+2 -6
View File
@@ -1,14 +1,10 @@
import '@tanstack/react-table';
declare module '@tanstack/table-core' {
declare module '@tanstack/react-table' {
interface ColumnMeta<TData extends RowData, TValue> {
className?: string;
filter?: Filter<TData, TValue>;
width?: number | 'auto' | string;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
table?: string;
minWidth?: string;
}
}
@@ -1,27 +0,0 @@
.actions {
float: right;
margin: 5px 10px 0 0;
}
.actions-active {
color: var(--blue-2);
}
.table-actions-menu-list {
background: var(--bg-widget-color);
border: 1px solid var(--border-color);
}
.table-actions-menu-list [data-reach-menu-item] {
padding: 5px 15px;
}
.table-actions-menu-btn {
border: none;
background: none;
padding: 0 10px;
}
[data-reach-menu-link] {
text-decoration: none !important;
}
@@ -1,34 +0,0 @@
import { ReactNode } from 'react';
import clsx from 'clsx';
import { Menu, MenuList, MenuButton } from '@reach/menu-button';
import { MoreVertical } from 'lucide-react';
import { Icon } from '@@/Icon';
import styles from './ActionsMenu.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenu({ children }: Props) {
return (
<Menu className={styles.actions}>
{({ isExpanded }) => (
<>
<MenuButton
className={clsx(
styles.tableActionsMenuBtn,
isExpanded && styles.actionsActive
)}
>
<Icon icon={MoreVertical} />
</MenuButton>
<MenuList>
<div className={styles.tableActionsMenuList}>{children}</div>
</MenuList>
</>
)}
</Menu>
);
}
@@ -1,4 +0,0 @@
.table-actions-title {
color: var(--blue-2);
padding: 5px 10px;
}
@@ -1,11 +0,0 @@
import { ReactNode } from 'react';
import styles from './ActionsMenuTitle.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenuTitle({ children }: Props) {
return <div className={styles.tableActionsTitle}>{children}</div>;
}
@@ -0,0 +1,68 @@
import { Meta, StoryFn } from '@storybook/react';
import { Clock } from 'lucide-react';
import { createColumnHelper, TableOptions } from '@tanstack/react-table';
import { BasicTableSettings } from './types';
import { TableState } from './useTableState';
import { Datatable } from './Datatable';
interface BasicRow {
Name: string;
Created: string;
}
type Args = {
isLoading: boolean;
data: BasicRow[];
settings: TableState<BasicTableSettings>;
columns: TableOptions<BasicRow>['columns'];
};
export default {
component: Datatable,
title: 'Components/Tables/Datatable',
} as Meta;
function Template({ isLoading, data, settings, columns }: Args) {
return (
<Datatable
columns={columns}
isLoading={isLoading}
dataset={data}
settingsManager={settings}
title="Edge Jobs"
titleIcon={Clock}
data-cy="edge-jobs-datatable"
/>
);
}
const columnHelper = createColumnHelper<BasicRow>();
export const Default: StoryFn<Args> = Template.bind({});
const defaultColumns = [
columnHelper.accessor('Name', {
header: 'Name',
}),
columnHelper.accessor('Created', {
header: 'Created',
}),
];
Default.args = {
isLoading: false,
data: [
{ Name: 'Juan', Created: '2021-01-21' },
{ Name: 'Ji Hee', Created: '2023-03-01' },
{ Name: 'Saki', Created: '2023-08-16' },
{ Name: 'Eve', Created: '2017-11-06' },
],
columns: defaultColumns,
settings: {
sortBy: { id: '', desc: true },
setSortBy: () => {},
search: '',
setSearch: () => {},
pageSize: 10,
setPageSize: () => {},
},
};
@@ -58,9 +58,9 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
getRowId?(row: D): string;
isRowSelectable?(row: Row<D>): boolean;
emptyContentLabel?: string;
title?: React.ReactNode;
titleIcon?: IconProps['icon'];
title?: ReactNode;
titleId?: string;
titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState>;
isLoading?: boolean;
description?: ReactNode;
@@ -85,8 +85,8 @@ export function Datatable<D extends DefaultType>({
getRowId = defaultGetRowId,
isRowSelectable = () => true,
title,
titleId,
titleIcon,
titleId,
emptyContentLabel,
initialTableState = {},
isLoading,
@@ -344,9 +344,6 @@ function filterPrimitive(value: unknown, filterValueLower: string) {
return false;
}
function getColumnCanGlobalFilter<D>(column: Column<D, unknown>): boolean {
if (column.id === 'select') {
return false;
}
return true;
function getColumnCanGlobalFilter<D>(column: Column<D>): boolean {
return column.id !== 'select';
}
@@ -0,0 +1,87 @@
import { Meta, StoryFn } from '@storybook/react';
import { Clock } from 'lucide-react';
import { createColumnHelper, TableOptions } from '@tanstack/react-table';
import { isEditableTableMeta } from '@@/datatables/editable/isEditableTableMeta';
import { EditableDatatable } from '@@/datatables/editable/EditableDatatable';
import { BasicTableSettings } from '../types';
import { TableState } from '../useTableState';
import { actionsColumn } from './actionsColumn';
interface BasicRow {
Name: string;
Created: string;
}
type Args = {
isLoading: boolean;
data: BasicRow[];
settings: TableState<BasicTableSettings>;
columns: TableOptions<BasicRow>['columns'];
};
export default {
component: EditableDatatable,
title: 'Components/Tables/EditableDatatable',
} as Meta;
function Template({ isLoading, data, settings, columns }: Args) {
return (
<EditableDatatable
columns={columns}
isLoading={isLoading}
dataset={data}
settingsManager={settings}
title="Edge Jobs"
titleIcon={Clock}
data-cy="edge-jobs-datatable"
acceptRow={() => {}}
revertRow={() => {}}
/>
);
}
const columnHelper = createColumnHelper<BasicRow>();
export const Default: StoryFn<Args> = Template.bind({});
const editableColumns = [
columnHelper.accessor('Name', {
header: 'Name',
cell: ({ row: { original, index }, table }) => {
if (!isEditableTableMeta(table.options.meta)) {
return null;
}
const editableRowIndex = table.options.meta.getEditableRow();
return index === editableRowIndex ? (
<input type="text" name="name" value={original.Name} />
) : (
<div>{original.Name}</div>
);
},
}),
columnHelper.accessor('Created', {
header: 'Created',
}),
actionsColumn<BasicRow>(() => {}),
];
Default.args = {
isLoading: false,
data: [
{ Name: 'Juan', Created: '2021-01-21' },
{ Name: 'Ji Hee', Created: '2023-03-01' },
{ Name: 'Saki', Created: '2023-08-16' },
{ Name: 'Eve', Created: '2017-11-06' },
],
columns: editableColumns,
settings: {
sortBy: { id: '', desc: true },
setSortBy: () => {},
search: '',
setSearch: () => {},
pageSize: 10,
setPageSize: () => {},
},
};
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
import { withMeta } from '@@/datatables/extend-options/withMeta';
import {
Datatable,
Props as DatatableProps,
PaginationProps,
} from '../Datatable';
import { DefaultType } from '../types';
export const NEW_ROW_ID = -1;
export const NEW_ROW_INDEX = 0;
export const UNSET_EDITABLE_ROW = -1;
interface Props<D extends DefaultType> extends Omit<DatatableProps<D>, 'meta'> {
revertRow(): void;
acceptRow(): void;
}
export function EditableDatatable<D extends DefaultType>({
dataset,
revertRow,
acceptRow,
...props
}: Props<D> & PaginationProps) {
const [autoResetPageIndex, skipAutoResetPageIndex] = useSkipper();
const [data, setData] = useState(dataset);
const [editableRow, setEditableRow] = useState(-1);
const [editableRowOriginalData, setEditableRowOriginalData] = useState<
D | undefined
>();
useEffect(() => {
setData(dataset);
}, [dataset]);
return (
<Datatable<D>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
dataset={data}
extendTableOptions={mergeOptions(
(options) => ({
...options,
autoResetPageIndex,
}),
withMeta({
getEditableRow: () => editableRow,
getEditableRowOriginalData: () => editableRowOriginalData,
editRow: (rowIndex: number, row: D) => {
setEditableRow(rowIndex);
setEditableRowOriginalData(row);
},
updateRow: (rowIndex: number, value: D) => {
// Skip page index reset until after next rerender
skipAutoResetPageIndex();
setData((old: D[]) =>
old.map((row, index) => (index === rowIndex ? value : row))
);
},
revertRow,
acceptRow,
})
)}
/>
);
}
function useSkipper() {
const shouldSkipRef = useRef(true);
const shouldSkip = shouldSkipRef.current;
// Wrap a function with this to skip a pagination reset temporarily
const skip = useCallback(() => {
shouldSkipRef.current = false;
}, []);
useEffect(() => {
shouldSkipRef.current = true;
});
return [shouldSkip, skip] as const;
}
@@ -0,0 +1,137 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Trash2 } from 'lucide-react';
import { Button } from '@@/buttons';
import {
NEW_ROW_ID,
UNSET_EDITABLE_ROW,
} from '@@/datatables/editable/EditableDatatable';
import { isEditableTableMeta } from '@@/datatables/editable/isEditableTableMeta';
function defaultIsNewRow(row: unknown): boolean {
return (row as { Id: number }).Id === NEW_ROW_ID;
}
export function actionsColumn<T>(
onRemove: (item: T) => void,
isNewRow: (row: T) => boolean = defaultIsNewRow
) {
const columnHelper = createColumnHelper<T>();
return columnHelper.accessor(() => '', {
header: 'Actions',
id: 'actions',
enableSorting: false,
cell: ({ row: { original, index }, table }) => {
if (!isEditableTableMeta(table.options.meta)) {
return null;
}
const {
editRow,
updateRow,
getEditableRow,
getEditableRowOriginalData,
revertRow,
acceptRow,
} = table.options.meta;
const editableRowIndex = getEditableRow();
return index === editableRowIndex || isNewRow(original) ? (
<EditActionsCell
acceptRow={() => {
if (isNewRow(original)) {
acceptRow();
} else {
editRow(UNSET_EDITABLE_ROW, undefined);
}
}}
revertRow={() => {
if (isNewRow(original)) {
revertRow();
} else {
updateRow(index, getEditableRowOriginalData());
editRow(UNSET_EDITABLE_ROW, undefined);
}
}}
/>
) : (
<ActionsCell<T>
onRemove={onRemove}
editableRow={editableRowIndex}
editRow={editRow}
row={original}
rowIndex={index}
/>
);
},
});
}
function ActionsCell<T>({
onRemove,
editRow,
editableRow,
row,
rowIndex,
}: {
onRemove: (item: T) => void;
editRow: (index: number, original: T | undefined) => void;
editableRow: number;
row: T;
rowIndex: number;
}) {
return (
<div className="flex gap-x-2 justify-center">
<Button
color="light"
size="small"
onClick={() => {
editRow(rowIndex, row);
}}
disabled={editableRow !== -1}
data-cy="edit-access-button"
>
Edit
</Button>
<Button
color="dangerlight"
size="small"
icon={Trash2}
onClick={() => onRemove(row)}
data-cy="remove-access-button"
>
Remove
</Button>
</div>
);
}
function EditActionsCell({
acceptRow,
revertRow,
}: {
acceptRow: () => void;
revertRow: () => void;
}) {
return (
<div className="flex gap-x-2 justify-center">
<Button
color="light"
size="small"
onClick={() => acceptRow()}
data-cy="edit-access-button"
>
Accept
</Button>
<Button
color="dangerlight"
size="small"
onClick={() => revertRow()}
data-cy="remove-access-button"
>
Revert
</Button>
</div>
);
}
@@ -0,0 +1,25 @@
import { RowData } from '@tanstack/react-table';
interface EditableTableMeta<TData extends RowData> {
getEditableRow: () => number;
getEditableRowOriginalData: () => TData | undefined;
editRow: (rowIndex: number, row: TData | undefined) => void;
updateRow: (rowIndex: number, row: TData | undefined) => void;
revertRow: () => void;
acceptRow: () => void;
}
export function isEditableTableMeta<TData extends RowData>(
meta?: unknown
): meta is EditableTableMeta<TData> {
return (
!!meta &&
typeof meta === 'object' &&
'getEditableRow' in meta &&
'getEditableRowOriginalData' in meta &&
'editRow' in meta &&
'updateRow' in meta &&
'revertRow' in meta &&
'acceptRow' in meta
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export type ZustandSetFunc<T> = (
) => void;
// pagination (page size dropdown)
// for both backend and frontend paginations
// for both backend and frontend pagination
export interface PaginationTableSettings {
pageSize: number;
setPageSize: (pageSize: number) => void;
@@ -1,23 +1,27 @@
import { TableMeta as BaseTableMeta } from '@tanstack/react-table';
import { VolumeViewModel } from '@/docker/models/volume';
interface TableMeta {
isBrowseVisible: boolean;
table: 'volumes';
}
function isTableMeta(meta: BaseTableMeta<VolumeViewModel>): meta is TableMeta {
return !!meta && 'table' in meta && meta.table === 'volumes';
function isTableMeta(meta: unknown): meta is TableMeta {
return (
!!meta &&
typeof meta === 'object' &&
'table' in meta &&
meta.table === 'volumes'
);
}
export function getTableMeta(meta?: BaseTableMeta<VolumeViewModel>): TableMeta {
if (!meta || !isTableMeta(meta)) {
export function getTableMeta(meta?: unknown): TableMeta {
if (!isTableMeta(meta)) {
return {
isBrowseVisible: false,
table: 'volumes',
};
}
return meta;
return {
isBrowseVisible: meta.isBrowseVisible,
table: 'volumes',
};
}