mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
refactor(ui/datatables): migrate views to use datatable component [EE-4064] (#7609)
This commit is contained in:
parent
0f0513c684
commit
fe8e834dbf
90 changed files with 1714 additions and 2717 deletions
|
@ -8,79 +8,95 @@ import {
|
|||
Row,
|
||||
TableInstance,
|
||||
TableState,
|
||||
TableRowProps,
|
||||
useExpanded,
|
||||
} from 'react-table';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
import { Table } from './Table';
|
||||
import { multiple } from './filter-types';
|
||||
import { SearchBar, useSearchBarState } from './SearchBar';
|
||||
import { SelectedRowsCount } from './SelectedRowsCount';
|
||||
import { TableSettingsProvider } from './useZustandTableSettings';
|
||||
import { useRowSelect } from './useRowSelect';
|
||||
import { PaginationTableSettings, SortableTableSettings } from './types';
|
||||
import { BasicTableSettings } from './types';
|
||||
import { DatatableHeader } from './DatatableHeader';
|
||||
import { DatatableFooter } from './DatatableFooter';
|
||||
import { DatatableContent } from './DatatableContent';
|
||||
import { defaultGetRowId } from './defaultGetRowId';
|
||||
import { emptyPlugin } from './emptyReactTablePlugin';
|
||||
import { useGoToHighlightedRow } from './useGoToHighlightedRow';
|
||||
|
||||
interface DefaultTableSettings
|
||||
extends SortableTableSettings,
|
||||
PaginationTableSettings {}
|
||||
|
||||
interface TitleOptionsVisible {
|
||||
title: string;
|
||||
icon?: IconProps['icon'];
|
||||
featherIcon?: IconProps['featherIcon'];
|
||||
hide?: never;
|
||||
}
|
||||
|
||||
type TitleOptions = TitleOptionsVisible | { hide: true };
|
||||
|
||||
interface Props<
|
||||
D extends Record<string, unknown>,
|
||||
TSettings extends DefaultTableSettings
|
||||
> {
|
||||
export interface Props<D extends Record<string, unknown>> {
|
||||
dataset: D[];
|
||||
storageKey: string;
|
||||
columns: readonly Column<D>[];
|
||||
renderTableSettings?(instance: TableInstance<D>): ReactNode;
|
||||
renderTableActions?(selectedRows: D[]): ReactNode;
|
||||
settingsStore: TSettings;
|
||||
disableSelect?: boolean;
|
||||
getRowId?(row: D): string;
|
||||
isRowSelectable?(row: Row<D>): boolean;
|
||||
emptyContentLabel?: string;
|
||||
titleOptions: TitleOptions;
|
||||
title?: string;
|
||||
titleIcon?: IconProps['icon'];
|
||||
initialTableState?: Partial<TableState<D>>;
|
||||
isLoading?: boolean;
|
||||
totalCount?: number;
|
||||
description?: JSX.Element;
|
||||
initialActiveItem?: string;
|
||||
description?: ReactNode;
|
||||
pageCount?: number;
|
||||
initialSortBy?: BasicTableSettings['sortBy'];
|
||||
initialPageSize?: BasicTableSettings['pageSize'];
|
||||
highlightedItemId?: string;
|
||||
|
||||
searchValue: string;
|
||||
onSearchChange(search: string): void;
|
||||
onSortByChange(colId: string, desc: boolean): void;
|
||||
onPageSizeChange(pageSize: number): void;
|
||||
|
||||
// send state up
|
||||
onPageChange?(page: number): void;
|
||||
|
||||
renderRow?(
|
||||
row: Row<D>,
|
||||
rowProps: TableRowProps,
|
||||
highlightedItemId?: string
|
||||
): ReactNode;
|
||||
expandable?: boolean;
|
||||
noWidget?: boolean;
|
||||
}
|
||||
|
||||
export function Datatable<
|
||||
D extends Record<string, unknown>,
|
||||
TSettings extends DefaultTableSettings
|
||||
>({
|
||||
export function Datatable<D extends Record<string, unknown>>({
|
||||
columns,
|
||||
dataset,
|
||||
storageKey,
|
||||
renderTableSettings,
|
||||
renderTableActions,
|
||||
settingsStore,
|
||||
renderTableSettings = () => null,
|
||||
renderTableActions = () => null,
|
||||
disableSelect,
|
||||
getRowId = defaultGetRowId,
|
||||
isRowSelectable = () => true,
|
||||
titleOptions,
|
||||
title,
|
||||
titleIcon,
|
||||
emptyContentLabel,
|
||||
initialTableState = {},
|
||||
isLoading,
|
||||
totalCount = dataset.length,
|
||||
description,
|
||||
initialActiveItem,
|
||||
}: Props<D, TSettings>) {
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||
pageCount,
|
||||
|
||||
initialSortBy,
|
||||
initialPageSize = 10,
|
||||
onPageChange = () => {},
|
||||
|
||||
onPageSizeChange,
|
||||
onSortByChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
|
||||
renderRow = defaultRenderRow,
|
||||
expandable = false,
|
||||
highlightedItemId,
|
||||
noWidget,
|
||||
}: Props<D>) {
|
||||
const isServerSidePagination = typeof pageCount !== 'undefined';
|
||||
|
||||
const tableInstance = useTable<D>(
|
||||
{
|
||||
|
@ -89,183 +105,104 @@ export function Datatable<
|
|||
data: dataset,
|
||||
filterTypes: { multiple },
|
||||
initialState: {
|
||||
pageSize: settingsStore.pageSize || 10,
|
||||
sortBy: [settingsStore.sortBy],
|
||||
globalFilter: searchBarValue,
|
||||
pageSize: initialPageSize,
|
||||
sortBy: initialSortBy ? [initialSortBy] : [],
|
||||
globalFilter: searchValue,
|
||||
...initialTableState,
|
||||
},
|
||||
isRowSelectable,
|
||||
autoResetExpanded: false,
|
||||
autoResetSelectedRows: false,
|
||||
getRowId,
|
||||
stateReducer: (newState, action) => {
|
||||
switch (action.type) {
|
||||
case 'setGlobalFilter':
|
||||
setSearchBarValue(action.filterValue);
|
||||
break;
|
||||
case 'toggleSortBy':
|
||||
settingsStore.setSortBy(action.columnId, action.desc);
|
||||
break;
|
||||
case 'setPageSize':
|
||||
settingsStore.setPageSize(action.pageSize);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
|
||||
},
|
||||
useFilters,
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
expandable ? useExpanded : emptyPlugin,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
!disableSelect ? useRowSelectColumn : emptyPlugin
|
||||
);
|
||||
|
||||
const {
|
||||
rows,
|
||||
selectedFlatRows,
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
setGlobalFilter,
|
||||
state: { pageIndex, pageSize },
|
||||
} = tableInstance;
|
||||
useGoToHighlightedRow(
|
||||
isServerSidePagination,
|
||||
tableInstance.state.pageSize,
|
||||
tableInstance.rows,
|
||||
handlePageChange,
|
||||
highlightedItemId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialActiveItem && pageSize !== rows.length) {
|
||||
const paginatedData = [...Array(Math.ceil(rows.length / pageSize))].map(
|
||||
(_, i) => rows.slice(pageSize * i, pageSize + pageSize * i)
|
||||
);
|
||||
|
||||
const itemPage = paginatedData.findIndex((sub) =>
|
||||
sub.some((row) => row.id === initialActiveItem)
|
||||
);
|
||||
|
||||
gotoPage(itemPage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialActiveItem]);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
const selectedItems = selectedFlatRows.map((row) => row.original);
|
||||
const selectedItems = tableInstance.selectedFlatRows.map(
|
||||
(row) => row.original
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableSettingsProvider settings={settingsStore}>
|
||||
<Table.Container>
|
||||
{isTitleVisible(titleOptions) && (
|
||||
<Table.Title
|
||||
label={titleOptions.title}
|
||||
icon={titleOptions.icon}
|
||||
featherIcon={titleOptions.featherIcon}
|
||||
description={description}
|
||||
>
|
||||
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
|
||||
{renderTableActions && (
|
||||
<Table.Actions>
|
||||
{renderTableActions(selectedItems)}
|
||||
</Table.Actions>
|
||||
)}
|
||||
<Table.TitleActions>
|
||||
{!!renderTableSettings && renderTableSettings(tableInstance)}
|
||||
</Table.TitleActions>
|
||||
</Table.Title>
|
||||
)}
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<Table.HeaderRow<D>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content<D>
|
||||
rows={page}
|
||||
isLoading={isLoading}
|
||||
prepareRow={prepareRow}
|
||||
emptyContent={emptyContentLabel}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<Table.Row<D>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={clsx(
|
||||
className,
|
||||
initialActiveItem &&
|
||||
initialActiveItem === row.id &&
|
||||
'active'
|
||||
)}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
<Table.Footer>
|
||||
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={pageIndex + 1}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={setPageSize}
|
||||
/>
|
||||
</Table.Footer>
|
||||
</Table.Container>
|
||||
</TableSettingsProvider>
|
||||
</div>
|
||||
</div>
|
||||
<Table.Container noWidget={noWidget}>
|
||||
<DatatableHeader
|
||||
onSearchChange={handleSearchBarChange}
|
||||
searchValue={searchValue}
|
||||
title={title}
|
||||
titleIcon={titleIcon}
|
||||
renderTableActions={() => renderTableActions(selectedItems)}
|
||||
renderTableSettings={() => renderTableSettings(tableInstance)}
|
||||
description={description}
|
||||
/>
|
||||
<DatatableContent<D>
|
||||
tableInstance={tableInstance}
|
||||
renderRow={(row, rowProps) =>
|
||||
renderRow(row, rowProps, highlightedItemId)
|
||||
}
|
||||
emptyContentLabel={emptyContentLabel}
|
||||
isLoading={isLoading}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
<DatatableFooter
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
page={tableInstance.state.pageIndex}
|
||||
pageSize={tableInstance.state.pageSize}
|
||||
totalCount={totalCount}
|
||||
totalSelected={selectedItems.length}
|
||||
/>
|
||||
</Table.Container>
|
||||
);
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
tableInstance.setGlobalFilter(value);
|
||||
onSearchChange(value);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
tableInstance.gotoPage(page);
|
||||
onPageChange(page);
|
||||
}
|
||||
|
||||
function handleSortChange(colId: string, desc: boolean) {
|
||||
onSortByChange(colId, desc);
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
tableInstance.setPageSize(pageSize);
|
||||
onPageSizeChange(pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRenderRow<D extends Record<string, unknown>>(
|
||||
row: Row<D>,
|
||||
rowProps: TableRowProps,
|
||||
highlightedItemId?: string
|
||||
) {
|
||||
return (
|
||||
<Table.Row<D>
|
||||
key={rowProps.key}
|
||||
cells={row.cells}
|
||||
className={clsx(rowProps.className, {
|
||||
active: highlightedItemId === row.id,
|
||||
})}
|
||||
role={rowProps.role}
|
||||
style={rowProps.style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isTitleVisible(
|
||||
titleSettings: TitleOptions
|
||||
): titleSettings is TitleOptionsVisible {
|
||||
return !titleSettings.hide;
|
||||
}
|
||||
|
||||
function defaultGetRowId<D extends Record<string, unknown>>(row: D): string {
|
||||
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
|
||||
return row.id.toString();
|
||||
}
|
||||
|
||||
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
|
||||
return row.Id.toString();
|
||||
}
|
||||
|
||||
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
|
||||
return row.ID.toString();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function emptyPlugin() {}
|
||||
|
||||
emptyPlugin.pluginName = 'emptyPlugin';
|
||||
|
|
62
app/react/components/datatables/DatatableContent.tsx
Normal file
62
app/react/components/datatables/DatatableContent.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { Row, TableInstance, TableRowProps } from 'react-table';
|
||||
|
||||
import { Table } from './Table';
|
||||
|
||||
interface Props<D extends Record<string, unknown>> {
|
||||
tableInstance: TableInstance<D>;
|
||||
renderRow(row: Row<D>, rowProps: TableRowProps): React.ReactNode;
|
||||
onSortChange?(colId: string, desc: boolean): void;
|
||||
isLoading?: boolean;
|
||||
emptyContentLabel?: string;
|
||||
}
|
||||
|
||||
export function DatatableContent<D extends Record<string, unknown>>({
|
||||
tableInstance,
|
||||
renderRow,
|
||||
onSortChange,
|
||||
isLoading,
|
||||
emptyContentLabel,
|
||||
}: Props<D>) {
|
||||
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
|
||||
tableInstance;
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
return (
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<Table.HeaderRow<D>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content<D>
|
||||
rows={page}
|
||||
isLoading={isLoading}
|
||||
prepareRow={prepareRow}
|
||||
emptyContent={emptyContentLabel}
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
36
app/react/components/datatables/DatatableFooter.tsx
Normal file
36
app/react/components/datatables/DatatableFooter.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { PaginationControls } from '@@/PaginationControls';
|
||||
|
||||
import { Table } from './Table';
|
||||
import { SelectedRowsCount } from './SelectedRowsCount';
|
||||
|
||||
interface Props {
|
||||
totalSelected: number;
|
||||
pageSize: number;
|
||||
page: number;
|
||||
onPageChange(page: number): void;
|
||||
totalCount: number;
|
||||
onPageSizeChange(pageSize: number): void;
|
||||
}
|
||||
|
||||
export function DatatableFooter({
|
||||
totalSelected,
|
||||
pageSize,
|
||||
page,
|
||||
onPageChange,
|
||||
totalCount,
|
||||
onPageSizeChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Table.Footer>
|
||||
<SelectedRowsCount value={totalSelected} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={page + 1}
|
||||
onPageChange={(page) => onPageChange(page - 1)}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={onPageSizeChange}
|
||||
/>
|
||||
</Table.Footer>
|
||||
);
|
||||
}
|
42
app/react/components/datatables/DatatableHeader.tsx
Normal file
42
app/react/components/datatables/DatatableHeader.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
import { SearchBar } from './SearchBar';
|
||||
import { Table } from './Table';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
titleIcon?: IconProps['icon'];
|
||||
searchValue: string;
|
||||
onSearchChange(value: string): void;
|
||||
renderTableSettings?(): ReactNode;
|
||||
renderTableActions?(): ReactNode;
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
export function DatatableHeader({
|
||||
onSearchChange,
|
||||
renderTableActions,
|
||||
renderTableSettings,
|
||||
searchValue,
|
||||
title,
|
||||
titleIcon,
|
||||
description,
|
||||
}: Props) {
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Title label={title} icon={titleIcon} description={description}>
|
||||
<SearchBar value={searchValue} onChange={onSearchChange} />
|
||||
{renderTableActions && (
|
||||
<Table.Actions>{renderTableActions()}</Table.Actions>
|
||||
)}
|
||||
<Table.TitleActions>
|
||||
{!!renderTableSettings && renderTableSettings()}
|
||||
</Table.TitleActions>
|
||||
</Table.Title>
|
||||
);
|
||||
}
|
33
app/react/components/datatables/ExpandableDatatable.tsx
Normal file
33
app/react/components/datatables/ExpandableDatatable.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Row } from 'react-table';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
|
||||
import { Datatable, Props as DatatableProps } from './Datatable';
|
||||
|
||||
interface Props<D extends Record<string, unknown>>
|
||||
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
|
||||
renderSubRow(row: Row<D>): ReactNode;
|
||||
}
|
||||
|
||||
export function ExpandableDatatable<D extends Record<string, unknown>>({
|
||||
renderSubRow,
|
||||
...props
|
||||
}: Props<D>) {
|
||||
return (
|
||||
<Datatable<D>
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
expandable
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<ExpandableDatatableTableRow<D>
|
||||
key={key}
|
||||
row={row}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
renderSubRow={renderSubRow}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
41
app/react/components/datatables/ExpandableDatatableRow.tsx
Normal file
41
app/react/components/datatables/ExpandableDatatableRow.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { CSSProperties, ReactNode } from 'react';
|
||||
import { Row } from 'react-table';
|
||||
|
||||
import { TableRow } from './TableRow';
|
||||
|
||||
interface Props<D extends Record<string, unknown>> {
|
||||
row: Row<D>;
|
||||
className?: string;
|
||||
role?: string;
|
||||
style?: CSSProperties;
|
||||
disableSelect?: boolean;
|
||||
renderSubRow(row: Row<D>): ReactNode;
|
||||
}
|
||||
|
||||
export function ExpandableDatatableTableRow<D extends Record<string, unknown>>({
|
||||
row,
|
||||
className,
|
||||
role,
|
||||
style,
|
||||
disableSelect,
|
||||
renderSubRow,
|
||||
}: Props<D>) {
|
||||
return (
|
||||
<>
|
||||
<TableRow<D>
|
||||
cells={row.cells}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
{row.isExpanded && (
|
||||
<tr>
|
||||
{!disableSelect && <td />}
|
||||
<td colSpan={disableSelect ? row.cells.length : row.cells.length - 1}>
|
||||
{renderSubRow(row)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
74
app/react/components/datatables/NestedDatatable.tsx
Normal file
74
app/react/components/datatables/NestedDatatable.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
useTable,
|
||||
useFilters,
|
||||
useSortBy,
|
||||
Column,
|
||||
TableState,
|
||||
usePagination,
|
||||
} from 'react-table';
|
||||
|
||||
import { Table } from './Table';
|
||||
import { multiple } from './filter-types';
|
||||
import { NestedTable } from './NestedTable';
|
||||
import { DatatableContent } from './DatatableContent';
|
||||
import { defaultGetRowId } from './defaultGetRowId';
|
||||
|
||||
interface Props<D extends Record<string, unknown>> {
|
||||
dataset: D[];
|
||||
columns: readonly Column<D>[];
|
||||
|
||||
getRowId?(row: D): string;
|
||||
emptyContentLabel?: string;
|
||||
initialTableState?: Partial<TableState<D>>;
|
||||
isLoading?: boolean;
|
||||
defaultSortBy?: string;
|
||||
}
|
||||
|
||||
export function NestedDatatable<D extends Record<string, unknown>>({
|
||||
columns,
|
||||
dataset,
|
||||
getRowId = defaultGetRowId,
|
||||
emptyContentLabel,
|
||||
initialTableState = {},
|
||||
isLoading,
|
||||
defaultSortBy,
|
||||
}: Props<D>) {
|
||||
const tableInstance = useTable<D>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: dataset,
|
||||
filterTypes: { multiple },
|
||||
initialState: {
|
||||
sortBy: defaultSortBy ? [{ id: defaultSortBy, desc: true }] : [],
|
||||
...initialTableState,
|
||||
},
|
||||
autoResetSelectedRows: false,
|
||||
getRowId,
|
||||
},
|
||||
useFilters,
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
return (
|
||||
<NestedTable>
|
||||
<Table.Container>
|
||||
<DatatableContent<D>
|
||||
tableInstance={tableInstance}
|
||||
isLoading={isLoading}
|
||||
emptyContentLabel={emptyContentLabel}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<Table.Row<D>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Table.Container>
|
||||
</NestedTable>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import './InnerDatatable.css';
|
||||
import './NestedTable.css';
|
||||
|
||||
export function InnerDatatable({ children }: PropsWithChildren<unknown>) {
|
||||
export function NestedTable({ children }: PropsWithChildren<unknown>) {
|
||||
return <div className="inner-datatable">{children}</div>;
|
||||
}
|
|
@ -5,7 +5,7 @@ import {
|
|||
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { useTableSettings } from './useZustandTableSettings';
|
||||
import { useTableSettings } from './useTableSettings';
|
||||
|
||||
export interface Action {
|
||||
id: QuickAction;
|
||||
|
@ -17,7 +17,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export function QuickActionsSettings({ actions }: Props) {
|
||||
const { settings } =
|
||||
const settings =
|
||||
useTableSettings<SettableQuickActionsTableSettings<QuickAction>>();
|
||||
|
||||
return (
|
||||
|
|
|
@ -35,6 +35,8 @@ function MainComponent({
|
|||
);
|
||||
}
|
||||
|
||||
MainComponent.displayName = 'Table';
|
||||
|
||||
interface SubComponents {
|
||||
Container: typeof TableContainer;
|
||||
Actions: typeof TableActions;
|
||||
|
|
|
@ -2,12 +2,28 @@ import { PropsWithChildren } from 'react';
|
|||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
export function TableContainer({ children }: PropsWithChildren<unknown>) {
|
||||
interface Props {
|
||||
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
|
||||
noWidget?: boolean;
|
||||
}
|
||||
|
||||
export function TableContainer({
|
||||
children,
|
||||
noWidget = false,
|
||||
}: PropsWithChildren<Props>) {
|
||||
if (noWidget) {
|
||||
return <div className="datatable">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="datatable">
|
||||
<Widget>
|
||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||
</Widget>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<div className="datatable">
|
||||
<Widget>
|
||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ interface Props {
|
|||
icon?: ReactNode | ComponentType<unknown>;
|
||||
featherIcon?: boolean;
|
||||
label: string;
|
||||
description?: JSX.Element;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export function TableTitle({
|
||||
|
@ -34,7 +34,7 @@ export function TableTitle({
|
|||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{description && description}
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
17
app/react/components/datatables/defaultGetRowId.ts
Normal file
17
app/react/components/datatables/defaultGetRowId.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
export function defaultGetRowId<D extends Record<string, unknown>>(
|
||||
row: D
|
||||
): string {
|
||||
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
|
||||
return row.id.toString();
|
||||
}
|
||||
|
||||
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
|
||||
return row.Id.toString();
|
||||
}
|
||||
|
||||
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
|
||||
return row.ID.toString();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
3
app/react/components/datatables/emptyReactTablePlugin.ts
Normal file
3
app/react/components/datatables/emptyReactTablePlugin.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function emptyPlugin() {}
|
||||
|
||||
emptyPlugin.pluginName = 'emptyPlugin';
|
49
app/react/components/datatables/expand-column.tsx
Normal file
49
app/react/components/datatables/expand-column.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { ChevronDown, ChevronUp } from 'react-feather';
|
||||
import { CellProps, Column, HeaderProps } from 'react-table';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
export function buildExpandColumn<T extends Record<string, unknown>>(
|
||||
isExpandable: (item: T) => boolean
|
||||
): Column<T> {
|
||||
return {
|
||||
id: 'expand',
|
||||
Header: ({
|
||||
filteredFlatRows,
|
||||
getToggleAllRowsExpandedProps,
|
||||
isAllRowsExpanded,
|
||||
}: HeaderProps<T>) => {
|
||||
const hasExpandableItems = filteredFlatRows.some((item) =>
|
||||
isExpandable(item.original)
|
||||
);
|
||||
|
||||
return (
|
||||
hasExpandableItems && (
|
||||
<Button
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getToggleAllRowsExpandedProps()}
|
||||
color="none"
|
||||
icon={isAllRowsExpanded ? ChevronDown : ChevronUp}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
Cell: ({ row }: CellProps<T>) => (
|
||||
<div className="vertical-center">
|
||||
{isExpandable(row.original) && (
|
||||
<Button
|
||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||
{...row.getToggleRowExpandedProps()}
|
||||
color="none"
|
||||
icon={row.isExpanded ? ChevronDown : ChevronUp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
width: 30,
|
||||
disableResizing: true,
|
||||
};
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
export interface PaginationTableSettings {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SortableTableSettings {
|
||||
sortBy: { id: string; desc: boolean };
|
||||
}
|
||||
|
||||
export interface SettableColumnsTableSettings {
|
||||
hiddenColumns: string[];
|
||||
}
|
||||
|
||||
export interface RefreshableTableSettings {
|
||||
autoRefreshRate: number;
|
||||
}
|
|
@ -1,15 +1,20 @@
|
|||
import { createStore } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/react/hooks/useLocalStorage';
|
||||
|
||||
export interface PaginationTableSettings {
|
||||
pageSize: number;
|
||||
setPageSize: (pageSize: number) => void;
|
||||
}
|
||||
|
||||
type Set<T> = (
|
||||
type ZustandSetFunc<T> = (
|
||||
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
|
||||
replace?: boolean | undefined
|
||||
) => void;
|
||||
|
||||
export function paginationSettings(
|
||||
set: Set<PaginationTableSettings>
|
||||
set: ZustandSetFunc<PaginationTableSettings>
|
||||
): PaginationTableSettings {
|
||||
return {
|
||||
pageSize: 10,
|
||||
|
@ -23,12 +28,14 @@ export interface SortableTableSettings {
|
|||
}
|
||||
|
||||
export function sortableSettings(
|
||||
set: Set<SortableTableSettings>,
|
||||
initialSortBy = 'name',
|
||||
desc = false
|
||||
set: ZustandSetFunc<SortableTableSettings>,
|
||||
initialSortBy: string | { id: string; desc: boolean }
|
||||
): SortableTableSettings {
|
||||
return {
|
||||
sortBy: { id: initialSortBy, desc },
|
||||
sortBy:
|
||||
typeof initialSortBy === 'string'
|
||||
? { id: initialSortBy, desc: false }
|
||||
: initialSortBy,
|
||||
setSortBy: (id: string, desc: boolean) => set({ sortBy: { id, desc } }),
|
||||
};
|
||||
}
|
||||
|
@ -39,7 +46,7 @@ export interface SettableColumnsTableSettings {
|
|||
}
|
||||
|
||||
export function hiddenColumnsSettings(
|
||||
set: Set<SettableColumnsTableSettings>
|
||||
set: ZustandSetFunc<SettableColumnsTableSettings>
|
||||
): SettableColumnsTableSettings {
|
||||
return {
|
||||
hiddenColumns: [],
|
||||
|
@ -53,10 +60,38 @@ export interface RefreshableTableSettings {
|
|||
}
|
||||
|
||||
export function refreshableSettings(
|
||||
set: Set<RefreshableTableSettings>
|
||||
set: ZustandSetFunc<RefreshableTableSettings>
|
||||
): RefreshableTableSettings {
|
||||
return {
|
||||
autoRefreshRate: 0,
|
||||
setAutoRefreshRate: (autoRefreshRate: number) => set({ autoRefreshRate }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface BasicTableSettings
|
||||
extends SortableTableSettings,
|
||||
PaginationTableSettings {}
|
||||
|
||||
export function createPersistedStore<T extends BasicTableSettings>(
|
||||
storageKey: string,
|
||||
initialSortBy: string | { id: string; desc: boolean } = 'name',
|
||||
create: (set: ZustandSetFunc<T>) => Omit<T, keyof BasicTableSettings> = () =>
|
||||
({} as T)
|
||||
) {
|
||||
return createStore<T>()(
|
||||
persist(
|
||||
(set) =>
|
||||
({
|
||||
...sortableSettings(
|
||||
set as ZustandSetFunc<SortableTableSettings>,
|
||||
initialSortBy
|
||||
),
|
||||
...paginationSettings(set as ZustandSetFunc<PaginationTableSettings>),
|
||||
...create(set),
|
||||
} as T),
|
||||
{
|
||||
name: keyBuilder(storageKey),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
51
app/react/components/datatables/useGoToHighlightedRow.ts
Normal file
51
app/react/components/datatables/useGoToHighlightedRow.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import _ from 'lodash';
|
||||
import { useRef, useLayoutEffect, useEffect } from 'react';
|
||||
|
||||
export function useGoToHighlightedRow<T extends { id: string }>(
|
||||
isServerSidePagination: boolean,
|
||||
pageSize: number,
|
||||
rows: Array<T>,
|
||||
goToPage: (page: number) => void,
|
||||
highlightedItemId?: string
|
||||
) {
|
||||
const handlePageChangeRef = useRef(goToPage);
|
||||
useLayoutEffect(() => {
|
||||
handlePageChangeRef.current = goToPage;
|
||||
});
|
||||
|
||||
const highlightedItemIdRef = useRef(highlightedItemId);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isServerSidePagination &&
|
||||
highlightedItemId &&
|
||||
highlightedItemId !== highlightedItemIdRef.current
|
||||
) {
|
||||
const page = getRowPage(highlightedItemId, pageSize, rows);
|
||||
if (page) {
|
||||
handlePageChangeRef.current(page);
|
||||
}
|
||||
highlightedItemIdRef.current = highlightedItemId;
|
||||
}
|
||||
}, [highlightedItemId, isServerSidePagination, rows, pageSize]);
|
||||
}
|
||||
|
||||
function getRowPage<T extends { id: string }>(
|
||||
rowID: string,
|
||||
pageSize: number,
|
||||
rows: Array<T>
|
||||
) {
|
||||
const totalRows = rows.length;
|
||||
|
||||
if (!rowID || pageSize > totalRows) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const paginatedData = _.chunk(rows, pageSize);
|
||||
|
||||
const itemPage = paginatedData.findIndex((sub) =>
|
||||
sub.some((row) => row.id === rowID)
|
||||
);
|
||||
|
||||
return itemPage;
|
||||
}
|
|
@ -1,91 +1,34 @@
|
|||
import {
|
||||
Context,
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Context, createContext, ReactNode, useContext } from 'react';
|
||||
import { StoreApi, useStore } from 'zustand';
|
||||
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
|
||||
interface TableSettingsContextInterface<T> {
|
||||
settings: T;
|
||||
setTableSettings(partialSettings: Partial<T>): void;
|
||||
setTableSettings(mutation: (settings: T) => T): void;
|
||||
}
|
||||
|
||||
const TableSettingsContext = createContext<TableSettingsContextInterface<
|
||||
Record<string, unknown>
|
||||
> | null>(null);
|
||||
const TableSettingsContext = createContext<StoreApi<object> | null>(null);
|
||||
TableSettingsContext.displayName = 'TableSettingsContext';
|
||||
|
||||
export function useTableSettings<T>() {
|
||||
export function useTableSettings<T extends object>() {
|
||||
const Context = getContextType<T>();
|
||||
|
||||
const context = useContext(Context);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('must be nested under TableSettingsProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
return useStore(context);
|
||||
}
|
||||
|
||||
interface ProviderProps<T> {
|
||||
interface ProviderProps<T extends object> {
|
||||
children: ReactNode;
|
||||
defaults?: T;
|
||||
storageKey: string;
|
||||
settings: StoreApi<T>;
|
||||
}
|
||||
|
||||
export function TableSettingsProvider<T>({
|
||||
export function TableSettingsProvider<T extends object>({
|
||||
children,
|
||||
defaults,
|
||||
storageKey,
|
||||
settings,
|
||||
}: ProviderProps<T>) {
|
||||
const Context = getContextType<T>();
|
||||
|
||||
const [storage, setStorage] = useLocalStorage<T>(
|
||||
keyBuilder(storageKey),
|
||||
defaults as T
|
||||
);
|
||||
|
||||
const [settings, setTableSettings] = useState(storage);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(mutation: Partial<T> | ((settings: T) => T)): void => {
|
||||
setTableSettings((settings) => {
|
||||
const newTableSettings =
|
||||
mutation instanceof Function
|
||||
? mutation(settings)
|
||||
: { ...settings, ...mutation };
|
||||
|
||||
setStorage(newTableSettings);
|
||||
|
||||
return newTableSettings;
|
||||
});
|
||||
},
|
||||
[setStorage]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
setTableSettings: handleChange,
|
||||
}),
|
||||
[settings, handleChange]
|
||||
);
|
||||
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
|
||||
function keyBuilder(key: string) {
|
||||
return `datatable_TableSettings_${key}`;
|
||||
}
|
||||
return <Context.Provider value={settings}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
function getContextType<T>() {
|
||||
return TableSettingsContext as unknown as Context<
|
||||
TableSettingsContextInterface<T>
|
||||
>;
|
||||
function getContextType<T extends object>() {
|
||||
return TableSettingsContext as unknown as Context<StoreApi<T>>;
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
import { Context, createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
|
||||
interface TableSettingsContextInterface<T> {
|
||||
settings: T;
|
||||
}
|
||||
|
||||
const TableSettingsContext = createContext<TableSettingsContextInterface<
|
||||
Record<string, unknown>
|
||||
> | null>(null);
|
||||
TableSettingsContext.displayName = 'TableSettingsContext';
|
||||
|
||||
export function useTableSettings<T>() {
|
||||
const Context = getContextType<T>();
|
||||
|
||||
const context = useContext(Context);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('must be nested under TableSettingsProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ProviderProps<T> {
|
||||
children: ReactNode;
|
||||
settings: T;
|
||||
}
|
||||
|
||||
export function TableSettingsProvider<T>({
|
||||
children,
|
||||
settings,
|
||||
}: ProviderProps<T>) {
|
||||
const Context = getContextType<T>();
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
}),
|
||||
[settings]
|
||||
);
|
||||
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
function getContextType<T>() {
|
||||
return TableSettingsContext as unknown as Context<
|
||||
TableSettingsContextInterface<T>
|
||||
>;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue