From 2ad0a6561342adb56ada7dd6e6da5ae8a3f701d2 Mon Sep 17 00:00:00 2001 From: nickl-portainer Date: Mon, 2 Mar 2026 09:12:13 +1300 Subject: [PATCH] feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955) --- app/react-table-config.d.ts | 8 +- .../datatables/ActionsMenu.module.css | 27 ---- .../components/datatables/ActionsMenu.tsx | 34 ----- .../datatables/ActionsMenuTitle.module.css | 4 - .../datatables/ActionsMenuTitle.tsx | 11 -- .../datatables/Datatable.stories.tsx | 68 +++++++++ app/react/components/datatables/Datatable.tsx | 13 +- .../editable/EditableDatatable.stories.tsx | 87 +++++++++++ .../datatables/editable/EditableDatatable.tsx | 85 +++++++++++ .../datatables/editable/actionsColumn.tsx | 137 ++++++++++++++++++ .../editable/isEditableTableMeta.ts | 25 ++++ app/react/components/datatables/types.ts | 2 +- .../ListView/VolumesDatatable/tableMeta.ts | 22 +-- 13 files changed, 423 insertions(+), 100 deletions(-) delete mode 100644 app/react/components/datatables/ActionsMenu.module.css delete mode 100644 app/react/components/datatables/ActionsMenu.tsx delete mode 100644 app/react/components/datatables/ActionsMenuTitle.module.css delete mode 100644 app/react/components/datatables/ActionsMenuTitle.tsx create mode 100644 app/react/components/datatables/Datatable.stories.tsx create mode 100644 app/react/components/datatables/editable/EditableDatatable.stories.tsx create mode 100644 app/react/components/datatables/editable/EditableDatatable.tsx create mode 100644 app/react/components/datatables/editable/actionsColumn.tsx create mode 100644 app/react/components/datatables/editable/isEditableTableMeta.ts diff --git a/app/react-table-config.d.ts b/app/react-table-config.d.ts index 80e934d483..276e9ba2cc 100644 --- a/app/react-table-config.d.ts +++ b/app/react-table-config.d.ts @@ -1,14 +1,10 @@ import '@tanstack/react-table'; -declare module '@tanstack/table-core' { +declare module '@tanstack/react-table' { interface ColumnMeta { className?: string; filter?: Filter; width?: number | 'auto' | string; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface TableMeta { - table?: string; + minWidth?: string; } } diff --git a/app/react/components/datatables/ActionsMenu.module.css b/app/react/components/datatables/ActionsMenu.module.css deleted file mode 100644 index 69e132eec2..0000000000 --- a/app/react/components/datatables/ActionsMenu.module.css +++ /dev/null @@ -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; -} diff --git a/app/react/components/datatables/ActionsMenu.tsx b/app/react/components/datatables/ActionsMenu.tsx deleted file mode 100644 index 51edd2410f..0000000000 --- a/app/react/components/datatables/ActionsMenu.tsx +++ /dev/null @@ -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 ( - - {({ isExpanded }) => ( - <> - - - - -
{children}
-
- - )} -
- ); -} diff --git a/app/react/components/datatables/ActionsMenuTitle.module.css b/app/react/components/datatables/ActionsMenuTitle.module.css deleted file mode 100644 index b8ff387f93..0000000000 --- a/app/react/components/datatables/ActionsMenuTitle.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.table-actions-title { - color: var(--blue-2); - padding: 5px 10px; -} diff --git a/app/react/components/datatables/ActionsMenuTitle.tsx b/app/react/components/datatables/ActionsMenuTitle.tsx deleted file mode 100644 index ab23bd1382..0000000000 --- a/app/react/components/datatables/ActionsMenuTitle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode } from 'react'; - -import styles from './ActionsMenuTitle.module.css'; - -interface Props { - children: ReactNode; -} - -export function ActionsMenuTitle({ children }: Props) { - return
{children}
; -} diff --git a/app/react/components/datatables/Datatable.stories.tsx b/app/react/components/datatables/Datatable.stories.tsx new file mode 100644 index 0000000000..e9563b09a9 --- /dev/null +++ b/app/react/components/datatables/Datatable.stories.tsx @@ -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; + columns: TableOptions['columns']; +}; + +export default { + component: Datatable, + title: 'Components/Tables/Datatable', +} as Meta; + +function Template({ isLoading, data, settings, columns }: Args) { + return ( + + ); +} + +const columnHelper = createColumnHelper(); + +export const Default: StoryFn = 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: () => {}, + }, +}; diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index e4c80051cd..04b8e3c23f 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -58,9 +58,9 @@ export interface Props extends AutomationTestingProps { getRowId?(row: D): string; isRowSelectable?(row: Row): boolean; emptyContentLabel?: string; - title?: React.ReactNode; - titleIcon?: IconProps['icon']; + title?: ReactNode; titleId?: string; + titleIcon?: IconProps['icon']; initialTableState?: Partial; isLoading?: boolean; description?: ReactNode; @@ -85,8 +85,8 @@ export function Datatable({ 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(column: Column): boolean { - if (column.id === 'select') { - return false; - } - return true; +function getColumnCanGlobalFilter(column: Column): boolean { + return column.id !== 'select'; } diff --git a/app/react/components/datatables/editable/EditableDatatable.stories.tsx b/app/react/components/datatables/editable/EditableDatatable.stories.tsx new file mode 100644 index 0000000000..b09306c0b0 --- /dev/null +++ b/app/react/components/datatables/editable/EditableDatatable.stories.tsx @@ -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; + columns: TableOptions['columns']; +}; + +export default { + component: EditableDatatable, + title: 'Components/Tables/EditableDatatable', +} as Meta; + +function Template({ isLoading, data, settings, columns }: Args) { + return ( + {}} + revertRow={() => {}} + /> + ); +} + +const columnHelper = createColumnHelper(); + +export const Default: StoryFn = 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 ? ( + + ) : ( +
{original.Name}
+ ); + }, + }), + columnHelper.accessor('Created', { + header: 'Created', + }), + actionsColumn(() => {}), +]; +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: () => {}, + }, +}; diff --git a/app/react/components/datatables/editable/EditableDatatable.tsx b/app/react/components/datatables/editable/EditableDatatable.tsx new file mode 100644 index 0000000000..1998029bc5 --- /dev/null +++ b/app/react/components/datatables/editable/EditableDatatable.tsx @@ -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 extends Omit, 'meta'> { + revertRow(): void; + acceptRow(): void; +} + +export function EditableDatatable({ + dataset, + revertRow, + acceptRow, + ...props +}: Props & 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 ( + + // 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; +} diff --git a/app/react/components/datatables/editable/actionsColumn.tsx b/app/react/components/datatables/editable/actionsColumn.tsx new file mode 100644 index 0000000000..d8cc02ed5a --- /dev/null +++ b/app/react/components/datatables/editable/actionsColumn.tsx @@ -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( + onRemove: (item: T) => void, + isNewRow: (row: T) => boolean = defaultIsNewRow +) { + const columnHelper = createColumnHelper(); + + 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) ? ( + { + if (isNewRow(original)) { + acceptRow(); + } else { + editRow(UNSET_EDITABLE_ROW, undefined); + } + }} + revertRow={() => { + if (isNewRow(original)) { + revertRow(); + } else { + updateRow(index, getEditableRowOriginalData()); + editRow(UNSET_EDITABLE_ROW, undefined); + } + }} + /> + ) : ( + + onRemove={onRemove} + editableRow={editableRowIndex} + editRow={editRow} + row={original} + rowIndex={index} + /> + ); + }, + }); +} + +function ActionsCell({ + onRemove, + editRow, + editableRow, + row, + rowIndex, +}: { + onRemove: (item: T) => void; + editRow: (index: number, original: T | undefined) => void; + editableRow: number; + row: T; + rowIndex: number; +}) { + return ( +
+ + +
+ ); +} + +function EditActionsCell({ + acceptRow, + revertRow, +}: { + acceptRow: () => void; + revertRow: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/app/react/components/datatables/editable/isEditableTableMeta.ts b/app/react/components/datatables/editable/isEditableTableMeta.ts new file mode 100644 index 0000000000..3705e46b51 --- /dev/null +++ b/app/react/components/datatables/editable/isEditableTableMeta.ts @@ -0,0 +1,25 @@ +import { RowData } from '@tanstack/react-table'; + +interface EditableTableMeta { + 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( + meta?: unknown +): meta is EditableTableMeta { + return ( + !!meta && + typeof meta === 'object' && + 'getEditableRow' in meta && + 'getEditableRowOriginalData' in meta && + 'editRow' in meta && + 'updateRow' in meta && + 'revertRow' in meta && + 'acceptRow' in meta + ); +} diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts index aecc23a189..b9339b515d 100644 --- a/app/react/components/datatables/types.ts +++ b/app/react/components/datatables/types.ts @@ -12,7 +12,7 @@ export type ZustandSetFunc = ( ) => 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; diff --git a/app/react/docker/volumes/ListView/VolumesDatatable/tableMeta.ts b/app/react/docker/volumes/ListView/VolumesDatatable/tableMeta.ts index 77190da681..36380922de 100644 --- a/app/react/docker/volumes/ListView/VolumesDatatable/tableMeta.ts +++ b/app/react/docker/volumes/ListView/VolumesDatatable/tableMeta.ts @@ -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): 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): 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', + }; }