1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 13:29:41 +02:00

chore(deps): upgrade react-table to v8 [EE-4837] (#8245)

This commit is contained in:
Chaim Lev-Ari 2023-05-02 13:42:16 +07:00 committed by GitHub
parent f20d3e72b9
commit 757461d58b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
140 changed files with 1805 additions and 2872 deletions

View file

@ -1,149 +1,9 @@
import { import '@tanstack/react-table';
UseColumnOrderInstanceProps,
UseColumnOrderState,
UseExpandedHooks,
UseExpandedInstanceProps,
UseExpandedOptions,
UseExpandedRowProps,
UseExpandedState,
UseFiltersColumnOptions,
UseFiltersColumnProps,
UseFiltersInstanceProps,
UseFiltersOptions,
UseFiltersState,
UseGlobalFiltersColumnOptions,
UseGlobalFiltersInstanceProps,
UseGlobalFiltersOptions,
UseGlobalFiltersState,
UseGroupByCellProps,
UseGroupByColumnOptions,
UseGroupByColumnProps,
UseGroupByHooks,
UseGroupByInstanceProps,
UseGroupByOptions,
UseGroupByRowProps,
UseGroupByState,
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseResizeColumnsColumnOptions,
UseResizeColumnsColumnProps,
UseResizeColumnsOptions,
UseResizeColumnsState,
UseRowSelectHooks,
UseRowSelectInstanceProps,
UseRowSelectOptions,
UseRowSelectRowProps,
UseRowSelectState,
UseRowStateCellProps,
UseRowStateInstanceProps,
UseRowStateOptions,
UseRowStateRowProps,
UseRowStateState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from 'react-table';
import { UseSelectColumnTableOptions } from '@lineup-lite/hooks';
declare module 'react-table' { declare module '@tanstack/table-core' {
// take this file as-is, or comment out the sections that don't apply to your plugin configuration interface ColumnMeta<TData extends RowData, TValue> {
export interface TableOptions<D extends Record<string, unknown>>
extends UseExpandedOptions<D>,
UseFiltersOptions<D>,
UseGlobalFiltersOptions<D>,
UseGroupByOptions<D>,
UsePaginationOptions<D>,
UseResizeColumnsOptions<D>,
UseRowSelectOptions<D>,
UseRowStateOptions<D>,
UseSortByOptions<D>,
UseSelectColumnTableOptions<D>,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Record<string, any> {}
export interface Hooks<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseExpandedHooks<D>,
UseGroupByHooks<D>,
UseRowSelectHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseColumnOrderInstanceProps<D>,
UseExpandedInstanceProps<D>,
UseFiltersInstanceProps<D>,
UseGlobalFiltersInstanceProps<D>,
UseGroupByInstanceProps<D>,
UsePaginationInstanceProps<D>,
UseRowSelectInstanceProps<D>,
UseRowStateInstanceProps<D>,
UseSortByInstanceProps<D> {}
export interface TableState<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseColumnOrderState<D>,
UseExpandedState<D>,
UseFiltersState<D>,
UseGlobalFiltersState<D>,
UseGroupByState<D>,
UsePaginationState<D>,
UseResizeColumnsState<D>,
UseRowSelectState<D>,
UseRowStateState<D>,
UseSortByState<D> {}
export interface ColumnInterface<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseFiltersColumnOptions<D>,
UseGlobalFiltersColumnOptions<D>,
UseGroupByColumnOptions<D>,
UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {
className?: string; className?: string;
canHide?: boolean; filter?: Filter<TData, TValue>;
} width?: number | 'auto' | string;
export interface ColumnInstance<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseFiltersColumnProps<D>,
UseGroupByColumnProps<D>,
UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {
className?: string;
}
export interface Cell<
D extends Record<string, unknown> = Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
V = any
> extends UseTableCellProps<D, V>,
UseGroupByCellProps<D>,
UseRowStateCellProps<D> {
className?: string;
}
export interface Row<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}
export function makePropGetter(
hooks: Array<PropGetter>,
...meta: Record<string, unknown>[]
): PropGetter;
export interface TableToggleRowsSelectedProps {
disabled: boolean;
} }
} }

View file

