mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955)
This commit is contained in:
Vendored
+2
-6
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user