@ -1,5 +1,4 @@
import { Box, Plus, Trash2 } from 'lucide-react'; import { Box, Plus, Trash2 } from 'lucide-react';
import { useStore } from 'zustand';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
@ -9,7 +8,7 @@ import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { createPersistedStore } from '@@/datatables/types'; import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar'; import { useTableState } from '@@/datatables/useTableState';
import { columns } from './columns'; import { columns } from './columns';
@ -22,19 +21,13 @@ export interface Props {
} }
export function ContainersDatatable({ dataset, onRemoveClick }: Props) { export function ContainersDatatable({ dataset, onRemoveClick }: Props) {
const settings = useStore(settingsStore); const tableState = useTableState(settingsStore, tableKey);
const [search, setSearch] = useSearchBarState(tableKey);
return ( return (
<Datatable <Datatable
dataset={dataset} dataset={dataset}
columns={columns} columns={columns}
initialPageSize={settings.pageSize} settingsManager={tableState}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Containers" title="Containers"
titleIcon={Box} titleIcon={Box}
getRowId={(container) => container.id} getRowId={(container) => container.id}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { ContainerGroup } from '@/react/azure/types';
export const columnHelper = createColumnHelper<ContainerGroup>();

View file

@ -1,13 +1,5 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { ContainerGroup } from '@/react/azure/types'; export const location = columnHelper.accessor('location', {
header: 'Location',
export const location: Column<ContainerGroup> = { });
Header: 'Location',
accessor: (container) => container.location,
id: 'location',
disableFilters: true,
Filter: () => null,
canHide: true,
sortType: 'string',
};

View file

@ -1,24 +1,22 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
export const name: Column<ContainerGroup> = { import { columnHelper } from './helper';
Header: 'Name',
accessor: (container) => container.name, export const name = columnHelper.accessor('name', {
id: 'name', header: 'Name',
Cell: NameCell, cell: NameCell,
disableFilters: true, });
Filter: () => null,
canHide: true,
sortType: 'string',
};
export function NameCell({ export function NameCell({
value: name, getValue,
row: { original: container }, row: { original: container },
}: CellProps<ContainerGroup, string>) { }: CellContext<ContainerGroup, string>) {
const name = getValue();
return ( return (
<Link <Link
to="azure.containerinstances.container" to="azure.containerinstances.container"

View file

@ -1,30 +1,30 @@
import { Column } from 'react-table';
import clsx from 'clsx'; import clsx from 'clsx';
import { CellContext } from '@tanstack/react-table';
import { ownershipIcon } from '@/portainer/filters/filters'; import { ownershipIcon } from '@/portainer/filters/filters';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel';
export const ownership: Column<ContainerGroup> = { import { columnHelper } from './helper';
Header: 'Ownership',
id: 'ownership', export const ownership = columnHelper.accessor(
accessor: (row) => (row) =>
row.Portainer && row.Portainer.ResourceControl row.Portainer && row.Portainer.ResourceControl
? determineOwnership(row.Portainer.ResourceControl) ? determineOwnership(row.Portainer.ResourceControl)
: ResourceControlOwnership.ADMINISTRATORS, : ResourceControlOwnership.ADMINISTRATORS,
Cell: OwnershipCell, {
disableFilters: true, header: 'Ownership',
canHide: true, cell: OwnershipCell,
sortType: 'string', id: 'ownership',
Filter: () => null, }
}; );
interface Props { function OwnershipCell({
value: 'public' | 'private' | 'restricted' | 'administrators'; getValue,
} }: CellContext<ContainerGroup, ResourceControlOwnership>) {
const value = getValue();
function OwnershipCell({ value }: Props) {
return ( return (
<> <>
<i <i

View file

@ -1,25 +1,25 @@
import { CellProps, Column } from 'react-table';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { CellContext } from '@tanstack/react-table';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { getPorts } from '@/react/azure/utils'; import { getPorts } from '@/react/azure/utils';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
export const ports: Column<ContainerGroup> = { import { columnHelper } from './helper';
Header: 'Published Ports',
accessor: (container) => getPorts(container), export const ports = columnHelper.accessor(getPorts, {
header: 'Published Ports',
cell: PortsCell,
id: 'ports', id: 'ports',
disableFilters: true, });
Filter: () => null,
canHide: true,
Cell: PortsCell,
};
function PortsCell({ function PortsCell({
value: ports, getValue,
row: { original: container }, row: { original: container },
}: CellProps<ContainerGroup, ReturnType<typeof getPorts>>) { }: CellContext<ContainerGroup, ReturnType<typeof getPorts>>) {
const ports = getValue();
const ip = container.properties.ipAddress const ip = container.properties.ipAddress
? container.properties.ipAddress.ip ? container.properties.ipAddress.ip
: ''; : '';

View file

@ -19,7 +19,7 @@ export function DetailsRow({
<td className={clsx(colClassName, 'min-w-[150px] !break-normal')}> <td className={clsx(colClassName, 'min-w-[150px] !break-normal')}>
{label} {label}
</td> </td>
{children && ( {!!children && (
<td className={colClassName} data-cy={`detailsTable-${label}Value`}> <td className={colClassName} data-cy={`detailsTable-${label}Value`}>
{children} {children}
</td> </td>

View file

@ -21,7 +21,7 @@ export function DetailsTable({
</tr> </tr>
</thead> </thead>
)} )}
<tbody>{children}</tbody> {children && <tbody>{children}</tbody>}
</table> </table>
); );
} }

View file

@ -1,12 +1,12 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Menu, MenuButton, MenuList } from '@reach/menu-button'; import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { ColumnInstance } from 'react-table';
import { Columns } from 'lucide-react'; import { Columns } from 'lucide-react';
import { Column } from '@tanstack/react-table';
import { Checkbox } from '@@/form-components/Checkbox'; import { Checkbox } from '@@/form-components/Checkbox';
interface Props<D extends object> { interface Props<D extends object> {
columns: ColumnInstance<D>[]; columns: Column<D>[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
value: string[]; value: string[];
} }
@ -40,8 +40,12 @@ export function ColumnVisibilityMenu<D extends object>({
{columns.map((column) => ( {columns.map((column) => (
<div key={column.id}> <div key={column.id}>
<Checkbox <Checkbox
checked={column.isVisible} checked={column.getIsVisible()}
label={column.Header as string} label={
typeof column.columnDef.header === 'string'
? column.columnDef.header
: ''
}
id={`visibility_${column.id}`} id={`visibility_${column.id}`}
onChange={(e) => onChange={(e) =>
handleChangeColumnVisibility( handleChangeColumnVisibility(

View file

@ -1,36 +1,40 @@
import { import {
useTable, Table as TableInstance,
useFilters,
useGlobalFilter,
useSortBy,
usePagination,
Column,
Row,
TableInstance,
TableState, TableState,
TableRowProps, useReactTable,
useExpanded, Row,
} from 'react-table'; getCoreRowModel,
import { ReactNode } from 'react'; getPaginationRowModel,
import { useRowSelectColumn } from '@lineup-lite/hooks'; getFilteredRowModel,
getSortedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFacetedMinMaxValues,
getExpandedRowModel,
TableOptions,
} from '@tanstack/react-table';
import { ReactNode, useMemo } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import _ from 'lodash';
import { IconProps } from '@@/Icon'; import { IconProps } from '@@/Icon';
import { Table } from './Table';
import { multiple } from './filter-types';
import { useRowSelect } from './useRowSelect';
import { BasicTableSettings } from './types';
import { DatatableHeader } from './DatatableHeader'; import { DatatableHeader } from './DatatableHeader';
import { DatatableFooter } from './DatatableFooter'; import { DatatableFooter } from './DatatableFooter';
import { DatatableContent } from './DatatableContent';
import { defaultGetRowId } from './defaultGetRowId'; import { defaultGetRowId } from './defaultGetRowId';
import { emptyPlugin } from './emptyReactTablePlugin'; import { Table } from './Table';
import { useGoToHighlightedRow } from './useGoToHighlightedRow'; import { useGoToHighlightedRow } from './useGoToHighlightedRow';
import { BasicTableSettings } from './types';
import { DatatableContent } from './DatatableContent';
import { createSelectColumn } from './select-column';
import { TableRow } from './TableRow';
export interface Props<D extends Record<string, unknown>> { export interface Props<
D extends Record<string, unknown>,
TSettings extends BasicTableSettings = BasicTableSettings
> {
dataset: D[]; dataset: D[];
columns: readonly Column<D>[]; columns: TableOptions<D>['columns'];
renderTableSettings?(instance: TableInstance<D>): ReactNode; renderTableSettings?(instance: TableInstance<D>): ReactNode;
renderTableActions?(selectedRows: D[]): ReactNode; renderTableActions?(selectedRows: D[]): ReactNode;
disableSelect?: boolean; disableSelect?: boolean;
@ -39,29 +43,20 @@ export interface Props<D extends Record<string, unknown>> {
emptyContentLabel?: string; emptyContentLabel?: string;
title?: string; title?: string;
titleIcon?: IconProps['icon']; titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState<D>>; initialTableState?: Partial<TableState>;
isLoading?: boolean; isLoading?: boolean;
totalCount?: number; totalCount?: number;
description?: ReactNode; description?: ReactNode;
pageCount?: number; pageCount?: number;
initialSortBy?: BasicTableSettings['sortBy'];
initialPageSize?: BasicTableSettings['pageSize'];
highlightedItemId?: string; 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; onPageChange?(page: number): void;
renderRow?( settingsManager: TSettings & {
row: Row<D>, search: string;
rowProps: TableRowProps, setSearch: (value: string) => void;
highlightedItemId?: string };
): ReactNode; renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode;
expandable?: boolean; getRowCanExpand?(row: Row<D>): boolean;
noWidget?: boolean; noWidget?: boolean;
} }
@ -81,78 +76,83 @@ export function Datatable<D extends Record<string, unknown>>({
totalCount = dataset.length, totalCount = dataset.length,
description, description,
pageCount, pageCount,
onPageChange = () => null,
initialSortBy, settingsManager: settings,
initialPageSize = 10,
onPageChange = () => {},
onPageSizeChange,
onSortByChange,
searchValue,
onSearchChange,
renderRow = defaultRenderRow, renderRow = defaultRenderRow,
expandable = false,
highlightedItemId, highlightedItemId,
noWidget, noWidget,
getRowCanExpand,
}: Props<D>) { }: Props<D>) {
const isServerSidePagination = typeof pageCount !== 'undefined'; const isServerSidePagination = typeof pageCount !== 'undefined';
const enableRowSelection = getIsSelectionEnabled(
const tableInstance = useTable<D>( disableSelect,
{ isRowSelectable
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: initialPageSize,
sortBy: initialSortBy ? [initialSortBy] : [],
globalFilter: searchValue,
...initialTableState,
},
isRowSelectable,
autoResetExpanded: false,
autoResetSelectedRows: false,
getRowId,
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
},
useFilters,
useGlobalFilter,
useSortBy,
expandable ? useExpanded : emptyPlugin,
usePagination,
useRowSelect,
!disableSelect ? useRowSelectColumn : emptyPlugin
); );
const allColumns = useMemo(
() => _.compact([!disableSelect && createSelectColumn<D>(), ...columns]),
[disableSelect, columns]
);
const tableInstance = useReactTable<D>({
columns: allColumns,
data: dataset,
initialState: {
pagination: {
pageSize: settings.pageSize,
},
sorting: settings.sortBy ? [settings.sortBy] : [],
globalFilter: settings.search,
...initialTableState,
},
defaultColumn: {
enableColumnFilter: false,
enableHiding: true,
},
enableRowSelection,
autoResetExpanded: false,
globalFilterFn,
getRowId,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand,
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
});
const tableState = tableInstance.getState();
useGoToHighlightedRow( useGoToHighlightedRow(
isServerSidePagination, isServerSidePagination,
tableInstance.state.pageSize, tableState.pagination.pageSize,
tableInstance.rows, tableInstance.getCoreRowModel().rows,
handlePageChange, handlePageChange,
highlightedItemId highlightedItemId
); );
const selectedItems = tableInstance.selectedFlatRows.map( const selectedRowModel = tableInstance.getSelectedRowModel();
(row) => row.original const selectedItems = selectedRowModel.rows.map((row) => row.original);
);
return ( return (
<Table.Container noWidget={noWidget}> <Table.Container noWidget={noWidget}>
<DatatableHeader <DatatableHeader
onSearchChange={handleSearchBarChange} onSearchChange={handleSearchBarChange}
searchValue={searchValue} searchValue={settings.search}
title={title} title={title}
titleIcon={titleIcon} titleIcon={titleIcon}
description={description}
renderTableActions={() => renderTableActions(selectedItems)} renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)} renderTableSettings={() => renderTableSettings(tableInstance)}
description={description}
/> />
<DatatableContent<D> <DatatableContent<D>
tableInstance={tableInstance} tableInstance={tableInstance}
renderRow={(row, rowProps) => renderRow={(row) => renderRow(row, highlightedItemId)}
renderRow(row, rowProps, highlightedItemId)
}
emptyContentLabel={emptyContentLabel} emptyContentLabel={emptyContentLabel}
isLoading={isLoading} isLoading={isLoading}
onSortChange={handleSortChange} onSortChange={handleSortChange}
@ -161,8 +161,8 @@ export function Datatable<D extends Record<string, unknown>>({
<DatatableFooter <DatatableFooter
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
page={tableInstance.state.pageIndex} page={tableState.pagination.pageIndex}
pageSize={tableInstance.state.pageSize} pageSize={tableState.pagination.pageSize}
totalCount={totalCount} totalCount={totalCount}
totalSelected={selectedItems.length} totalSelected={selectedItems.length}
/> />
@ -171,38 +171,81 @@ export function Datatable<D extends Record<string, unknown>>({
function handleSearchBarChange(value: string) { function handleSearchBarChange(value: string) {
tableInstance.setGlobalFilter(value); tableInstance.setGlobalFilter(value);
onSearchChange(value); settings.setSearch(value);
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
tableInstance.gotoPage(page); tableInstance.setPageIndex(page);
onPageChange(page); onPageChange(page);
} }
function handleSortChange(colId: string, desc: boolean) { function handleSortChange(colId: string, desc: boolean) {
onSortByChange(colId, desc); settings.setSortBy(colId, desc);
} }
function handlePageSizeChange(pageSize: number) { function handlePageSizeChange(pageSize: number) {
tableInstance.setPageSize(pageSize); tableInstance.setPageSize(pageSize);
onPageSizeChange(pageSize); settings.setPageSize(pageSize);
} }
} }
function defaultRenderRow<D extends Record<string, unknown>>( function defaultRenderRow<D extends Record<string, unknown>>(
row: Row<D>, row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string highlightedItemId?: string
) { ) {
return ( return (
<Table.Row<D> <TableRow<D>
key={rowProps.key} cells={row.getVisibleCells()}
cells={row.cells} className={clsx({
className={clsx(rowProps.className, {
active: highlightedItemId === row.id, active: highlightedItemId === row.id,
})} })}
role={rowProps.role}
style={rowProps.style}
/> />
); );
} }
function getIsSelectionEnabled<D extends Record<string, unknown>>(
disabledSelect?: boolean,
isRowSelectable?: Props<D>['isRowSelectable']
) {
if (disabledSelect) {
return false;
}
if (isRowSelectable) {
return isRowSelectable;
}
return true;
}
function globalFilterFn<D>(
row: Row<D>,
columnId: string,
filterValue: null | string
): boolean {
const value = row.getValue(columnId);
if (filterValue === null || filterValue === '') {
return true;
}
if (value == null) {
return false;
}
const filterValueLower = filterValue.toLowerCase();
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value.toString().toLowerCase().includes(filterValueLower);
}
if (Array.isArray(value)) {
return value.some((item) => item.toLowerCase().includes(filterValueLower));
}
return false;
}

View file

@ -1,10 +1,10 @@
import { Row, TableInstance, TableRowProps } from 'react-table'; import { Row, Table as TableInstance } from '@tanstack/react-table';
import { Table } from './Table'; import { Table } from './Table';
interface Props<D extends Record<string, unknown>> { interface Props<D extends Record<string, unknown>> {
tableInstance: TableInstance<D>; tableInstance: TableInstance<D>;
renderRow(row: Row<D>, rowProps: TableRowProps): React.ReactNode; renderRow(row: Row<D>): React.ReactNode;
onSortChange?(colId: string, desc: boolean): void; onSortChange?(colId: string, desc: boolean): void;
isLoading?: boolean; isLoading?: boolean;
emptyContentLabel?: string; emptyContentLabel?: string;
@ -17,42 +17,24 @@ export function DatatableContent<D extends Record<string, unknown>>({
isLoading, isLoading,
emptyContentLabel, emptyContentLabel,
}: Props<D>) { }: Props<D>) {
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } = const headerGroups = tableInstance.getHeaderGroups();
tableInstance; const pageRowModel = tableInstance.getPaginationRowModel();
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<Table <Table>
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead> <thead>
{headerGroups.map((headerGroup) => { {headerGroups.map((headerGroup) => (
const { key, className, role, style } = <Table.HeaderRow<D>
headerGroup.getHeaderGroupProps(); key={headerGroup.id}
return ( headers={headerGroup.headers}
<Table.HeaderRow<D> onSortChange={onSortChange}
key={key} />
className={className} ))}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={onSortChange}
/>
);
})}
</thead> </thead>
<tbody <tbody>
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content<D> <Table.Content<D>
rows={page} rows={pageRowModel.rows}
isLoading={isLoading} isLoading={isLoading}
prepareRow={prepareRow}
emptyContent={emptyContentLabel} emptyContent={emptyContentLabel}
renderRow={renderRow} renderRow={renderRow}
/> />

View file

@ -1,4 +1,4 @@
import { Row } from 'react-table'; import { Row } from '@tanstack/react-table';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow'; import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
@ -7,25 +7,25 @@ import { Datatable, Props as DatatableProps } from './Datatable';
interface Props<D extends Record<string, unknown>> interface Props<D extends Record<string, unknown>>
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> { extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
renderSubRow(row: Row<D>): ReactNode; renderSubRow(row: Row<D>): ReactNode;
expandOnRowClick?: boolean;
} }
export function ExpandableDatatable<D extends Record<string, unknown>>({ export function ExpandableDatatable<D extends Record<string, unknown>>({
renderSubRow, renderSubRow,
getRowCanExpand = () => true,
expandOnRowClick,
...props ...props
}: Props<D>) { }: Props<D>) {
return ( return (
<Datatable<D> <Datatable<D>
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
expandable getRowCanExpand={getRowCanExpand}
renderRow={(row, { key, className, role, style }) => ( renderRow={(row) => (
<ExpandableDatatableTableRow<D> <ExpandableDatatableTableRow<D>
key={key}
row={row} row={row}
className={className}
role={role}
style={style}
renderSubRow={renderSubRow} renderSubRow={renderSubRow}
expandOnClick={expandOnRowClick}
/> />
)} )}
/> />

View file

@ -1,37 +1,33 @@
import { CSSProperties, ReactNode } from 'react'; import { ReactNode } from 'react';
import { Row } from 'react-table'; import { Row } from '@tanstack/react-table';
import { TableRow } from './TableRow'; import { TableRow } from './TableRow';
interface Props<D extends Record<string, unknown>> { interface Props<D extends Record<string, unknown>> {
row: Row<D>; row: Row<D>;
className?: string;
role?: string;
style?: CSSProperties;
disableSelect?: boolean; disableSelect?: boolean;
renderSubRow(row: Row<D>): ReactNode; renderSubRow(row: Row<D>): ReactNode;
expandOnClick?: boolean;
} }
export function ExpandableDatatableTableRow<D extends Record<string, unknown>>({ export function ExpandableDatatableTableRow<D extends Record<string, unknown>>({
row, row,
className,
role,
style,
disableSelect, disableSelect,
renderSubRow, renderSubRow,
expandOnClick,
}: Props<D>) { }: Props<D>) {
const cells = row.getVisibleCells();
return ( return (
<> <>
<TableRow<D> <TableRow<D>
cells={row.cells} cells={cells}
className={className} onClick={expandOnClick ? () => row.toggleExpanded() : undefined}
role={role}
style={style}
/> />
{row.isExpanded && ( {row.getIsExpanded() && (
<tr> <tr>
{!disableSelect && <td />} {!disableSelect && <td />}
<td colSpan={disableSelect ? row.cells.length : row.cells.length - 1}> <td colSpan={disableSelect ? cells.length : cells.length - 1}>
{renderSubRow(row)} {renderSubRow(row)}
</td> </td>
</tr> </tr>

View file

@ -1,9 +0,0 @@
.expand-button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}

View file

@ -1,32 +0,0 @@
import { PropsWithChildren } from 'react';
import { Row } from 'react-table';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Icon } from '@@/Icon';
import styles from './ExpandingCell.module.css';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
row: Row<D>;
showExpandArrow: boolean;
}
export function ExpandingCell<
D extends Record<string, unknown> = Record<string, unknown>
>({ row, showExpandArrow, children }: PropsWithChildren<Props<D>>) {
return (
<>
{showExpandArrow && (
<button type="button" className={styles.expandButton}>
<Icon
// eslint-disable-next-line react/jsx-props-no-spreading
{...row.getToggleRowExpandedProps()}
icon={row.isExpanded ? ChevronDown : ChevronRight}
className="mr-1"
/>
</button>
)}
{children}
</>
);
}

View file

@ -1,13 +1,11 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Menu, MenuButton, MenuPopover } from '@reach/menu-button'; import { Menu, MenuButton, MenuPopover } from '@reach/menu-button';
import { ColumnInstance } from 'react-table'; import { Column } from '@tanstack/react-table';
import { Check, Filter } from 'lucide-react'; import { Check, Filter } from 'lucide-react';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
export const DefaultFilter = filterHOC('Filter by state');
interface MultipleSelectionFilterProps { interface MultipleSelectionFilterProps {
options: string[]; options: string[];
value: string[]; value: string[];
@ -28,12 +26,12 @@ export function MultipleSelectionFilter({
<div> <div>
<Menu> <Menu>
<MenuButton <MenuButton
className={clsx('table-filter flex items-center', { className={clsx('table-filter', { 'filter-active': enabled })}
'filter-active': enabled,
})}
> >
Filter <div className="flex items-center gap-1">
<Icon icon={enabled ? Check : Filter} className="!ml-1" /> Filter
<Icon icon={enabled ? Check : Filter} />
</div>
</MenuButton> </MenuButton>
<MenuPopover className="dropdown-menu"> <MenuPopover className="dropdown-menu">
<div className="tableMenu"> <div className="tableMenu">
@ -70,27 +68,54 @@ export function MultipleSelectionFilter({
} }
} }
export function filterHOC(menuTitle: string) { export function filterHOC<TData extends Record<string, unknown>>(
menuTitle: string
) {
return function Filter({ return function Filter({
column: { filterValue, setFilter, preFilteredRows, id }, column: { getFilterValue, setFilterValue, getFacetedRowModel, id },
}: { }: {
column: ColumnInstance; column: Column<TData>;
}) { }) {
const { flatRows } = getFacetedRowModel();
const options = useMemo(() => { const options = useMemo(() => {
const options = new Set<string>(); const options = new Set<string>();
preFilteredRows.forEach((row) => { flatRows.forEach(({ getValue }) => {
options.add(row.values[id]); const value = getValue<string>(id);
options.add(value);
}); });
return Array.from(options); return Array.from(options);
}, [id, preFilteredRows]); }, [flatRows, id]);
const value = getFilterValue();
const valueAsArray = getValueAsArrayOfStrings(value);
return ( return (
<MultipleSelectionFilter <MultipleSelectionFilter
options={options} options={options}
filterKey={id} filterKey={id}
value={filterValue} value={valueAsArray}
onChange={setFilter} onChange={setFilterValue}
menuTitle={menuTitle} menuTitle={menuTitle}
/> />
); );
}; };
} }
function getValueAsArrayOfStrings(value: unknown): string[] {
if (Array.isArray(value)) {
return value;
}
if (!value || (typeof value !== 'string' && typeof value !== 'number')) {
return [];
}
if (typeof value === 'number') {
return [value.toString()];
}
return [value];
}

View file

@ -1,30 +1,36 @@
import { CellProps, Column } from 'react-table'; import { ColumnDef, CellContext } from '@tanstack/react-table';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
export function buildNameColumn<T extends Record<string, unknown>>( export function buildNameColumn<T extends Record<string, unknown>>(
nameKey: string, nameKey: keyof T,
idKey: string, idKey: string,
path: string path: string
) { ): ColumnDef<T> {
const name: Column<T> = { const cell = createCell<T>();
Header: 'Name',
accessor: (row) => row[nameKey], return {
header: 'Name',
accessorKey: nameKey,
id: 'name', id: 'name',
Cell: NameCell, cell,
disableFilters: true, enableSorting: true,
Filter: () => null, sortingFn: 'text',
canHide: false,
sortType: 'string',
}; };
return name; function createCell<T extends Record<string, unknown>>() {
return function NameCell({ renderValue, row }: CellContext<T, unknown>) {
const name = renderValue() || '';
function NameCell({ value: name, row }: CellProps<T, string>) { if (typeof name !== 'string') {
return ( return null;
<Link to={path} params={{ id: row.original[idKey] }} title={name}> }
{name}
</Link> return (
); <Link to={path} params={{ id: row.original[idKey] }} title={name}>
{name}
</Link>
);
};
} }
} }

View file

@ -1,27 +1,28 @@
import { import {
useTable, getCoreRowModel,
useFilters, getFilteredRowModel,
useSortBy, getPaginationRowModel,
Column, getSortedRowModel,
TableOptions,
TableState, TableState,
usePagination, useReactTable,
} from 'react-table'; } from '@tanstack/react-table';
import { defaultGetRowId } from './defaultGetRowId';
import { Table } from './Table'; import { Table } from './Table';
import { multiple } from './filter-types';
import { NestedTable } from './NestedTable'; import { NestedTable } from './NestedTable';
import { DatatableContent } from './DatatableContent'; import { DatatableContent } from './DatatableContent';
import { defaultGetRowId } from './defaultGetRowId'; import { BasicTableSettings } from './types';
interface Props<D extends Record<string, unknown>> { interface Props<D extends Record<string, unknown>> {
dataset: D[]; dataset: D[];
columns: readonly Column<D>[]; columns: TableOptions<D>['columns'];
getRowId?(row: D): string; getRowId?(row: D): string;
emptyContentLabel?: string; emptyContentLabel?: string;
initialTableState?: Partial<TableState<D>>; initialTableState?: Partial<TableState>;
isLoading?: boolean; isLoading?: boolean;
defaultSortBy?: string; initialSortBy?: BasicTableSettings['sortBy'];
} }
export function NestedDatatable<D extends Record<string, unknown>>({ export function NestedDatatable<D extends Record<string, unknown>>({
@ -31,25 +32,26 @@ export function NestedDatatable<D extends Record<string, unknown>>({
emptyContentLabel, emptyContentLabel,
initialTableState = {}, initialTableState = {},
isLoading, isLoading,
defaultSortBy, initialSortBy,
}: Props<D>) { }: Props<D>) {
const tableInstance = useTable<D>( const tableInstance = useReactTable<D>({
{ columns,
defaultCanFilter: false, data: dataset,
columns, initialState: {
data: dataset, sorting: initialSortBy ? [initialSortBy] : [],
filterTypes: { multiple }, ...initialTableState,
initialState: {
sortBy: defaultSortBy ? [{ id: defaultSortBy, desc: true }] : [],
...initialTableState,
},
autoResetSelectedRows: false,
getRowId,
}, },
useFilters, defaultColumn: {
useSortBy, enableColumnFilter: false,
usePagination enableHiding: false,
); },
getRowId,
autoResetExpanded: false,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return ( return (
<NestedTable> <NestedTable>
@ -58,15 +60,7 @@ export function NestedDatatable<D extends Record<string, unknown>>({
tableInstance={tableInstance} tableInstance={tableInstance}
isLoading={isLoading} isLoading={isLoading}
emptyContentLabel={emptyContentLabel} emptyContentLabel={emptyContentLabel}
renderRow={(row, { key, className, role, style }) => ( renderRow={(row) => <Table.Row<D> cells={row.getVisibleCells()} />}
<Table.Row<D>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
/> />
</Table.Container> </Table.Container>
</NestedTable> </NestedTable>

View file

@ -1,3 +1,7 @@
.inner-datatable .widget {
border: 0 !important;
}
.inner-datatable { .inner-datatable {
@apply rounded-md border border-solid border-gray-5 th-dark:border-gray-9; @apply rounded-md border border-solid border-gray-5 th-dark:border-gray-9;
overflow: hidden; overflow: hidden;

View file

@ -1,24 +1,22 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { TableProps } from 'react-table';
import { TableContainer } from './TableContainer'; import { TableContainer } from './TableContainer';
import { TableActions } from './TableActions'; import { TableActions } from './TableActions';
import { TableFooter } from './TableFooter';
import { TableTitleActions } from './TableTitleActions'; import { TableTitleActions } from './TableTitleActions';
import { TableContent } from './TableContent';
import { TableHeaderCell } from './TableHeaderCell';
import { TableSettingsMenu } from './TableSettingsMenu'; import { TableSettingsMenu } from './TableSettingsMenu';
import { TableTitle } from './TableTitle'; import { TableTitle } from './TableTitle';
import { TableContent } from './TableContent';
import { TableHeaderCell } from './TableHeaderCell';
import { TableHeaderRow } from './TableHeaderRow'; import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow'; import { TableRow } from './TableRow';
import { TableFooter } from './TableFooter';
function MainComponent({ interface Props {
children, className?: string;
className, }
role,
style, function MainComponent({ children, className }: PropsWithChildren<Props>) {
}: PropsWithChildren<TableProps>) {
return ( return (
<div className="table-responsive"> <div className="table-responsive">
<table <table
@ -26,8 +24,6 @@ function MainComponent({
'table-hover table-filters nowrap-cells table', 'table-hover table-filters nowrap-cells table',
className className
)} )}
role={role}
style={style}
> >
{children} {children}
</table> </table>

View file

@ -1,12 +1,11 @@
import { PropsWithChildren } from 'react'; import { Fragment, PropsWithChildren } from 'react';
import { Row, TableRowProps } from 'react-table'; import { Row } from '@tanstack/react-table';
interface Props<T extends Record<string, unknown> = Record<string, unknown>> { interface Props<T extends Record<string, unknown> = Record<string, unknown>> {
isLoading?: boolean; isLoading?: boolean;
rows: Row<T>[]; rows: Row<T>[];
emptyContent?: string; emptyContent?: string;
prepareRow(row: Row<T>): void; renderRow(row: Row<T>): React.ReactNode;
renderRow(row: Row<T>, rowProps: TableRowProps): React.ReactNode;
} }
export function TableContent< export function TableContent<
@ -15,7 +14,6 @@ export function TableContent<
isLoading = false, isLoading = false,
rows, rows,
emptyContent = 'No items available', emptyContent = 'No items available',
prepareRow,
renderRow, renderRow,
}: Props<T>) { }: Props<T>) {
if (isLoading) { if (isLoading) {
@ -28,11 +26,9 @@ export function TableContent<
return ( return (
<> <>
{rows.map((row) => { {rows.map((row) => (
prepareRow(row); <Fragment key={row.id}>{renderRow(row)}</Fragment>
const { key, className, role, style } = row.getRowProps(); ))}
return renderRow(row, { key, className, role, style });
})}
</> </>
); );
} }

View file

@ -1,33 +1,33 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react'; import { CSSProperties, PropsWithChildren, ReactNode } from 'react';
import { TableHeaderProps } from 'react-table';
import { TableHeaderSortIcons } from './TableHeaderSortIcons';
import styles from './TableHeaderCell.module.css'; import styles from './TableHeaderCell.module.css';
import { TableHeaderSortIcons } from './TableHeaderSortIcons';
interface Props { interface Props {
canFilter: boolean;
canSort: boolean; canSort: boolean;
headerProps: TableHeaderProps;
isSorted: boolean; isSorted: boolean;
isSortedDesc?: boolean; isSortedDesc?: boolean;
onSortClick: (desc: boolean) => void; onSortClick: (desc: boolean) => void;
render: () => ReactNode; render: () => ReactNode;
renderFilter: () => ReactNode; renderFilter?: () => ReactNode;
className?: string;
style?: CSSProperties;
} }
export function TableHeaderCell({ export function TableHeaderCell({
headerProps: { className, role, style },
canSort, canSort,
render, render,
onSortClick, onSortClick,
isSorted, isSorted,
isSortedDesc = true, isSortedDesc = true,
canFilter,
renderFilter, renderFilter,
className,
style,
}: Props) { }: Props) {
return ( return (
<th role={role} style={style} className={className}> <th style={style} className={className}>
<div className="flex h-full flex-row flex-nowrap items-center gap-1"> <div className="flex h-full flex-row flex-nowrap items-center gap-1">
<SortWrapper <SortWrapper
canSort={canSort} canSort={canSort}
@ -37,7 +37,7 @@ export function TableHeaderCell({
> >
{render()} {render()}
</SortWrapper> </SortWrapper>
{canFilter ? renderFilter() : null} {renderFilter ? renderFilter() : null}
</div> </div>
</th> </th>
); );
@ -76,7 +76,6 @@ function SortWrapper({
<TableHeaderSortIcons <TableHeaderSortIcons
sorted={isSorted} sorted={isSorted}
descending={isSorted && !!isSortedDesc} descending={isSorted && !!isSortedDesc}
className="ml-1"
/> />
</div> </div>
</button> </button>

View file

@ -1,48 +1,58 @@
import { HeaderGroup, TableHeaderProps } from 'react-table'; import { Header, flexRender } from '@tanstack/react-table';
import { filterHOC } from './Filter';
import { TableHeaderCell } from './TableHeaderCell'; import { TableHeaderCell } from './TableHeaderCell';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> { interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
headers: HeaderGroup<D>[]; headers: Header<D, unknown>[];
onSortChange?(colId: string, desc: boolean): void; onSortChange?(colId: string, desc: boolean): void;
} }
export function TableHeaderRow< export function TableHeaderRow<
D extends Record<string, unknown> = Record<string, unknown> D extends Record<string, unknown> = Record<string, unknown>
>({ >({ headers, onSortChange }: Props<D>) {
headers,
onSortChange,
className,
role,
style,
}: Props<D> & TableHeaderProps) {
return ( return (
<tr className={className} role={role} style={style}> <tr>
{headers.map((column) => ( {headers.map((header) => {
<TableHeaderCell const sortDirection = header.column.getIsSorted();
headerProps={{ const {
...column.getHeaderProps({ meta: { className, width } = { className: '', width: undefined },
className: column.className, } = header.column.columnDef;
style: {
width: column.disableResizing ? column.width : '', return (
}, <TableHeaderCell
}), className={className}
}} style={{
key={column.id} width,
canSort={column.canSort} }}
onSortClick={(desc) => { key={header.id}
column.toggleSortBy(desc); canSort={header.column.getCanSort()}
if (onSortChange) { onSortClick={(desc) => {
onSortChange(column.id, desc); header.column.toggleSorting(desc);
if (onSortChange) {
onSortChange(header.id, desc);
}
}}
isSorted={!!sortDirection}
isSortedDesc={sortDirection ? sortDirection === 'desc' : false}
render={() =>
flexRender(header.column.columnDef.header, header.getContext())
} }
}} renderFilter={
isSorted={column.isSorted} header.column.getCanFilter()
isSortedDesc={column.isSortedDesc} ? () =>
render={() => column.render('Header')} flexRender(
canFilter={!column.disableFilters} header.column.columnDef.meta?.filter ||
renderFilter={() => column.render('Filter')} filterHOC('Filter'),
/> {
))} column: header.column,
}
)
: undefined
}
/>
);
})}
</tr> </tr>
); );
} }

View file

@ -1,31 +1,25 @@
import { Cell, TableRowProps } from 'react-table'; import { Cell, flexRender } from '@tanstack/react-table';
import clsx from 'clsx';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
extends Omit<TableRowProps, 'key'> { cells: Cell<D, unknown>[];
cells: Cell<D>[]; className?: string;
onClick?: () => void;
} }
export function TableRow< export function TableRow<
D extends Record<string, unknown> = Record<string, unknown> D extends Record<string, unknown> = Record<string, unknown>
>({ cells, className, role, style }: Props<D>) { >({ cells, className, onClick }: Props<D>) {
return ( return (
<tr className={className} role={role} style={style}> <tr
{cells.map((cell) => { className={clsx(className, { 'cursor-pointer': !!onClick })}
const cellProps = cell.getCellProps({ onClick={onClick}
className: cell.className, >
}); {cells.map((cell) => (
<td key={cell.id}>
return ( {flexRender(cell.column.columnDef.cell, cell.getContext())}
<td </td>
className={cellProps.className} ))}
role={cellProps.role}
style={cellProps.style}
key={cellProps.key}
>
{cell.render('Cell')}
</td>
);
})}
</tr> </tr>
); );
} }

View file

@ -1,3 +0,0 @@
export function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View file

@ -1,49 +1,45 @@
import { ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronDown, ChevronUp } from 'lucide-react';
import { CellProps, Column, HeaderProps } from 'react-table'; import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
export function buildExpandColumn<T extends Record<string, unknown>>( export function buildExpandColumn<
isExpandable: (item: T) => boolean T extends Record<string, unknown>
): Column<T> { >(): ColumnDef<T> {
return { return {
id: 'expand', id: 'expand',
Header: ({ header: ({ table }) => {
filteredFlatRows, const hasExpandableItems = table.getExpandedRowModel().rows.length > 0;
getToggleAllRowsExpandedProps,
isAllRowsExpanded,
}: HeaderProps<T>) => {
const hasExpandableItems = filteredFlatRows.some((item) =>
isExpandable(item.original)
);
return ( return (
hasExpandableItems && ( hasExpandableItems && (
<Button <Button
// eslint-disable-next-line react/jsx-props-no-spreading onClick={table.getToggleAllRowsExpandedHandler()}
{...getToggleAllRowsExpandedProps()}
color="none" color="none"
icon={isAllRowsExpanded ? ChevronDown : ChevronUp} icon={table.getIsAllRowsExpanded() ? ChevronDown : ChevronUp}
/> />
) )
); );
}, },
Cell: ({ row }: CellProps<T>) => ( cell: ({ row }) =>
<div className="vertical-center"> row.getCanExpand() && (
{isExpandable(row.original) && ( <Button
<Button onClick={(e) => {
/* eslint-disable-next-line react/jsx-props-no-spreading */ e.preventDefault();
{...row.getToggleRowExpandedProps()} e.stopPropagation();
color="none"
icon={row.isExpanded ? ChevronDown : ChevronUp} row.toggleExpanded();
/> }}
)} color="none"
</div> icon={row.getIsExpanded() ? ChevronDown : ChevronUp}
), />
disableFilters: true, ),
Filter: () => null, enableColumnFilter: false,
canHide: false, enableGlobalFilter: false,
width: 30, enableHiding: false,
disableResizing: true,
meta: {
width: 40,
},
}; };
} }

View file

@ -1,14 +1,13 @@
import { Row } from 'react-table'; import { Row } from '@tanstack/react-table';
export function multiple< export function multiple<
D extends Record<string, unknown> = Record<string, unknown> D extends Record<string, unknown> = Record<string, unknown>
>(rows: Row<D>[], columnIds: string[], filterValue: string[] = []) { >({ getValue }: Row<D>, columnId: string, filterValue: string[]): boolean {
if (filterValue.length === 0 || columnIds.length === 0) { if (filterValue.length === 0) {
return rows; return true;
} }
return rows.filter((row) => { const value = getValue(columnId) as string;
const value = row.values[columnIds[0]];
return filterValue.includes(value); return filterValue.includes(value);
});
} }

View file

@ -0,0 +1,67 @@
import { ColumnDef, Row } from '@tanstack/react-table';
import { Checkbox } from '@@/form-components/Checkbox';
export function createSelectColumn<T>(): ColumnDef<T> {
let lastSelectedId = '';
return {
id: 'select',
header: ({ table }) => (
<Checkbox
id="select-all"
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row, table }) => (
<Checkbox
id={`select-row-${row.id}`}
checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => {
if (e.shiftKey) {
const { rows, rowsById } = table.getRowModel();
const rowsToToggle = getRowRange(rows, row.id, lastSelectedId);
const isLastSelected = rowsById[lastSelectedId].getIsSelected();
rowsToToggle.forEach((row) => row.toggleSelected(isLastSelected));
}
lastSelectedId = row.id;
}}
/>
),
meta: {
width: 50,
},
};
}
function getRowRange<T>(rows: Array<Row<T>>, idA: string, idB: string) {
const range: Array<Row<T>> = [];
let foundStart = false;
let foundEnd = false;
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
if (row.id === idA || row.id === idB) {
if (foundStart) {
foundEnd = true;
}
if (!foundStart) {
foundStart = true;
}
}
if (foundStart) {
range.push(row);
}
if (foundEnd) {
break;
}
}
return range;
}

View file

@ -13,7 +13,7 @@ export function useGoToHighlightedRow<T extends { id: string }>(
handlePageChangeRef.current = goToPage; handlePageChangeRef.current = goToPage;
}); });
const highlightedItemIdRef = useRef(highlightedItemId); const highlightedItemIdRef = useRef<string>();
useEffect(() => { useEffect(() => {
if ( if (

View file

@ -1,481 +0,0 @@
/* eslint no-param-reassign: ["error", { "props": false }] */
import { ChangeEvent, useCallback, useMemo } from 'react';
import {
actions,
makePropGetter,
ensurePluginOrder,
useGetLatest,
useMountedLayoutEffect,
Hooks,
TableInstance,
TableState,
ActionType,
ReducerTableState,
IdType,
Row,
PropGetter,
TableToggleRowsSelectedProps,
TableToggleAllRowsSelectedProps,
} from 'react-table';
type DefaultType = Record<string, unknown>;
interface UseRowSelectTableInstance<D extends DefaultType = DefaultType>
extends TableInstance<D> {
isAllRowSelected: boolean;
selectSubRows: boolean;
getSubRows(row: Row<D>): Row<D>[];
isRowSelectable?(row: Row<D>): boolean;
}
const pluginName = 'useRowSelect';
// Actions
actions.resetSelectedRows = 'resetSelectedRows';
actions.toggleAllRowsSelected = 'toggleAllRowsSelected';
actions.toggleRowSelected = 'toggleRowSelected';
actions.toggleAllPageRowsSelected = 'toggleAllPageRowsSelected';
export function useRowSelect<D extends DefaultType>(hooks: Hooks<D>) {
hooks.getToggleRowSelectedProps = [
defaultGetToggleRowSelectedProps as PropGetter<
D,
TableToggleRowsSelectedProps
>,
];
hooks.getToggleAllRowsSelectedProps = [
defaultGetToggleAllRowsSelectedProps as PropGetter<
D,
TableToggleAllRowsSelectedProps
>,
];
hooks.getToggleAllPageRowsSelectedProps = [
defaultGetToggleAllPageRowsSelectedProps as PropGetter<
D,
TableToggleAllRowsSelectedProps
>,
];
hooks.stateReducers.push(
reducer as (
newState: TableState<D>,
action: ActionType,
previousState?: TableState<D>,
instance?: TableInstance<D>
) => ReducerTableState<D> | undefined
);
hooks.useInstance.push(useInstance as (instance: TableInstance<D>) => void);
hooks.prepareRow.push(prepareRow);
}
useRowSelect.pluginName = pluginName;
function defaultGetToggleRowSelectedProps<D extends DefaultType>(
props: D,
{ instance, row }: { instance: UseRowSelectTableInstance<D>; row: Row<D> }
) {
const {
manualRowSelectedKey = 'isSelected',
isRowSelectable = defaultIsRowSelectable,
} = instance;
let checked = false;
if (row.original && row.original[manualRowSelectedKey]) {
checked = true;
} else {
checked = row.isSelected;
}
return [
props,
{
onChange: (e: ChangeEvent<HTMLInputElement>) => {
row.toggleRowSelected(e.target.checked);
},
style: {
cursor: 'pointer',
},
checked,
title: 'Toggle Row Selected',
indeterminate: row.isSomeSelected,
disabled: !isRowSelectable(row),
},
];
}
function defaultGetToggleAllRowsSelectedProps<D extends DefaultType>(
props: D,
{ instance }: { instance: UseRowSelectTableInstance<D> }
) {
return [
props,
{
onChange: (e: ChangeEvent<HTMLInputElement>) => {
instance.toggleAllRowsSelected(e.target.checked);
},
style: {
cursor: 'pointer',
},
checked: instance.isAllRowsSelected,
title: 'Toggle All Rows Selected',
indeterminate: Boolean(
!instance.isAllRowsSelected &&
Object.keys(instance.state.selectedRowIds).length
),
},
];
}
function defaultGetToggleAllPageRowsSelectedProps<D extends DefaultType>(
props: D,
{ instance }: { instance: UseRowSelectTableInstance<D> }
) {
return [
props,
{
onChange(e: ChangeEvent<HTMLInputElement>) {
instance.toggleAllPageRowsSelected(e.target.checked);
},
style: {
cursor: 'pointer',
},
checked: instance.isAllPageRowsSelected,
title: 'Toggle All Current Page Rows Selected',
indeterminate: Boolean(
!instance.isAllPageRowsSelected &&
instance.page.some(({ id }) => instance.state.selectedRowIds[id])
),
},
];
}
function reducer<D extends Record<string, unknown>>(
state: TableState<D>,
action: ActionType,
_previousState?: TableState<D>,
instance?: UseRowSelectTableInstance<D>
) {
if (action.type === actions.init) {
return {
...state,
selectedRowIds: <Record<IdType<D>, boolean>>{},
};
}
if (action.type === actions.resetSelectedRows) {
return {
...state,
selectedRowIds: instance?.initialState.selectedRowIds || {},
};
}
if (action.type === actions.toggleAllRowsSelected) {
const { value: setSelected } = action;
if (!instance) {
return state;
}
const {
isAllRowsSelected,
rowsById,
nonGroupedRowsById = rowsById,
isRowSelectable = defaultIsRowSelectable,
} = instance;
const selectAll =
typeof setSelected !== 'undefined' ? setSelected : !isAllRowsSelected;
// Only remove/add the rows that are visible on the screen
// Leave all the other rows that are selected alone.
const selectedRowIds = { ...state.selectedRowIds };
Object.keys(nonGroupedRowsById).forEach((rowId: IdType<D>) => {
if (selectAll) {
const row = rowsById[rowId];
if (isRowSelectable(row)) {
selectedRowIds[rowId] = true;
}
} else {
delete selectedRowIds[rowId];
}
});
return {
...state,
selectedRowIds,
};
}
if (action.type === actions.toggleRowSelected) {
if (!instance) {
return state;
}
const { id, value: setSelected } = action;
const {
rowsById,
selectSubRows = true,
getSubRows,
isRowSelectable = defaultIsRowSelectable,
} = instance;
const isSelected = state.selectedRowIds[id];
const shouldExist =
typeof setSelected !== 'undefined' ? setSelected : !isSelected;
if (isSelected === shouldExist) {
return state;
}
const newSelectedRowIds = { ...state.selectedRowIds };
// eslint-disable-next-line no-inner-declarations
function handleRowById(id: IdType<D>) {
const row = rowsById[id];
if (!isRowSelectable(row)) {
return;
}
if (!row.isGrouped) {
if (shouldExist) {
newSelectedRowIds[id] = true;
} else {
delete newSelectedRowIds[id];
}
}
if (selectSubRows && getSubRows(row)) {
getSubRows(row).forEach((row) => handleRowById(row.id));
}
}
handleRowById(id);
return {
...state,
selectedRowIds: newSelectedRowIds,
};
}
if (action.type === actions.toggleAllPageRowsSelected) {
if (!instance) {
return state;
}
const { value: setSelected } = action;
const {
page,
rowsById,
selectSubRows = true,
isAllPageRowsSelected,
getSubRows,
} = instance;
const selectAll =
typeof setSelected !== 'undefined' ? setSelected : !isAllPageRowsSelected;
const newSelectedRowIds = { ...state.selectedRowIds };
// eslint-disable-next-line no-inner-declarations
function handleRowById(id: IdType<D>) {
const row = rowsById[id];
if (!row.isGrouped) {
if (selectAll) {
newSelectedRowIds[id] = true;
} else {
delete newSelectedRowIds[id];
}
}
if (selectSubRows && getSubRows(row)) {
getSubRows(row).forEach((row) => handleRowById(row.id));
}
}
page.forEach((row) => handleRowById(row.id));
return {
...state,
selectedRowIds: newSelectedRowIds,
};
}
return state;
}
function useInstance<D extends Record<string, unknown>>(
instance: UseRowSelectTableInstance<D>
) {
const {
data,
rows,
getHooks,
plugins,
rowsById,
nonGroupedRowsById = rowsById,
autoResetSelectedRows = true,
state: { selectedRowIds },
selectSubRows = true,
dispatch,
page,
getSubRows,
isRowSelectable = defaultIsRowSelectable,
} = instance;
ensurePluginOrder(
plugins,
['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'],
'useRowSelect'
);
const selectedFlatRows = useMemo(() => {
const selectedFlatRows = <Array<Row<D>>>[];
rows.forEach((row) => {
const isSelected = selectSubRows
? getRowIsSelected(row, selectedRowIds, getSubRows)
: !!selectedRowIds[row.id];
row.isSelected = !!isSelected;
row.isSomeSelected = isSelected === null;
if (isSelected) {
selectedFlatRows.push(row);
}
});
return selectedFlatRows;
}, [rows, selectSubRows, selectedRowIds, getSubRows]);
let isAllRowsSelected = Boolean(
Object.keys(nonGroupedRowsById).length && Object.keys(selectedRowIds).length
);
let isAllPageRowsSelected = isAllRowsSelected;
if (isAllRowsSelected) {
if (
Object.keys(nonGroupedRowsById).some((id) => {
const row = rowsById[id];
return !selectedRowIds[id] && isRowSelectable(row);
})
) {
isAllRowsSelected = false;
}
}
if (!isAllRowsSelected) {
if (
page &&
page.length &&
page.some(({ id }) => {
const row = rowsById[id];
return !selectedRowIds[id] && isRowSelectable(row);
})
) {
isAllPageRowsSelected = false;
}
}
const getAutoResetSelectedRows = useGetLatest(autoResetSelectedRows);
useMountedLayoutEffect(() => {
if (getAutoResetSelectedRows()) {
dispatch({ type: actions.resetSelectedRows });
}
}, [dispatch, data]);
const toggleAllRowsSelected = useCallback(
(value) => dispatch({ type: actions.toggleAllRowsSelected, value }),
[dispatch]
);
const toggleAllPageRowsSelected = useCallback(
(value) => dispatch({ type: actions.toggleAllPageRowsSelected, value }),
[dispatch]
);
const toggleRowSelected = useCallback(
(id, value) => dispatch({ type: actions.toggleRowSelected, id, value }),
[dispatch]
);
const getInstance = useGetLatest(instance);
const getToggleAllRowsSelectedProps = makePropGetter(
getHooks().getToggleAllRowsSelectedProps,
{ instance: getInstance() }
);
const getToggleAllPageRowsSelectedProps = makePropGetter(
getHooks().getToggleAllPageRowsSelectedProps,
{ instance: getInstance() }
);
Object.assign(instance, {
selectedFlatRows,
isAllRowsSelected,
isAllPageRowsSelected,
toggleRowSelected,
toggleAllRowsSelected,
getToggleAllRowsSelectedProps,
getToggleAllPageRowsSelectedProps,
toggleAllPageRowsSelected,
});
}
function prepareRow<D extends Record<string, unknown>>(
row: Row<D>,
{ instance }: { instance: TableInstance<D> }
) {
row.toggleRowSelected = (set) => instance.toggleRowSelected(row.id, set);
row.getToggleRowSelectedProps = makePropGetter(
instance.getHooks().getToggleRowSelectedProps,
{ instance, row }
);
}
function getRowIsSelected<D extends Record<string, unknown>>(
row: Row<D>,
selectedRowIds: Record<IdType<D>, boolean>,
getSubRows: (row: Row<D>) => Array<Row<D>>
) {
if (selectedRowIds[row.id]) {
return true;
}
const subRows = getSubRows(row);
if (subRows && subRows.length) {
let allChildrenSelected = true;
let someSelected = false;
subRows.forEach((subRow) => {
// Bail out early if we know both of these
if (someSelected && !allChildrenSelected) {
return;
}
if (getRowIsSelected(subRow, selectedRowIds, getSubRows)) {
someSelected = true;
} else {
allChildrenSelected = false;
}
});
if (allChildrenSelected) {
return true;
}
return someSelected ? null : false;
}
return false;
}
function defaultIsRowSelectable<D extends DefaultType>(row: Row<D>) {
return !row.original.disabled;
}

View file

@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { useStore } from 'zustand';
import { useSearchBarState } from './SearchBar';
import { BasicTableSettings, createPersistedStore } from './types';
/** this class is just a dummy class to get return type of createPersistedStore
* can be fixed after upgrade to ts 4.7+
* https://stackoverflow.com/a/64919133
*/
class Wrapper<T extends BasicTableSettings> {
// eslint-disable-next-line class-methods-use-this
wrapped() {
return createPersistedStore<T>('', '');
}
}
export function useTableState<
TSettings extends BasicTableSettings = BasicTableSettings
>(store: ReturnType<Wrapper<TSettings>['wrapped']>, storageKey: string) {
const settings = useStore(store);
const [search, setSearch] = useSearchBarState(storageKey);
return useMemo(
() => ({ ...settings, setSearch, search }),
[settings, search, setSearch]
);
}

View file

@ -0,0 +1,38 @@
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { ownershipIcon } from '@/portainer/filters/filters';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { Icon } from '@@/Icon';
export interface IResource {
ResourceControl?: {
Ownership: ResourceControlOwnership;
};
}
export function createOwnershipColumn<D extends IResource>(): ColumnDef<
D,
ResourceControlOwnership
> {
return {
accessorFn: (row) =>
row.ResourceControl?.Ownership || ResourceControlOwnership.ADMINISTRATORS,
header: 'Ownership',
id: 'ownership',
cell: OwnershipCell,
};
function OwnershipCell({
getValue,
}: CellContext<D, ResourceControlOwnership>) {
const value = getValue();
return (
<span className="flex items-center gap-2">
<Icon icon={ownershipIcon(value)} className="space-right" />
{value}
</span>
);
}
}

View file

@ -1,19 +1,17 @@
import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'lucide-react'; import { Box } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import type { DockerContainer } from '@/react/docker/containers/types'; import type { DockerContainer } from '@/react/docker/containers/types';
import { useShowGPUsColumn } from '@/react/docker/containers/utils'; import { useShowGPUsColumn } from '@/react/docker/containers/utils';
import { TableSettingsMenu, Datatable } from '@@/datatables'; import { Table, Datatable } from '@@/datatables';
import { import {
buildAction, buildAction,
QuickActionsSettings, QuickActionsSettings,
} from '@@/datatables/QuickActionsSettings'; } from '@@/datatables/QuickActionsSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu'; import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useTableState } from '@@/datatables/useTableState';
import { useContainers } from '../../queries/containers'; import { useContainers } from '../../queries/containers';
@ -43,20 +41,15 @@ export function ContainersDatatable({
isHostColumnVisible, isHostColumnVisible,
environment, environment,
}: Props) { }: Props) {
const settings = useStore(settingsStore);
const isGPUsColumnVisible = useShowGPUsColumn(environment.Id); const isGPUsColumnVisible = useShowGPUsColumn(environment.Id);
const columns = useColumns(isHostColumnVisible, isGPUsColumnVisible); const columns = useColumns(isHostColumnVisible, isGPUsColumnVisible);
const hidableColumns = _.compact( const tableState = useTableState(settingsStore, storageKey);
columns.filter((col) => col.canHide).map((col) => col.id)
);
const [search, setSearch] = useSearchBarState(storageKey);
const containersQuery = useContainers( const containersQuery = useContainers(
environment.Id, environment.Id,
true, true,
undefined, undefined,
settings.autoRefreshRate * 1000 tableState.autoRefreshRate * 1000
); );
return ( return (
@ -65,12 +58,7 @@ export function ContainersDatatable({
<Datatable <Datatable
titleIcon={Box} titleIcon={Box}
title="Containers" title="Containers"
initialPageSize={settings.pageSize} settingsManager={tableState}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
columns={columns} columns={columns}
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
<ContainersDatatableActions <ContainersDatatableActions
@ -81,30 +69,38 @@ export function ContainersDatatable({
)} )}
isLoading={containersQuery.isLoading} isLoading={containersQuery.isLoading}
isRowSelectable={(row) => !row.original.IsPortainer} isRowSelectable={(row) => !row.original.IsPortainer}
initialTableState={{ hiddenColumns: settings.hiddenColumns }} initialTableState={{
columnVisibility: Object.fromEntries(
tableState.hiddenColumns.map((col) => [col, false])
),
}}
renderTableSettings={(tableInstance) => { renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter( const columnsToHide = tableInstance
(colInstance) => hidableColumns?.includes(colInstance.id) .getAllColumns()
); .filter((col) => col.getCanHide());
return ( return (
<> <>
<ColumnVisibilityMenu<DockerContainer> <ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide} columns={columnsToHide}
onChange={(hiddenColumns) => { onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns); tableState.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns); tableInstance.setColumnVisibility(
Object.fromEntries(
hiddenColumns.map((col) => [col, false])
)
);
}} }}
value={settings.hiddenColumns} value={tableState.hiddenColumns}
/> />
<TableSettingsMenu <Table.SettingsMenu
quickActions={<QuickActionsSettings actions={actions} />} quickActions={<QuickActionsSettings actions={actions} />}
> >
<ContainersDatatableSettings <ContainersDatatableSettings
isRefreshVisible isRefreshVisible
settings={settings} settings={tableState}
/> />
</TableSettingsMenu> </Table.SettingsMenu>
</> </>
); );
}} }}

View file

@ -1,14 +1,9 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters'; import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import type { DockerContainer } from '@/react/docker/containers/types';
export const created: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'Created',
accessor: 'Created', export const created = columnHelper.accessor('Created', {
header: 'Created',
id: 'created', id: 'created',
Cell: ({ value }) => isoDateFromTimestamp(value), cell: ({ getValue }) => isoDateFromTimestamp(getValue()),
disableFilters: true, });
canHide: true,
Filter: () => null,
};

View file

@ -1,21 +1,20 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import type { DockerContainer } from '@/react/docker/containers/types'; import type { DockerContainer } from '@/react/docker/containers/types';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useContainerGpus } from '@/react/docker/containers/queries/gpus'; import { useContainerGpus } from '@/react/docker/containers/queries/gpus';
export const gpus: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'GPUs',
export const gpus = columnHelper.display({
header: 'GPUs',
id: 'gpus', id: 'gpus',
disableFilters: true, cell: GpusCell,
canHide: true, });
Filter: () => null,
Cell: GpusCell,
};
function GpusCell({ function GpusCell({
row: { original: container }, row: { original: container },
}: CellProps<DockerContainer>) { }: CellContext<DockerContainer, unknown>) {
const containerId = container.Id; const containerId = container.Id;
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const gpusQuery = useContainerGpus(environmentId, containerId); const gpusQuery = useContainerGpus(environmentId, containerId);

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { DockerContainer } from '../../../types';
export const columnHelper = createColumnHelper<DockerContainer>();

View file

@ -1,13 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import type { DockerContainer } from '@/react/docker/containers/types'; export const host = columnHelper.accessor((row) => row.NodeName || '-', {
header: 'Host',
export const host: Column<DockerContainer> = {
Header: 'Host',
accessor: (row) => row.NodeName || '-',
id: 'host', id: 'host',
disableFilters: true, });
canHide: true,
sortType: 'string',
Filter: () => null,
};

View file

@ -1,24 +1,18 @@
import { Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { useSref } from '@uirouter/react'; import { useSref } from '@uirouter/react';
import type { DockerContainer } from '@/react/docker/containers/types'; import type { DockerContainer } from '@/react/docker/containers/types';
export const image: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'Image',
accessor: 'Image', export const image = columnHelper.accessor('Image', {
header: 'Image',
id: 'image', id: 'image',
disableFilters: true, cell: ImageCell,
Cell: ImageCell, });
canHide: true,
sortType: 'string',
Filter: () => null,
};
interface Props { function ImageCell({ getValue }: CellContext<DockerContainer, string>) {
value: string; const imageName = getValue();
}
function ImageCell({ value: imageName }: Props) {
const linkProps = useSref('docker.images.image', { id: imageName }); const linkProps = useSref('docker.images.image', { id: imageName });
const shortImageName = trimSHASum(imageName); const shortImageName = trimSHASum(imageName);

View file

@ -1,12 +1,14 @@
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { createOwnershipColumn } from '@/react/docker/components/datatable-helpers/createOwnershipColumn';
import { DockerContainer } from '@/react/docker/containers/types';
import { created } from './created'; import { created } from './created';
import { host } from './host'; import { host } from './host';
import { image } from './image'; import { image } from './image';
import { ip } from './ip'; import { ip } from './ip';
import { name } from './name'; import { name } from './name';
import { ownership } from './ownership';
import { ports } from './ports'; import { ports } from './ports';
import { quickActions } from './quick-actions'; import { quickActions } from './quick-actions';
import { stack } from './stack'; import { stack } from './stack';
@ -15,7 +17,7 @@ import { gpus } from './gpus';
export function useColumns( export function useColumns(
isHostColumnVisible: boolean, isHostColumnVisible: boolean,
isGPUsColumnVisible?: boolean isGPUsColumnVisible: boolean | undefined
) { ) {
return useMemo( return useMemo(
() => () =>
@ -30,7 +32,7 @@ export function useColumns(
isHostColumnVisible && host, isHostColumnVisible && host,
isGPUsColumnVisible && gpus, isGPUsColumnVisible && gpus,
ports, ports,
ownership, createOwnershipColumn<DockerContainer>(),
]), ]),
[isHostColumnVisible, isGPUsColumnVisible] [isHostColumnVisible, isGPUsColumnVisible]
); );

View file

@ -1,12 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import type { DockerContainer } from '@/react/docker/containers/types'; export const ip = columnHelper.accessor((row) => row.IP || '-', {
header: 'IP Address',
export const ip: Column<DockerContainer> = {
Header: 'IP Address',
accessor: (row) => row.IP || '-',
id: 'ip', id: 'ip',
disableFilters: true, });
canHide: true,
Filter: () => null,
};

View file

@ -1,4 +1,4 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import _ from 'lodash'; import _ from 'lodash';
import { useSref } from '@uirouter/react'; import { useSref } from '@uirouter/react';
@ -8,21 +8,20 @@ import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types'; import { TableSettings } from '../types';
export const name: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'Name',
accessor: (row) => row.Names[0], export const name = columnHelper.accessor((row) => row.Names[0], {
header: 'Name',
id: 'name', id: 'name',
Cell: NameCell, cell: NameCell,
disableFilters: true, });
Filter: () => null,
canHide: true,
sortType: 'string',
};
export function NameCell({ export function NameCell({
value: name, getValue,
row: { original: container }, row: { original: container },
}: CellProps<DockerContainer>) { }: CellContext<DockerContainer, string>) {
const name = getValue();
const linkProps = useSref('.container', { const linkProps = useSref('.container', {
id: container.Id, id: container.Id,
nodeName: container.NodeName, nodeName: container.NodeName,

View file

@ -1,34 +0,0 @@
import { Column } from 'react-table';
import clsx from 'clsx';
import { ownershipIcon } from '@/portainer/filters/filters';
import type { DockerContainer } from '@/react/docker/containers/types';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
export const ownership: Column<DockerContainer> = {
Header: 'Ownership',
id: 'ownership',
accessor: (row) =>
row.ResourceControl?.Ownership || ResourceControlOwnership.ADMINISTRATORS,
Cell: OwnershipCell,
disableFilters: true,
canHide: true,
sortType: 'string',
Filter: () => null,
};
interface Props {
value: 'public' | 'private' | 'restricted' | 'administrators';
}
function OwnershipCell({ value }: Props) {
return (
<>
<i
className={clsx(ownershipIcon(value), 'space-right')}
aria-hidden="true"
/>
{value || ResourceControlOwnership.ADMINISTRATORS}
</>
);
}

View file

@ -1,6 +1,6 @@
import { Column } from 'react-table';
import _ from 'lodash'; import _ from 'lodash';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { CellContext } from '@tanstack/react-table';
import type { DockerContainer, Port } from '@/react/docker/containers/types'; import type { DockerContainer, Port } from '@/react/docker/containers/types';
@ -8,22 +8,17 @@ import { Icon } from '@@/Icon';
import { useRowContext } from '../RowContext'; import { useRowContext } from '../RowContext';
export const ports: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'Published Ports',
accessor: 'Ports', export const ports = columnHelper.accessor('Ports', {
header: 'Published Ports',
id: 'ports', id: 'ports',
Cell: PortsCell, cell: PortsCell,
disableSortBy: true, });
disableFilters: true,
canHide: true,
Filter: () => null,
};
interface Props { function PortsCell({ getValue }: CellContext<DockerContainer, Port[]>) {
value: Port[]; const ports = getValue();
}
function PortsCell({ value: ports }: Props) {
const { environment } = useRowContext(); const { environment } = useRowContext();
if (ports.length === 0) { if (ports.length === 0) {

View file

@ -1,4 +1,4 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { useAuthorizations } from '@/react/hooks/useUser'; import { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
@ -8,20 +8,17 @@ import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types'; import { TableSettings } from '../types';
export const quickActions: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'Quick Actions',
export const quickActions = columnHelper.display({
header: 'Quick Actions',
id: 'actions', id: 'actions',
Cell: QuickActionsCell, cell: QuickActionsCell,
disableFilters: true, });
disableSortBy: true,
canHide: true,
sortType: 'string',
Filter: () => null,
};
function QuickActionsCell({ function QuickActionsCell({
row: { original: container }, row: { original: container },
}: CellProps<DockerContainer>) { }: CellContext<DockerContainer, unknown>) {
const settings = useTableSettings<TableSettings>(); const settings = useTableSettings<TableSettings>();
const { hiddenQuickActions = [] } = settings; const { hiddenQuickActions = [] } = settings;

View file

@ -1,13 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import type { DockerContainer } from '@/react/docker/containers/types'; export const stack = columnHelper.accessor((row) => row.StackName || '-', {
header: 'Stack',
export const stack: Column<DockerContainer> = {
Header: 'Stack',
accessor: (row) => row.StackName || '-',
id: 'stack', id: 'stack',
sortType: 'string', });
disableFilters: true,
canHide: true,
Filter: () => null,
};

View file

@ -1,27 +1,32 @@
import { CellProps, Column } from 'react-table';
import clsx from 'clsx'; import clsx from 'clsx';
import { CellContext } from '@tanstack/react-table';
import { import {
type DockerContainer, type DockerContainer,
ContainerStatus, ContainerStatus,
} from '@/react/docker/containers/types'; } from '@/react/docker/containers/types';
import { DefaultFilter } from '@@/datatables/Filter'; import { filterHOC } from '@@/datatables/Filter';
import { multiple } from '@@/datatables/filter-types';
export const state: Column<DockerContainer> = { import { columnHelper } from './helper';
Header: 'State',
accessor: 'Status', export const state = columnHelper.accessor('Status', {
header: 'State',
id: 'state', id: 'state',
Cell: StatusCell, cell: StatusCell,
sortType: 'string', enableColumnFilter: true,
filter: 'multiple', filterFn: multiple,
Filter: DefaultFilter, meta: {
canHide: true, filter: filterHOC('Filter by state'),
}; },
});
function StatusCell({ function StatusCell({
value: status, getValue,
}: CellProps<DockerContainer, ContainerStatus>) { }: CellContext<DockerContainer, ContainerStatus>) {
const status = getValue();
const hasHealthCheck = [ const hasHealthCheck = [
ContainerStatus.Starting, ContainerStatus.Starting,
ContainerStatus.Healthy, ContainerStatus.Healthy,

View file

@ -9,7 +9,7 @@ import { QuickAction, TableSettings } from './types';
export const TRUNCATE_LENGTH = 32; export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) { export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({ return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...hiddenColumnsSettings(set), ...hiddenColumnsSettings(set),
...refreshableSettings(set), ...refreshableSettings(set),
truncateContainerName: TRUNCATE_LENGTH, truncateContainerName: TRUNCATE_LENGTH,

View file

@ -7,7 +7,7 @@ import { TableContainer, TableTitle } from '@@/datatables';
import { DetailsTable } from '@@/DetailsTable'; import { DetailsTable } from '@@/DetailsTable';
interface DockerImage { interface DockerImage {
Command: Array<string>; Command: null | Array<string>;
Entrypoint: Array<string>; Entrypoint: Array<string>;
ExposedPorts: Array<number>; ExposedPorts: Array<number>;
Volumes: Array<string>; Volumes: Array<string>;
@ -24,7 +24,7 @@ export function DockerfileDetails({ image }: Props) {
<TableTitle label="Dockerfile details" icon={List} /> <TableTitle label="Dockerfile details" icon={List} />
<DetailsTable> <DetailsTable>
<DetailsTable.Row label="CMD"> <DetailsTable.Row label="CMD">
<code>{joinCommand(image.Command)}</code> <code>{image.Command ? joinCommand(image.Command) : '-'}</code>
</DetailsTable.Row> </DetailsTable.Row>
{image.Entrypoint && ( {image.Entrypoint && (

View file

@ -4,7 +4,7 @@ import { Authorized } from '@/react/hooks/useUser';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { Icon } from '@/react/components/Icon'; import { Icon } from '@/react/components/Icon';
import { Table, TableContainer, TableTitle } from '@@/datatables'; import { TableContainer, TableTitle } from '@@/datatables';
import { DetailsTable } from '@@/DetailsTable'; import { DetailsTable } from '@@/DetailsTable';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
@ -42,53 +42,51 @@ export function NetworkContainersTable({
return ( return (
<TableContainer> <TableContainer>
<TableTitle label="Containers in network" icon={Server} /> <TableTitle label="Containers in network" icon={Server} />
<Table className="nopadding"> <DetailsTable
<DetailsTable headers={tableHeaders}
headers={tableHeaders} dataCy="networkDetails-networkContainers"
dataCy="networkDetails-networkContainers" >
> {networkContainers.map((container) => (
{networkContainers.map((container) => ( <tr key={container.Id}>
<tr key={container.Id}> <td>
<td> <Link
<Link to="docker.containers.container"
to="docker.containers.container" params={{
params={{ id: container.Id,
id: container.Id, nodeName,
nodeName, }}
title={container.Name}
>
{container.Name}
</Link>
</td>
<td>{container.IPv4Address || '-'}</td>
<td>{container.IPv6Address || '-'}</td>
<td>{container.MacAddress || '-'}</td>
<td>
<Authorized authorizations="DockerNetworkDisconnect">
<Button
data-cy={`networkDetails-disconnect${container.Name}`}
size="xsmall"
color="dangerlight"
onClick={() => {
if (container.Id) {
disconnectContainer.mutate({
containerId: container.Id,
environmentId,
networkId,
});
}
}} }}
title={container.Name}
> >
{container.Name} <Icon icon={Trash2} class-name="icon-secondary icon-md" />
</Link> Leave Network
</td> </Button>
<td>{container.IPv4Address || '-'}</td> </Authorized>
<td>{container.IPv6Address || '-'}</td> </td>
<td>{container.MacAddress || '-'}</td> </tr>
<td> ))}
<Authorized authorizations="DockerNetworkDisconnect"> </DetailsTable>
<Button
data-cy={`networkDetails-disconnect${container.Name}`}
size="xsmall"
color="dangerlight"
onClick={() => {
if (container.Id) {
disconnectContainer.mutate({
containerId: container.Id,
environmentId,
networkId,
});
}
}}
>
<Icon icon={Trash2} class-name="icon-secondary icon-md" />
Leave Network
</Button>
</Authorized>
</td>
</tr>
))}
</DetailsTable>
</Table>
</TableContainer> </TableContainer>
); );
} }

View file

@ -4,7 +4,7 @@ import { Share2, Trash2 } from 'lucide-react';
import DockerNetworkHelper from '@/docker/helpers/networkHelper'; import DockerNetworkHelper from '@/docker/helpers/networkHelper';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import { Table, TableContainer, TableTitle } from '@@/datatables'; import { TableContainer, TableTitle } from '@@/datatables';
import { DetailsTable } from '@@/DetailsTable'; import { DetailsTable } from '@@/DetailsTable';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
@ -32,76 +32,74 @@ export function NetworkDetailsTable({
return ( return (
<TableContainer> <TableContainer>
<TableTitle label="Network details" icon={Share2} /> <TableTitle label="Network details" icon={Share2} />
<Table className="nopadding"> <DetailsTable dataCy="networkDetails-detailsTable">
<DetailsTable dataCy="networkDetails-detailsTable"> {/* networkRowContent */}
{/* networkRowContent */} <DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row>
<DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row> <DetailsTable.Row label="Id">
<DetailsTable.Row label="Id"> {network.Id}
{network.Id} {allowRemoveNetwork && (
{allowRemoveNetwork && ( <Authorized authorizations="DockerNetworkDelete">
<Authorized authorizations="DockerNetworkDelete"> <Button
<Button data-cy="networkDetails-deleteNetwork"
data-cy="networkDetails-deleteNetwork" size="xsmall"
size="xsmall" color="danger"
color="danger" onClick={() => onRemoveNetworkClicked()}
onClick={() => onRemoveNetworkClicked()} >
> <Icon
<Icon icon={Trash2}
icon={Trash2} className="space-right"
className="space-right" aria-hidden="true"
aria-hidden="true" />
/> Delete this network
Delete this network </Button>
</Button> </Authorized>
</Authorized> )}
)} </DetailsTable.Row>
</DetailsTable.Row> <DetailsTable.Row label="Driver">{network.Driver}</DetailsTable.Row>
<DetailsTable.Row label="Driver">{network.Driver}</DetailsTable.Row> <DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row>
<DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row> <DetailsTable.Row label="Attachable">
<DetailsTable.Row label="Attachable"> {String(network.Attachable)}
{String(network.Attachable)} </DetailsTable.Row>
</DetailsTable.Row> <DetailsTable.Row label="Internal">
<DetailsTable.Row label="Internal"> {String(network.Internal)}
{String(network.Internal)} </DetailsTable.Row>
</DetailsTable.Row>
{/* IPV4 ConfigRowContent */} {/* IPV4 ConfigRowContent */}
{ipv4Configs.map((config) => ( {ipv4Configs.map((config) => (
<Fragment key={config.Subnet}> <Fragment key={config.Subnet}>
<DetailsTable.Row <DetailsTable.Row
label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`} label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`}
> >
{`IPV4 Gateway${getConfigDetails(config.Gateway)}`} {`IPV4 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row> </DetailsTable.Row>
<DetailsTable.Row <DetailsTable.Row
label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`} label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`}
> >
{`IPV4 Excluded IPs${getAuxiliaryAddresses( {`IPV4 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses config.AuxiliaryAddresses
)}`} )}`}
</DetailsTable.Row> </DetailsTable.Row>
</Fragment> </Fragment>
))} ))}
{/* IPV6 ConfigRowContent */} {/* IPV6 ConfigRowContent */}
{ipv6Configs.map((config) => ( {ipv6Configs.map((config) => (
<Fragment key={config.Subnet}> <Fragment key={config.Subnet}>
<DetailsTable.Row <DetailsTable.Row
label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`} label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`}
> >
{`IPV6 Gateway${getConfigDetails(config.Gateway)}`} {`IPV6 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row> </DetailsTable.Row>
<DetailsTable.Row <DetailsTable.Row
label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`} label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`}
> >
{`IPV6 Excluded IPs${getAuxiliaryAddresses( {`IPV6 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses config.AuxiliaryAddresses
)}`} )}`}
</DetailsTable.Row> </DetailsTable.Row>
</Fragment> </Fragment>
))} ))}
</DetailsTable> </DetailsTable>
</Table>
</TableContainer> </TableContainer>
); );

View file

@ -1,6 +1,6 @@
import { Share2 } from 'lucide-react'; import { Share2 } from 'lucide-react';
import { Table, TableContainer, TableTitle } from '@@/datatables'; import { TableContainer, TableTitle } from '@@/datatables';
import { DetailsTable } from '@@/DetailsTable'; import { DetailsTable } from '@@/DetailsTable';
import { NetworkOptions } from '../types'; import { NetworkOptions } from '../types';
@ -19,15 +19,13 @@ export function NetworkOptionsTable({ options }: Props) {
return ( return (
<TableContainer> <TableContainer>
<TableTitle label="Network options" icon={Share2} /> <TableTitle label="Network options" icon={Share2} />
<Table className="nopadding"> <DetailsTable dataCy="networkDetails-networkOptionsTable">
<DetailsTable dataCy="networkDetails-networkOptionsTable"> {networkEntries.map(([key, value]) => (
{networkEntries.map(([key, value]) => ( <DetailsTable.Row key={key} label={key}>
<DetailsTable.Row key={key} label={key}> {value}
{value} </DetailsTable.Row>
</DetailsTable.Row> ))}
))} </DetailsTable>
</DetailsTable>
</Table>
</TableContainer> </TableContainer>
); );
} }

View file

@ -1,5 +1,3 @@
import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'lucide-react'; import { Box } from 'lucide-react';
import { DockerContainer } from '@/react/docker/containers/types'; import { DockerContainer } from '@/react/docker/containers/types';
@ -10,17 +8,16 @@ import { ContainersDatatableActions } from '@/react/docker/containers/ListView/C
import { ContainersDatatableSettings } from '@/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableSettings'; import { ContainersDatatableSettings } from '@/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableSettings';
import { useShowGPUsColumn } from '@/react/docker/containers/utils'; import { useShowGPUsColumn } from '@/react/docker/containers/utils';
import { Datatable, TableSettingsMenu } from '@@/datatables'; import { Datatable, Table } from '@@/datatables';
import { import {
buildAction, buildAction,
QuickActionsSettings, QuickActionsSettings,
} from '@@/datatables/QuickActionsSettings'; } from '@@/datatables/QuickActionsSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu'; import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useTableState } from '@@/datatables/useTableState';
import { useContainers } from '../../containers/queries/containers'; import { useContainers } from '../../containers/queries/containers';
import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext';
const storageKey = 'stack-containers'; const storageKey = 'stack-containers';
const settingsStore = createStore(storageKey); const settingsStore = createStore(storageKey);
@ -39,74 +36,68 @@ export interface Props {
} }
export function StackContainersDatatable({ environment, stackName }: Props) { export function StackContainersDatatable({ environment, stackName }: Props) {
const settings = useStore(settingsStore); const tableState = useTableState(settingsStore, storageKey);
const [search, setSearch] = useSearchBarState(storageKey);
const isGPUsColumnVisible = useShowGPUsColumn(environment.Id); const isGPUsColumnVisible = useShowGPUsColumn(environment.Id);
const columns = useColumns(false, isGPUsColumnVisible); const columns = useColumns(false, isGPUsColumnVisible);
const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id)
);
const containersQuery = useContainers( const containersQuery = useContainers(
environment.Id, environment.Id,
true, true,
{ {
label: [`com.docker.compose.project=${stackName}`], label: [`com.docker.compose.project=${stackName}`],
}, },
settings.autoRefreshRate * 1000 tableState.autoRefreshRate * 1000
); );
return ( return (
<RowProvider context={{ environment }}> <TableSettingsProvider settings={settingsStore}>
<TableSettingsProvider settings={settingsStore}> <Datatable
<Datatable title="Containers"
title="Containers" titleIcon={Box}
titleIcon={Box} settingsManager={tableState}
initialPageSize={settings.pageSize} columns={columns}
onPageSizeChange={settings.setPageSize} renderTableActions={(selectedRows) => (
initialSortBy={settings.sortBy} <ContainersDatatableActions
onSortByChange={settings.setSortBy} selectedItems={selectedRows}
searchValue={search} isAddActionVisible={false}
onSearchChange={setSearch} endpointId={environment.Id}
columns={columns} />
renderTableActions={(selectedRows) => ( )}
<ContainersDatatableActions initialTableState={{
selectedItems={selectedRows} columnVisibility: Object.fromEntries(
isAddActionVisible={false} tableState.hiddenColumns.map((col) => [col, false])
endpointId={environment.Id} ),
/> }}
)} renderTableSettings={(tableInstance) => {
initialTableState={{ hiddenColumns: settings.hiddenColumns }} const columnsToHide = tableInstance
renderTableSettings={(tableInstance) => { .getAllColumns()
const columnsToHide = tableInstance.allColumns.filter( .filter((col) => col.getCanHide());
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return ( return (
<> <>
<ColumnVisibilityMenu<DockerContainer> <ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide} columns={columnsToHide}
onChange={(hiddenColumns) => { onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns); tableState.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns); tableInstance.setColumnVisibility(
}} Object.fromEntries(hiddenColumns.map((col) => [col, false]))
value={settings.hiddenColumns} );
/> }}
<TableSettingsMenu value={tableState.hiddenColumns}
quickActions={<QuickActionsSettings actions={actions} />} />
> <Table.SettingsMenu
<ContainersDatatableSettings settings={settings} /> quickActions={<QuickActionsSettings actions={actions} />}
</TableSettingsMenu> >
</> <ContainersDatatableSettings settings={tableState} />
); </Table.SettingsMenu>
}} </>
dataset={containersQuery.data || []} );
isLoading={containersQuery.isLoading} }}
emptyContentLabel="No containers found" dataset={containersQuery.data || []}
/> isLoading={containersQuery.isLoading}
</TableSettingsProvider> emptyContentLabel="No containers found"
</RowProvider> />
</TableSettingsProvider>
); );
} }

View file

@ -1,4 +1,3 @@
import { useStore } from 'zustand';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
@ -9,7 +8,7 @@ import { Datatable as GenericDatatable } from '@@/datatables';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { createPersistedStore } from '@@/datatables/types'; import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar'; import { useTableState } from '@@/datatables/useTableState';
import { confirm } from '@@/modals/confirm'; import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals'; import { ModalType } from '@@/modals';
@ -28,20 +27,14 @@ export function Datatable() {
const associateMutation = useAssociateDeviceMutation(); const associateMutation = useAssociateDeviceMutation();
const removeMutation = useDeleteEnvironmentsMutation(); const removeMutation = useDeleteEnvironmentsMutation();
const licenseOverused = useLicenseOverused(); const licenseOverused = useLicenseOverused();
const settings = useStore(settingsStore); const tableState = useTableState(settingsStore, storageKey);
const [search, setSearch] = useSearchBarState(storageKey);
const { data: environments, totalCount, isLoading } = useEnvironments(); const { data: environments, totalCount, isLoading } = useEnvironments();
return ( return (
<GenericDatatable <GenericDatatable
settingsManager={tableState}
columns={columns} columns={columns}
dataset={environments} dataset={environments}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Edge Devices Waiting Room" title="Edge Devices Waiting Room"
emptyContentLabel="No Edge Devices found" emptyContentLabel="No Edge Devices found"
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
@ -57,7 +50,7 @@ export function Datatable() {
<Button <Button
onClick={() => handleAssociateDevice(selectedRows)} onClick={() => handleAssociateDevice(selectedRows)}
disabled={selectedRows.length === 0} disabled={selectedRows.length === 0 || licenseOverused}
> >
Associate Device Associate Device
</Button> </Button>

View file

@ -1,76 +1,37 @@
import moment from 'moment'; import moment from 'moment';
import { CellProps, Column } from 'react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { WaitingRoomEnvironment } from '../types'; import { WaitingRoomEnvironment } from '../types';
export const columns: readonly Column<WaitingRoomEnvironment>[] = [ const columnHelper = createColumnHelper<WaitingRoomEnvironment>();
{
Header: 'Name', export const columns = [
accessor: (row) => row.Name, columnHelper.accessor('Name', {
header: 'Name',
id: 'name', id: 'name',
disableFilters: true, }),
Filter: () => null, columnHelper.accessor('EdgeID', {
canHide: false, header: 'Edge ID',
sortType: 'string',
},
{
Header: 'Edge ID',
accessor: (row) => row.EdgeID,
id: 'edge-id', id: 'edge-id',
disableFilters: true, }),
Filter: () => null, columnHelper.accessor((row) => row.EdgeGroups.join(', ') || '-', {
canHide: false, header: 'Edge Groups',
sortType: 'string',
},
{
Header: 'Edge Groups',
accessor: (row) => row.EdgeGroups || [],
Cell: ({ value }: CellProps<WaitingRoomEnvironment, string[]>) =>
value.join(', ') || '-',
id: 'edge-groups', id: 'edge-groups',
disableFilters: true, }),
Filter: () => null, columnHelper.accessor((row) => row.Group || '-', {
canHide: false, header: 'Group',
sortType: 'string',
},
{
Header: 'Group',
accessor: (row) => row.Group || '-',
id: 'group', id: 'group',
disableFilters: true, }),
Filter: () => null, columnHelper.accessor((row) => row.Tags.join(', ') || '-', {
canHide: false, header: 'Tags',
sortType: 'string',
},
{
Header: 'Tags',
accessor: (row) => row.Tags || [],
Cell: ({ value }: CellProps<WaitingRoomEnvironment, string[]>) =>
value.join(', ') || '-',
id: 'tags', id: 'tags',
disableFilters: true, }),
Filter: () => null, columnHelper.accessor((row) => row.LastCheckInDate, {
canHide: false, header: 'Last Check-in',
sortType: 'string',
},
{
Header: 'Last Check-in',
accessor: 'LastCheckInDate',
Cell: LastCheckinDateCell,
id: 'last-check-in', id: 'last-check-in',
disableFilters: true, cell: ({ getValue }) => {
Filter: () => null, const value = getValue();
canHide: false, return value ? moment(value * 1000).fromNow() : '-';
sortType: 'string', },
}, }),
] as const; ];
function LastCheckinDateCell({
value,
}: CellProps<WaitingRoomEnvironment, number>) {
if (!value) {
return '-';
}
return moment(value * 1000).fromNow();
}

View file

@ -1,8 +1,7 @@
import { Row, TableRowProps } from 'react-table';
import { Shuffle, Trash2 } from 'lucide-react'; import { Shuffle, Trash2 } from 'lucide-react';
import { useStore } from 'zustand';
import { useRouter } from '@uirouter/react'; import { useRouter } from '@uirouter/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Row } from '@tanstack/react-table';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { import {
@ -15,15 +14,15 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { useTableState } from '@@/datatables/useTableState';
import { useMutationDeleteServices, useServices } from '../service'; import { useMutationDeleteServices, useServices } from '../service';
import { Service } from '../types'; import { Service } from '../types';
import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings'; import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings';
import { useColumns } from './columns'; import { columns } from './columns';
import { createStore } from './datatable-store'; import { createStore } from './datatable-store';
import { ServicesDatatableDescription } from './ServicesDatatableDescription'; import { ServicesDatatableDescription } from './ServicesDatatableDescription';
@ -31,19 +30,16 @@ const storageKey = 'k8sServicesDatatable';
const settingsStore = createStore(storageKey); const settingsStore = createStore(storageKey);
export function ServicesDatatable() { export function ServicesDatatable() {
const tableState = useTableState(settingsStore, storageKey);
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const servicesQuery = useServices(environmentId); const servicesQuery = useServices(environmentId);
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const columns = useColumns();
const readOnly = !useAuthorizations(['K8sServiceW']); const readOnly = !useAuthorizations(['K8sServiceW']);
const { isAdmin } = useCurrentUser(); const { isAdmin } = useCurrentUser();
const filteredServices = servicesQuery.data?.filter( const filteredServices = servicesQuery.data?.filter(
(service) => (service) =>
(isAdmin && settings.showSystemResources) || (isAdmin && tableState.showSystemResources) ||
!KubernetesNamespaceHelper.isSystemNamespace(service.Namespace) !KubernetesNamespaceHelper.isSystemNamespace(service.Namespace)
); );
@ -51,35 +47,30 @@ export function ServicesDatatable() {
<Datatable <Datatable
dataset={filteredServices || []} dataset={filteredServices || []}
columns={columns} columns={columns}
settingsManager={tableState}
isLoading={servicesQuery.isLoading} isLoading={servicesQuery.isLoading}
emptyContentLabel="No services found" emptyContentLabel="No services found"
title="Services" title="Services"
titleIcon={Shuffle} titleIcon={Shuffle}
getRowId={(row) => row.UID} getRowId={(row) => row.UID}
isRowSelectable={(row) => isRowSelectable={(row) =>
!KubernetesNamespaceHelper.isSystemNamespace(row.values.namespace) !KubernetesNamespaceHelper.isSystemNamespace(row.original.Namespace)
} }
disableSelect={readOnly} disableSelect={readOnly}
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
<TableActions selectedItems={selectedRows} /> <TableActions selectedItems={selectedRows} />
)} )}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
renderTableSettings={() => ( renderTableSettings={() => (
<TableSettingsMenu> <TableSettingsMenu>
<DefaultDatatableSettings <DefaultDatatableSettings
settings={settings} settings={tableState}
hideShowSystemResources={!isAdmin} hideShowSystemResources={!isAdmin}
/> />
</TableSettingsMenu> </TableSettingsMenu>
)} )}
description={ description={
<ServicesDatatableDescription <ServicesDatatableDescription
showSystemResources={settings.showSystemResources || !isAdmin} showSystemResources={tableState.showSystemResources || !isAdmin}
/> />
} }
renderRow={servicesRenderRow} renderRow={servicesRenderRow}
@ -89,20 +80,13 @@ export function ServicesDatatable() {
// needed to apply custom styling to the row cells and not globally. // needed to apply custom styling to the row cells and not globally.
// required in the AC's for this ticket. // required in the AC's for this ticket.
function servicesRenderRow<D extends Record<string, unknown>>( function servicesRenderRow(row: Row<Service>, highlightedItemId?: string) {
row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string
) {
return ( return (
<Table.Row<D> <Table.Row<Service>
key={rowProps.key} cells={row.getVisibleCells()}
cells={row.cells} className={clsx('[&>td]:!py-4 [&>td]:!align-top', {
className={clsx('[&>td]:!py-4 [&>td]:!align-top', rowProps.className, {
active: highlightedItemId === row.id, active: highlightedItemId === row.id,
})} })}
role={rowProps.role}
style={rowProps.style}
/> />
); );
} }

View file

@ -1,4 +1,4 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -6,29 +6,34 @@ import { Link } from '@@/Link';
import { Service } from '../../types'; import { Service } from '../../types';
export const application: Column<Service> = { import { columnHelper } from './helper';
Header: 'Application',
accessor: (row) => (row.Applications ? row.Applications[0].Name : ''),
id: 'application',
Cell: ({ row, value: appname }: CellProps<Service, string>) => { export const application = columnHelper.accessor(
const environmentId = useEnvironmentId(); (row) => (row.Applications ? row.Applications[0].Name : ''),
return appname ? ( {
<Link header: 'Application',
to="kubernetes.applications.application" id: 'application',
params={{ cell: Cell,
endpointId: environmentId, }
namespace: row.original.Namespace, );
name: appname,
}} function Cell({ row, getValue }: CellContext<Service, string>) {
title={appname} const appName = getValue();
> const environmentId = useEnvironmentId();
{appname}
</Link> return appName ? (
) : ( <Link
'-' to="kubernetes.applications.application"
); params={{
}, endpointId: environmentId,
canHide: true, namespace: row.original.Namespace,
disableFilters: true, name: appName,
}; }}
title={appName}
>
{appName}
</Link>
) : (
'-'
);
}

View file

@ -1,20 +1,17 @@
import { CellProps, Column } from 'react-table'; import { columnHelper } from './helper';
import { Service } from '../../types'; export const clusterIP = columnHelper.accessor('ClusterIPs', {
header: 'Cluster IP',
export const clusterIP: Column<Service> = {
Header: 'Cluster IP',
accessor: 'ClusterIPs',
id: 'clusterIP', id: 'clusterIP',
Cell: ({ value: clusterIPs }: CellProps<Service, Service['ClusterIPs']>) => { cell: ({ getValue }) => {
const clusterIPs = getValue();
if (!clusterIPs?.length) { if (!clusterIPs?.length) {
return '-'; return '-';
} }
return clusterIPs.map((ip) => <div key={ip}>{ip}</div>); return clusterIPs.map((ip) => <div key={ip}>{ip}</div>);
}, },
disableFilters: true, sortingFn: (rowA, rowB) => {
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.ClusterIPs; const a = rowA.original.ClusterIPs;
const b = rowB.original.ClusterIPs; const b = rowB.original.ClusterIPs;
@ -38,4 +35,4 @@ export const clusterIP: Column<Service> = {
} }
); );
}, },
}; });

View file

@ -1,23 +1,20 @@
import { CellProps, Column } from 'react-table';
import { formatDate } from '@/portainer/filters/filters'; import { formatDate } from '@/portainer/filters/filters';
import { Service } from '../../types'; import { columnHelper } from './helper';
export const created: Column<Service> = { export const created = columnHelper.accessor('CreationTimestamp', {
Header: 'Created', header: 'Created',
id: 'created', id: 'created',
accessor: (row) => row.CreationTimestamp, cell: ({ row, getValue }) => {
Cell: ({ row }: CellProps<Service>) => { const date = formatDate(getValue());
const owner = const owner =
row.original.Labels?.['io.portainer.kubernetes.application.owner']; row.original.Labels?.['io.portainer.kubernetes.application.owner'];
if (owner) { if (owner) {
return `${formatDate(row.original.CreationTimestamp)} by ${owner}`; return `${date} by ${owner}`;
} }
return formatDate(row.original.CreationTimestamp); return date;
}, },
disableFilters: true, });
canHide: true,
};

View file

@ -1,8 +1,108 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { Service } from '../../types'; import { Service } from '../../types';
import { ExternalIPLink } from './externalIPLink'; import { ExternalIPLink } from './ExternalIPLink';
import { columnHelper } from './helper';
export const externalIP = columnHelper.accessor(
(row) => {
if (row.Type === 'ExternalName') {
return row.ExternalName;
}
if (row.ExternalIPs?.length) {
return row.ExternalIPs?.slice(0);
}
return row.IngressStatus?.slice(0);
},
{
header: 'External IP',
id: 'externalIP',
cell: Cell,
sortingFn: (rowA, rowB) => {
const a = rowA.original.IngressStatus;
const b = rowB.original.IngressStatus;
const aExternal = rowA.original.ExternalIPs;
const bExternal = rowB.original.ExternalIPs;
const ipA = a?.[0].IP || aExternal?.[0] || rowA.original.ExternalName;
const ipB = b?.[0].IP || bExternal?.[0] || rowA.original.ExternalName;
if (!ipA) return 1;
if (!ipB) return -1;
// use a nat sort order for ip addresses
return ipA.localeCompare(
ipB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
}
);
},
}
);
function Cell({ row }: CellContext<Service, string>) {
if (row.original.Type === 'ExternalName') {
if (row.original.ExternalName) {
const linkTo = `http://${row.original.ExternalName}`;
return <ExternalIPLink to={linkTo} text={row.original.ExternalName} />;
}
return '-';
}
const [scheme, port] = getSchemeAndPort(row.original);
if (row.original.ExternalIPs?.length) {
return row.original.ExternalIPs?.map((ip, index) => {
// some ips come through blank
if (ip.length === 0) {
return '-';
}
if (scheme) {
let linkTo = `${scheme}://${ip}`;
if (port !== 80 && port !== 443) {
linkTo = `${linkTo}:${port}`;
}
return (
<div key={index}>
<ExternalIPLink to={linkTo} text={ip} />
</div>
);
}
return <div key={index}>{ip}</div>;
});
}
const status = row.original.IngressStatus;
if (status) {
return status?.map((status, index) => {
// some ips come through blank
if (status.IP.length === 0) {
return '-';
}
if (scheme) {
let linkTo = `${scheme}://${status.IP}`;
if (port !== 80 && port !== 443) {
linkTo = `${linkTo}:${port}`;
}
return (
<div key={index}>
<ExternalIPLink to={linkTo} text={status.IP} />
</div>
);
}
return <div key={index}>{status.IP}</div>;
});
}
return '-';
}
// calculate the scheme based on the ports of the service // calculate the scheme based on the ports of the service
// favour https over http. // favour https over http.
@ -37,100 +137,3 @@ function getSchemeAndPort(svc: Service): [string, number] {
return [scheme, servicePort]; return [scheme, servicePort];
} }
export const externalIP: Column<Service> = {
Header: 'External IP',
id: 'externalIP',
accessor: (row) => {
if (row.Type === 'ExternalName') {
return row.ExternalName;
}
if (row.ExternalIPs?.length) {
return row.ExternalIPs?.slice(0);
}
return row.IngressStatus?.slice(0);
},
Cell: ({ row }: CellProps<Service>) => {
if (row.original.Type === 'ExternalName') {
if (row.original.ExternalName) {
const linkto = `http://${row.original.ExternalName}`;
return <ExternalIPLink to={linkto} text={row.original.ExternalName} />;
}
return '-';
}
const [scheme, port] = getSchemeAndPort(row.original);
if (row.original.ExternalIPs?.length) {
return row.original.ExternalIPs?.map((ip, index) => {
// some ips come through blank
if (ip.length === 0) {
return '-';
}
if (scheme) {
let linkto = `${scheme}://${ip}`;
if (port !== 80 && port !== 443) {
linkto = `${linkto}:${port}`;
}
return (
<div key={index}>
<ExternalIPLink to={linkto} text={ip} />
</div>
);
}
return <div key={index}>{ip}</div>;
});
}
const status = row.original.IngressStatus;
if (status) {
return status?.map((status, index) => {
// some ips come through blank
if (status.IP.length === 0) {
return '-';
}
if (scheme) {
let linkto = `${scheme}://${status.IP}`;
if (port !== 80 && port !== 443) {
linkto = `${linkto}:${port}`;
}
return (
<div key={index}>
<ExternalIPLink to={linkto} text={status.IP} />
</div>
);
}
return <div key={index}>{status.IP}</div>;
});
}
return '-';
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.IngressStatus;
const b = rowB.original.IngressStatus;
const aExternal = rowA.original.ExternalIPs;
const bExternal = rowB.original.ExternalIPs;
const ipA = a?.[0].IP || aExternal?.[0] || rowA.original.ExternalName;
const ipB = b?.[0].IP || bExternal?.[0] || rowA.original.ExternalName;
if (!ipA) return 1;
if (!ipB) return -1;
// use a nat sort order for ip addresses
return ipA.localeCompare(
ipB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
}
);
},
};

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Service } from '../../types';
export const columnHelper = createColumnHelper<Service>();

View file

@ -8,16 +8,14 @@ import { targetPorts } from './targetPorts';
import { application } from './application'; import { application } from './application';
import { created } from './created'; import { created } from './created';
export function useColumns() { export const columns = [
return [ name,
name, application,
application, namespace,
namespace, type,
type, ports,
ports, targetPorts,
targetPorts, clusterIP,
clusterIP, externalIP,
externalIP, created,
created, ];
];
}

View file

@ -1,15 +1,13 @@
import { CellProps, Column } from 'react-table';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
import { Service } from '../../types'; import { columnHelper } from './helper';
export const name: Column<Service> = { export const name = columnHelper.accessor('Name', {
Header: 'Name', header: 'Name',
id: 'Name', id: 'name',
accessor: (row) => row.Name, cell: ({ row, getValue }) => {
Cell: ({ row }: CellProps<Service>) => { const name = getValue();
const isSystem = KubernetesNamespaceHelper.isSystemNamespace( const isSystem = KubernetesNamespaceHelper.isSystemNamespace(
row.original.Namespace row.original.Namespace
); );
@ -19,11 +17,8 @@ export const name: Column<Service> = {
!row.original.Labels['io.portainer.kubernetes.application.owner']; !row.original.Labels['io.portainer.kubernetes.application.owner'];
return ( return (
<Authorized <Authorized authorizations="K8sServiceW" childrenUnauthorized={name}>
authorizations="K8sServiceW" {name}
childrenUnauthorized={row.original.Name}
>
{row.original.Name}
{isSystem && ( {isSystem && (
<span className="label label-info image-tag label-margins"> <span className="label label-info image-tag label-margins">
@ -39,7 +34,4 @@ export const name: Column<Service> = {
</Authorized> </Authorized>
); );
}, },
});
disableFilters: true,
canHide: true,
};

View file

@ -1,4 +1,4 @@
import { CellProps, Column, Row } from 'react-table'; import { Row } from '@tanstack/react-table';
import { filterHOC } from '@/react/components/datatables/Filter'; import { filterHOC } from '@/react/components/datatables/Filter';
@ -6,28 +6,30 @@ import { Link } from '@@/Link';
import { Service } from '../../types'; import { Service } from '../../types';
export const namespace: Column<Service> = { import { columnHelper } from './helper';
Header: 'Namespace',
export const namespace = columnHelper.accessor('Namespace', {
header: 'Namespace',
id: 'namespace', id: 'namespace',
accessor: 'Namespace', cell: ({ getValue }) => {
Cell: ({ row }: CellProps<Service>) => ( const namespace = getValue();
<Link
to="kubernetes.resourcePools.resourcePool" return (
params={{ <Link
id: row.original.Namespace, to="kubernetes.resourcePools.resourcePool"
}} params={{
title={row.original.Namespace} id: namespace,
> }}
{row.original.Namespace} title={namespace}
</Link> >
), {namespace}
canHide: true, </Link>
disableFilters: false, );
Filter: filterHOC('Filter by namespace'),
filter: (rows: Row<Service>[], _filterValue, filters) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((r) => filters.includes(r.original.Namespace));
}, },
}; meta: {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
filterValue.length === 0 || filterValue.includes(row.original.Namespace),
});

View file

@ -1,76 +1,68 @@
import { CellProps, Column } from 'react-table';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { Service } from '../../types'; import { columnHelper } from './helper';
export const ports: Column<Service> = { export const ports = columnHelper.accessor(
Header: () => ( (row) =>
<> row.Ports.map((port) => `${port.Port}:${port.NodePort}/${port.Protocol}`),
Ports {
<Tooltip message="The format of Ports is port[:nodePort]/protocol. Protocol is either TCP, UDP or SCTP." /> header: () => (
</>
),
id: 'ports',
accessor: (row) => {
const ports = row.Ports;
return ports.map(
(port) => `${port.Port}:${port.NodePort}/${port.Protocol}`
);
},
Cell: ({ row }: CellProps<Service>) => {
if (!row.original.Ports.length) {
return '-';
}
return (
<> <>
{row.original.Ports.map((port, index) => { Ports
if (port.NodePort !== 0) { <Tooltip message="The format of Ports is port[:nodePort]/protocol. Protocol is either TCP, UDP or SCTP." />
</>
),
id: 'ports',
cell: ({ row }) => {
if (!row.original.Ports.length) {
return '-';
}
return (
<>
{row.original.Ports.map((port, index) => {
if (port.NodePort !== 0) {
return (
<div key={index}>
{port.Port}:{port.NodePort}/{port.Protocol}
</div>
);
}
return ( return (
<div key={index}> <div key={index}>
{port.Port}:{port.NodePort}/{port.Protocol} {port.Port}/{port.Protocol}
</div> </div>
); );
} })}
</>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.Ports;
const b = rowB.original.Ports;
return ( if (!a.length && !b.length) return 0;
<div key={index}>
{port.Port}/{port.Protocol}
</div>
);
})}
</>
);
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => { if (!a.length) return 1;
const a = rowA.original.Ports; if (!b.length) return -1;
const b = rowB.original.Ports;
if (!a.length && !b.length) return 0; // sort order based on first port
const portA = a[0].Port;
const portB = b[0].Port;
if (!a.length) return 1; if (portA === portB) {
if (!b.length) return -1; // longer list of ports is considered "greater"
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
// sort order based on first port // now do a regular number sort
const portA = a[0].Port; if (portA < portB) return -1;
const portB = b[0].Port; if (portA > portB) return 1;
if (portA === portB) {
// longer list of ports is considered "greater"
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0; return 0;
} },
}
// now do a regular number sort );
if (portA < portB) return -1;
if (portA > portB) return 1;
return 0;
},
};

View file

@ -1,53 +1,46 @@
import { CellProps, Column } from 'react-table'; import { columnHelper } from './helper';
import { Service } from '../../types'; export const targetPorts = columnHelper.accessor(
(row) => row.Ports.map((port) => `${port.TargetPort}`),
{
header: 'Target Ports',
id: 'targetPorts',
cell: ({ getValue }) => {
const ports = getValue();
export const targetPorts: Column<Service> = { if (!ports.length) {
Header: 'Target Ports', return '-';
id: 'targetPorts',
accessor: (row) => {
const ports = row.Ports;
if (!ports.length) {
return '-';
}
return ports.map((port) => `${port.TargetPort}`);
},
Cell: ({ row }: CellProps<Service>) => {
const ports = row.original.Ports;
if (!ports.length) {
return '-';
}
return ports.map((port, index) => <div key={index}>{port.TargetPort}</div>);
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.Ports;
const b = rowB.original.Ports;
if (!a.length && !b.length) return 0;
if (!a.length) return 1;
if (!b.length) return -1;
const portA = a[0].TargetPort;
const portB = b[0].TargetPort;
if (portA === portB) {
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
// natural sort of the port
return portA.localeCompare(
portB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
} }
);
}, return ports.map((port, index) => <div key={index}>{port}</div>);
}; },
sortingFn: (rowA, rowB) => {
const a = rowA.original.Ports;
const b = rowB.original.Ports;
if (!a.length && !b.length) return 0;
if (!a.length) return 1;
if (!b.length) return -1;
const portA = a[0].TargetPort;
const portB = b[0].TargetPort;
if (portA === portB) {
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
// natural sort of the port
return portA.localeCompare(
portB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
}
);
},
}
);

View file

@ -1,22 +1,18 @@
import { CellProps, Column, Row } from 'react-table'; import { Row } from '@tanstack/react-table';
import { filterHOC } from '@@/datatables/Filter'; import { filterHOC } from '@@/datatables/Filter';
import { Service } from '../../types'; import { Service } from '../../types';
export const type: Column<Service> = { import { columnHelper } from './helper';
Header: 'Type',
id: 'type',
accessor: (row) => row.Type,
Cell: ({ row }: CellProps<Service>) => <div>{row.original.Type}</div>,
canHide: true,
disableFilters: false, export const type = columnHelper.accessor('Type', {
Filter: filterHOC('Filter by type'), header: 'Type',
filter: (rows: Row<Service>[], _filterValue, filters) => { id: 'type',
if (filters.length === 0) { meta: {
return rows; filter: filterHOC('Filter by type'),
}
return rows.filter((r) => filters.includes(r.original.Type));
}, },
}; enableColumnFilter: true,
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
filterValue.length === 0 || filterValue.includes(row.original.Type),
});

View file

@ -6,7 +6,7 @@ import {
} from '../../datatables/DefaultDatatableSettings'; } from '../../datatables/DefaultDatatableSettings';
export function createStore(storageKey: string) { export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({ return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...refreshableSettings(set), ...refreshableSettings(set),
...systemResourcesSettings(set), ...systemResourcesSettings(set),
})); }));

View file

@ -7,6 +7,8 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { getNamespaces } from '../namespaces/service'; import { getNamespaces } from '../namespaces/service';
import { Service } from './types';
export const queryKeys = { export const queryKeys = {
list: (environmentId: EnvironmentId) => list: (environmentId: EnvironmentId) =>
['environments', environmentId, 'kubernetes', 'services'] as const, ['environments', environmentId, 'kubernetes', 'services'] as const,
@ -18,7 +20,7 @@ async function getServices(
lookupApps: boolean lookupApps: boolean
) { ) {
try { try {
const { data: services } = await axios.get( const { data: services } = await axios.get<Array<Service>>(
`kubernetes/${environmentId}/namespaces/${namespace}/services`, `kubernetes/${environmentId}/namespaces/${namespace}/services`,
{ {
params: { params: {

View file

@ -1,19 +1,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AlertTriangle, Database } from 'lucide-react'; import { Database, AlertTriangle } from 'lucide-react';
import { useStore } from 'zustand';
import { confirm } from '@@/modals/confirm'; import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals'; import { ModalType } from '@@/modals';
import { Datatable } from '@@/datatables'; import { Datatable } from '@@/datatables';
import { Button, ButtonGroup } from '@@/buttons'; import { Button, ButtonGroup } from '@@/buttons';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { createPersistedStore } from '@@/datatables/types'; import { createPersistedStore } from '@@/datatables/types';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
import { useTableState } from '@@/datatables/useTableState';
import { IngressControllerClassMap } from '../types'; import { IngressControllerClassMap } from '../types';
import { useColumns } from './columns'; import { columns } from './columns';
const storageKey = 'ingressClasses'; const storageKey = 'ingressClasses';
const settingsStore = createPersistedStore(storageKey); const settingsStore = createPersistedStore(storageKey);
@ -39,12 +38,11 @@ export function IngressClassDatatable({
noIngressControllerLabel, noIngressControllerLabel,
view, view,
}: Props) { }: Props) {
const settings = useStore(settingsStore); const tableState = useTableState(settingsStore, storageKey);
const [search, setSearch] = useSearchBarState(storageKey);
const [ingControllerFormValues, setIngControllerFormValues] = useState( const [ingControllerFormValues, setIngControllerFormValues] = useState(
ingressControllers || [] ingressControllers || []
); );
const columns = useColumns();
useEffect(() => { useEffect(() => {
if (allowNoneIngressClass === undefined) { if (allowNoneIngressClass === undefined) {
@ -81,6 +79,7 @@ export function IngressClassDatatable({
return ( return (
<div className="-mx-[15px]"> <div className="-mx-[15px]">
<Datatable <Datatable
settingsManager={tableState}
dataset={ingControllerFormValues || []} dataset={ingControllerFormValues || []}
columns={columns} columns={columns}
isLoading={isLoading} isLoading={isLoading}
@ -90,12 +89,6 @@ export function IngressClassDatatable({
getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`} getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`}
renderTableActions={(selectedRows) => renderTableActions(selectedRows)} renderTableActions={(selectedRows) => renderTableActions(selectedRows)}
description={renderIngressClassDescription()} description={renderIngressClassDescription()}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
/> />
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { Badge } from '@@/Badge'; import { Badge } from '@@/Badge';
@ -6,23 +6,23 @@ import { Icon } from '@@/Icon';
import type { IngressControllerClassMap } from '../../types'; import type { IngressControllerClassMap } from '../../types';
export const availability: Column<IngressControllerClassMap> = { import { columnHelper } from './helper';
Header: 'Availability',
accessor: 'Availability', export const availability = columnHelper.accessor('Availability', {
Cell: AvailailityCell, header: 'Availability',
id: 'availability', cell: Cell,
disableFilters: true, id: 'availability',
canHide: true, invertSorting: true,
sortInverted: true, sortingFn: 'basic',
sortType: 'basic', });
Filter: () => null,
}; function Cell({ getValue }: CellContext<IngressControllerClassMap, boolean>) {
const availability = getValue();
function AvailailityCell({ value }: CellProps<IngressControllerClassMap>) {
return ( return (
<Badge type={value ? 'success' : 'danger'}> <Badge type={availability ? 'success' : 'danger'}>
<Icon icon={value ? Check : X} className="!mr-1" /> <Icon icon={availability ? Check : X} className="!mr-1" />
{value ? 'Allowed' : 'Disallowed'} {availability ? 'Allowed' : 'Disallowed'}
</Badge> </Badge>
); );
} }

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { IngressControllerClassMap } from '../../types';
export const columnHelper = createColumnHelper<IngressControllerClassMap>();

View file

@ -1,9 +1,5 @@
import { useMemo } from 'react';
import { availability } from './availability'; import { availability } from './availability';
import { type } from './type'; import { type } from './type';
import { name } from './name'; import { name } from './name';
export function useColumns() { export const columns = [name, type, availability];
return useMemo(() => [name, type, availability], []);
}

View file

@ -1,24 +1,26 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { Badge } from '@@/Badge'; import { Badge } from '@@/Badge';
import type { IngressControllerClassMap } from '../../types'; import type { IngressControllerClassMap } from '../../types';
export const name: Column<IngressControllerClassMap> = { import { columnHelper } from './helper';
Header: 'Ingress class',
accessor: 'ClassName', export const name = columnHelper.accessor('ClassName', {
Cell: NameCell, header: 'Ingress class',
id: 'name', cell: NameCell,
disableFilters: true, id: 'name',
canHide: true, });
Filter: () => null,
sortType: 'string', function NameCell({
}; row,
getValue,
}: CellContext<IngressControllerClassMap, string>) {
const className = getValue();
function NameCell({ row }: CellProps<IngressControllerClassMap>) {
return ( return (
<span className="flex flex-nowrap"> <span className="flex flex-nowrap">
{row.original.ClassName} {className}
{row.original.New && <Badge className="ml-1">Newly detected</Badge>} {row.original.New && <Badge className="ml-1">Newly detected</Badge>}
</span> </span>
); );

View file

@ -1,14 +1,10 @@
import { CellProps, Column } from 'react-table'; import { columnHelper } from './helper';
import type { IngressControllerClassMap } from '../../types'; export const type = columnHelper.accessor('Type', {
header: 'Ingress controller type',
export const type: Column<IngressControllerClassMap> = { cell: ({ getValue }) => {
Header: 'Ingress controller type', const type = getValue();
accessor: 'Type', return type || '-';
Cell: ({ row }: CellProps<IngressControllerClassMap>) => },
row.original.Type || '-',
id: 'type', id: 'type',
disableFilters: true, });
canHide: true,
Filter: () => null,
};

View file

@ -1,6 +1,5 @@
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useRouter } from '@uirouter/react'; import { useRouter } from '@uirouter/react';
import { useStore } from 'zustand';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
@ -12,7 +11,7 @@ import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { createPersistedStore } from '@@/datatables/types'; import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar'; import { useTableState } from '@@/datatables/useTableState';
import { DeleteIngressesRequest, Ingress } from '../types'; import { DeleteIngressesRequest, Ingress } from '../types';
import { useDeleteIngresses, useIngresses } from '../queries'; import { useDeleteIngresses, useIngresses } from '../queries';
@ -39,13 +38,13 @@ export function IngressDatatable() {
); );
const deleteIngressesMutation = useDeleteIngresses(); const deleteIngressesMutation = useDeleteIngresses();
const settings = useStore(settingsStore); const tableState = useTableState(settingsStore, storageKey);
const [search, setSearch] = useSearchBarState(storageKey);
const router = useRouter(); const router = useRouter();
return ( return (
<Datatable <Datatable
settingsManager={tableState}
dataset={ingressesQuery.data || []} dataset={ingressesQuery.data || []}
columns={columns} columns={columns}
isLoading={ingressesQuery.isLoading} isLoading={ingressesQuery.isLoading}
@ -55,12 +54,6 @@ export function IngressDatatable() {
getRowId={(row) => row.Name + row.Type + row.Namespace} getRowId={(row) => row.Name + row.Type + row.Namespace}
renderTableActions={tableActions} renderTableActions={tableActions}
disableSelect={useCheckboxes()} disableSelect={useCheckboxes()}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
/> />
); );

View file

@ -1,11 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { Ingress } from '../../types'; export const className = columnHelper.accessor('ClassName', {
header: 'Class Name',
export const className: Column<Ingress> = {
Header: 'Class Name',
accessor: 'ClassName',
id: 'className', id: 'className',
disableFilters: true, });
canHide: true,
};

View file

@ -1,23 +1,19 @@
import { CellProps, Column } from 'react-table';
import { formatDate } from '@/portainer/filters/filters'; import { formatDate } from '@/portainer/filters/filters';
import { Ingress } from '../../types'; import { columnHelper } from './helper';
export const created: Column<Ingress> = { export const created = columnHelper.accessor('CreationDate', {
Header: 'Created', header: 'Created',
id: 'created', cell: ({ row, getValue }) => {
accessor: (row) => row.CreationDate, const date = formatDate(getValue());
Cell: ({ row }: CellProps<Ingress>) => {
const owner = const owner =
row.original.Labels?.['io.portainer.kubernetes.ingress.owner']; row.original.Labels?.['io.portainer.kubernetes.ingress.owner'];
if (owner) { if (owner) {
return `${formatDate(row.original.CreationDate)} by ${owner}`; return `${date} by ${owner}`;
} }
return formatDate(row.original.CreationDate); return date;
}, },
disableFilters: true, id: 'created',
canHide: true, });
};

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Ingress } from '../../types';
export const columnHelper = createColumnHelper<Ingress>();

View file

@ -1,4 +1,4 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { AlertTriangle, ArrowRight } from 'lucide-react'; import { AlertTriangle, ArrowRight } from 'lucide-react';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
@ -6,6 +6,41 @@ import { Badge } from '@@/Badge';
import { Ingress, TLS, Path } from '../../types'; import { Ingress, TLS, Path } from '../../types';
import { columnHelper } from './helper';
export const ingressRules = columnHelper.accessor('Paths', {
header: 'Rules and Paths',
id: 'ingressRules',
cell: Cell,
});
function Cell({ row, getValue }: CellContext<Ingress, Path[] | undefined>) {
const paths = getValue();
if (!paths) {
return <div />;
}
return paths.map((path) => {
const isHttp = isHTTP(row.original.TLS || [], path.Host);
return (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
<span className="flex flex-nowrap items-center gap-1 px-2">
{link(path.Host, path.Path, isHttp)}
<Icon icon={ArrowRight} />
{`${path.ServiceName}:${path.Port}`}
{!path.HasService && (
<Badge type="warn" className="ml-1 gap-1">
<Icon icon={AlertTriangle} />
Service doesn&apos;t exist
</Badge>
)}
</span>
</div>
);
});
}
function isHTTP(TLSs: TLS[], host: string) { function isHTTP(TLSs: TLS[], host: string) {
return TLSs.filter((t) => t.Hosts.indexOf(host) !== -1).length === 0; return TLSs.filter((t) => t.Hosts.indexOf(host) !== -1).length === 0;
} }
@ -24,33 +59,3 @@ function link(host: string, path: string, isHttp: boolean) {
</a> </a>
); );
} }
export const ingressRules: Column<Ingress> = {
Header: 'Rules and Paths',
accessor: 'Paths',
Cell: ({ row }: CellProps<Ingress, Path[]>) => {
const results = row.original.Paths?.map((path: Path) => {
const isHttp = isHTTP(row.original.TLS || [], path.Host);
return (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
<span className="flex flex-nowrap items-center gap-1 px-2">
{link(path.Host, path.Path, isHttp)}
<Icon icon={ArrowRight} />
{`${path.ServiceName}:${path.Port}`}
{!path.HasService && (
<Badge type="warn" className="ml-1 gap-1">
<Icon icon={AlertTriangle} />
Service doesn&apos;t exist
</Badge>
)}
</span>
</div>
);
});
return results || <div />;
},
id: 'ingressRules',
disableFilters: true,
canHide: true,
disableSortBy: true,
};

View file

@ -1,4 +1,4 @@
import { CellProps, Column } from 'react-table'; import { CellContext } from '@tanstack/react-table';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
@ -6,28 +6,30 @@ import { Link } from '@@/Link';
import { Ingress } from '../../types'; import { Ingress } from '../../types';
export const name: Column<Ingress> = { import { columnHelper } from './helper';
Header: 'Name',
accessor: 'Name', export const name = columnHelper.accessor('Name', {
Cell: ({ row }: CellProps<Ingress>) => ( header: 'Name',
<Authorized cell: Cell,
authorizations="K8sIngressesW" id: 'name',
childrenUnauthorized={row.original.Name} });
>
function Cell({ row, getValue }: CellContext<Ingress, string>) {
const name = getValue();
return (
<Authorized authorizations="K8sIngressesW" childrenUnauthorized={name}>
<Link <Link
to="kubernetes.ingresses.edit" to="kubernetes.ingresses.edit"
params={{ params={{
uid: row.original.UID, uid: row.original.UID,
namespace: row.original.Namespace, namespace: row.original.Namespace,
name: row.original.Name, name,
}} }}
title={row.original.Name} title={name}
> >
{row.original.Name} {name}
</Link> </Link>
</Authorized> </Authorized>
), );
id: 'name', }
disableFilters: true,
canHide: true,
};

View file

@ -1,33 +1,40 @@
import { CellProps, Column, Row } from 'react-table'; import { CellContext, Row } from '@tanstack/react-table';
import { filterHOC } from '@/react/components/datatables/Filter';
import { filterHOC } from '@@/datatables/Filter';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { Ingress } from '../../types'; import { Ingress } from '../../types';
export const namespace: Column<Ingress> = { import { columnHelper } from './helper';
Header: 'Namespace',
accessor: 'Namespace', export const namespace = columnHelper.accessor('Namespace', {
Cell: ({ row }: CellProps<Ingress>) => ( header: 'Namespace',
id: 'namespace',
cell: Cell,
filterFn: (row: Row<Ingress>, columnId: string, filterValue: string[]) => {
if (filterValue.length === 0) {
return true;
}
return filterValue.includes(row.original.Namespace);
},
meta: {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
});
function Cell({ getValue }: CellContext<Ingress, string>) {
const namespace = getValue();
return (
<Link <Link
to="kubernetes.resourcePools.resourcePool" to="kubernetes.resourcePools.resourcePool"
params={{ params={{
id: row.original.Namespace, id: namespace,
}} }}
title={row.original.Namespace} title={namespace}
> >
{row.original.Namespace} {namespace}
</Link> </Link>
), );
id: 'namespace', }
disableFilters: false,
canHide: true,
Filter: filterHOC('Filter by namespace'),
filter: (rows: Row<Ingress>[], filterValue, filters) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((r) => filters.includes(r.original.Namespace));
},
};

View file

@ -1,11 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { Ingress } from '../../types'; export const type = columnHelper.accessor('Type', {
header: 'Type',
export const type: Column<Ingress> = {
Header: 'Type',
accessor: 'Type',
id: 'type', id: 'type',
disableFilters: true, });
canHide: true,
};

View file

@ -1,39 +1,31 @@
import { History } from 'lucide-react'; import { History } from 'lucide-react';
import { useStore } from 'zustand';
import { NomadEvent } from '@/react/nomad/types'; import { NomadEvent } from '@/react/nomad/types';
import { Datatable } from '@@/datatables'; import { Datatable } from '@@/datatables';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { createPersistedStore } from '@@/datatables/types'; import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { useColumns } from './columns'; import { columns } from './columns';
export interface EventsDatatableProps { export interface EventsDatatableProps {
data: NomadEvent[]; data: NomadEvent[];
isLoading: boolean; isLoading: boolean;
} }
const storageKey = 'events'; const storageKey = 'nomad_events';
const settingsStore = createPersistedStore(storageKey, 'Date'); const settingsStore = createPersistedStore(storageKey, 'date');
export function EventsDatatable({ data, isLoading }: EventsDatatableProps) { export function EventsDatatable({ data, isLoading }: EventsDatatableProps) {
const columns = useColumns(); const tableState = useTableState(settingsStore, storageKey);
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
return ( return (
<Datatable <Datatable
isLoading={isLoading} isLoading={isLoading}
settingsManager={tableState}
columns={columns} columns={columns}
dataset={data} dataset={data}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
titleIcon={History} titleIcon={History}
title="Events" title="Events"
totalCount={data.length} totalCount={data.length}

View file

@ -1,12 +1,12 @@
import { Column } from 'react-table';
import { NomadEvent } from '@/react/nomad/types';
import { isoDate } from '@/portainer/filters/filters'; import { isoDate } from '@/portainer/filters/filters';
export const date: Column<NomadEvent> = { import { columnHelper } from './helper';
Header: 'Date',
accessor: (row) => (row.Date ? isoDate(row.Date) : '-'), export const date = columnHelper.accessor('Date', {
header: 'Date',
id: 'date', id: 'date',
disableFilters: true, cell: ({ getValue }) => {
canHide: true, const date = getValue();
}; return date ? isoDate(date) : '-';
},
});

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { NomadEvent } from '@/react/nomad/types';
export const columnHelper = createColumnHelper<NomadEvent>();

View file

@ -1,9 +1,5 @@
import { useMemo } from 'react';
import { date } from './date'; import { date } from './date';
import { type } from './type'; import { type } from './type';
import { message } from './message'; import { message } from './message';
export function useColumns() { export const columns = [date, type, message];
return useMemo(() => [date, type, message], []);
}

View file

@ -1,11 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { NomadEvent } from '@/react/nomad/types'; export const message = columnHelper.accessor('Message', {
header: 'Message',
export const message: Column<NomadEvent> = {
Header: 'Message',
accessor: 'Message',
id: 'message', id: 'message',
disableFilters: true, });
canHide: true,
};

View file

@ -1,11 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { NomadEvent } from '@/react/nomad/types'; export const type = columnHelper.accessor('Type', {
header: 'Type',
export const type: Column<NomadEvent> = {
Header: 'Type',
accessor: 'Type',
id: 'type', id: 'type',
disableFilters: true, });
canHide: true,
};

View file

@ -1,12 +1,11 @@
import { useStore } from 'zustand';
import { Clock } from 'lucide-react'; import { Clock } from 'lucide-react';
import { Job } from '@/react/nomad/types'; import { Job } from '@/react/nomad/types';
import { useRepeater } from '@@/datatables/useRepeater'; import { useRepeater } from '@@/datatables/useRepeater';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { TableSettingsMenu } from '@@/datatables'; import { TableSettingsMenu } from '@@/datatables/TableSettingsMenu';
import { useSearchBarState } from '@@/datatables/SearchBar'; import { useTableState } from '@@/datatables/useTableState';
import { TasksDatatable } from './TasksDatatable'; import { TasksDatatable } from './TasksDatatable';
import { columns } from './columns'; import { columns } from './columns';
@ -19,7 +18,7 @@ export interface JobsDatatableProps {
isLoading?: boolean; isLoading?: boolean;
} }
const storageKey = 'jobs'; const storageKey = 'nomad_jobs';
const settingsStore = createStore(storageKey); const settingsStore = createStore(storageKey);
export function JobsDatatable({ export function JobsDatatable({
@ -27,20 +26,14 @@ export function JobsDatatable({
refreshData, refreshData,
isLoading, isLoading,
}: JobsDatatableProps) { }: JobsDatatableProps) {
const [search, setSearch] = useSearchBarState(storageKey); const tableState = useTableState(settingsStore, storageKey);
const settings = useStore(settingsStore); useRepeater(tableState.autoRefreshRate, refreshData);
useRepeater(settings.autoRefreshRate, refreshData);
return ( return (
<ExpandableDatatable<Job> <ExpandableDatatable
dataset={jobs} dataset={jobs}
columns={columns} columns={columns}
initialPageSize={settings.pageSize} settingsManager={tableState}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Nomad Jobs" title="Nomad Jobs"
titleIcon={Clock} titleIcon={Clock}
disableSelect disableSelect
@ -49,9 +42,10 @@ export function JobsDatatable({
isLoading={isLoading} isLoading={isLoading}
renderTableSettings={() => ( renderTableSettings={() => (
<TableSettingsMenu> <TableSettingsMenu>
<JobsDatatableSettings settings={settings} /> <JobsDatatableSettings settings={tableState} />
</TableSettingsMenu> </TableSettingsMenu>
)} )}
expandOnRowClick
/> />
); );
} }

View file

@ -2,20 +2,18 @@ import { Task } from '@/react/nomad/types';
import { NestedDatatable } from '@@/datatables/NestedDatatable'; import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { useColumns } from './columns'; import { columns } from './columns';
export interface Props { export interface Props {
data: Task[]; data: Task[];
} }
export function TasksDatatable({ data }: Props) { export function TasksDatatable({ data }: Props) {
const columns = useColumns();
return ( return (
<NestedDatatable <NestedDatatable
columns={columns} columns={columns}
dataset={data} dataset={data}
defaultSortBy="taskName" initialSortBy={{ id: 'taskName', desc: false }}
/> />
); );
} }

View file

@ -1,24 +1,23 @@
import { CellProps, Column } from 'react-table';
import { Clock, FileText } from 'lucide-react'; import { Clock, FileText } from 'lucide-react';
import { CellContext } from '@tanstack/react-table';
import { Task } from '@/react/nomad/types'; import { Task } from '@/react/nomad/types';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
export const actions: Column<Task> = { import { columnHelper } from './helper';
Header: 'Task Actions',
id: 'actions',
disableFilters: true,
canHide: true,
disableResizing: true,
width: '5px',
sortType: 'string',
Filter: () => null,
Cell: ActionsCell,
};
export function ActionsCell({ row }: CellProps<Task>) { export const actions = columnHelper.display({
header: 'Task Actions',
id: 'actions',
meta: {
width: '5px',
},
cell: ActionsCell,
});
export function ActionsCell({ row }: CellContext<Task, unknown>) {
const params = { const params = {
allocationID: row.original.AllocationID, allocationID: row.original.AllocationID,
taskName: row.original.TaskName, taskName: row.original.TaskName,

View file

@ -1,11 +1,10 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { Task } from '@/react/nomad/types'; export const allocationID = columnHelper.accessor('AllocationID', {
header: 'Allocation ID',
export const allocationID: Column<Task> = {
Header: 'Allocation ID',
accessor: (row) => row.AllocationID || '-',
id: 'allocationID', id: 'allocationID',
disableFilters: true, cell: ({ getValue }) => {
canHide: true, const value = getValue();
}; return value || '-';
},
});

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Task } from '@/react/nomad/types';
export const columnHelper = createColumnHelper<Task>();

View file

@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { taskStatus } from './taskStatus'; import { taskStatus } from './taskStatus';
import { taskName } from './taskName'; import { taskName } from './taskName';
import { taskGroup } from './taskGroup'; import { taskGroup } from './taskGroup';
@ -7,9 +5,11 @@ import { allocationID } from './allocationID';
import { started } from './started'; import { started } from './started';
import { actions } from './actions'; import { actions } from './actions';
export function useColumns() { export const columns = [
return useMemo( taskStatus,
() => [taskStatus, taskName, taskGroup, allocationID, actions, started], taskName,
[] taskGroup,
); allocationID,
} actions,
started,
];

View file

@ -1,19 +1,17 @@
import moment from 'moment'; import moment from 'moment';
import { Column } from 'react-table';
import { Task } from '@/react/nomad/types'; import { Task } from '@/react/nomad/types';
import { isoDate } from '@/portainer/filters/filters'; import { isoDate } from '@/portainer/filters/filters';
import { columnHelper } from './helper';
function accessor(row: Task) { function accessor(row: Task) {
const momentDate = moment(row.StartedAt); const momentDate = moment(row.StartedAt);
const isValid = momentDate.unix() > 0; const isValid = momentDate.unix() > 0;
return isValid ? isoDate(momentDate) : '-'; return isValid ? isoDate(momentDate) : '-';
} }
export const started: Column<Task> = { export const started = columnHelper.accessor(accessor, {
accessor, header: 'Started',
Header: 'Started',
id: 'startedName', id: 'startedName',
disableFilters: true, });
canHide: true,
};

View file

@ -1,11 +1,10 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { Task } from '@/react/nomad/types'; export const taskGroup = columnHelper.accessor('TaskGroup', {
header: 'Task Group',
export const taskGroup: Column<Task> = {
Header: 'Task Group',
accessor: (row) => row.TaskGroup || '-',
id: 'taskGroup', id: 'taskGroup',
disableFilters: true, cell: ({ getValue }) => {
canHide: true, const value = getValue();
}; return value || '-';
},
});

View file

@ -1,11 +1,6 @@
import { Column } from 'react-table'; import { columnHelper } from './helper';
import { Task } from '@/react/nomad/types'; export const taskName = columnHelper.accessor('TaskName', {
header: 'Task Name',
export const taskName: Column<Task> = {
Header: 'Task Name',
accessor: (row) => row.TaskName || '-',
id: 'taskName', id: 'taskName',
disableFilters: true, });
canHide: true,
};

Some files were not shown because too many files have changed in this diff Show more