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

refactor(ui/datatables): migrate views to use datatable component [EE-4064] (#7609)

This commit is contained in:
Chaim Lev-Ari 2022-11-22 14:16:34 +02:00 committed by GitHub
parent 0f0513c684
commit fe8e834dbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
90 changed files with 1714 additions and 2717 deletions

View file

@ -123,6 +123,10 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
endpointCount := len(endpoints) endpointCount := len(endpoints)
if start < 0 {
start = 0
}
if start > endpointCount { if start > endpointCount {
start = endpointCount start = endpointCount
} }

View file

@ -0,0 +1,18 @@
import { useMutation } from 'react-query';
import { activateDevice } from './open-amt.service';
export const activateDeviceMutationKey = [
'environments',
'open-amt',
'activate',
];
export function useActivateDeviceMutation() {
return useMutation(activateDevice, {
mutationKey: activateDeviceMutationKey,
meta: {
message: 'Unable to associate with OpenAMT',
},
});
}

View file

@ -40,4 +40,6 @@ export interface LicenseInfo {
nodes: number; nodes: number;
type: LicenseType; type: LicenseType;
valid: boolean; valid: boolean;
enforcedAt: number;
enforced: boolean;
} }

View file

@ -2,8 +2,10 @@ import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications'; import { error as notifyError } from '@/portainer/services/notifications';
import { getNodesCount } from '../services/api/status.service';
import { getLicenseInfo } from './license.service'; import { getLicenseInfo } from './license.service';
import { LicenseInfo } from './types'; import { LicenseInfo, LicenseType } from './types';
export function useLicenseInfo() { export function useLicenseInfo() {
const { isLoading, data: info } = useQuery<LicenseInfo, Error>( const { isLoading, data: info } = useQuery<LicenseInfo, Error>(
@ -18,3 +20,33 @@ export function useLicenseInfo() {
return { isLoading, info }; return { isLoading, info };
} }
function useNodesCounts() {
const { isLoading, data } = useQuery(
['status', 'nodes'],
() => getNodesCount(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get nodes count');
},
}
);
return { nodesCount: data || 0, isLoading };
}
export function useIntegratedLicenseInfo() {
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (
isLoadingLicense ||
isLoadingNodes ||
!info ||
info.type === LicenseType.Trial
) {
return null;
}
return { licenseInfo: info as LicenseInfo, usedNodes: nodesCount };
}

View file

@ -77,6 +77,11 @@
</form> </form>
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<access-tokens-datatable <access-tokens-datatable
title-text="Access tokens" title-text="Access tokens"
title-icon="key" title-icon="key"
@ -86,6 +91,11 @@
remove-action="removeAction" remove-action="removeAction"
ui-can-exit="uiCanExit" ui-can-exit="uiCanExit"
></access-tokens-datatable> ></access-tokens-datatable>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<theme-settings></theme-settings> <theme-settings></theme-settings>
</div> </div>
</div> </div>

View file

@ -1,119 +1,51 @@
import { useEffect } from 'react';
import {
useTable,
useSortBy,
useGlobalFilter,
usePagination,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Box, Plus, Trash2 } from 'react-feather'; import { Box, Plus, Trash2 } from 'react-feather';
import { useStore } from 'zustand';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { PaginationControls } from '@@/PaginationControls'; import { Datatable } from '@@/datatables';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { Checkbox } from '@@/form-components/Checkbox';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettings } from './types'; import { columns } from './columns';
import { useColumns } from './columns';
const tableKey = 'containergroups';
const settingsStore = createPersistedStore(tableKey, 'name');
export interface Props { export interface Props {
tableKey: string;
dataset: ContainerGroup[]; dataset: ContainerGroup[];
onRemoveClick(containerIds: string[]): void; onRemoveClick(containerIds: string[]): void;
} }
export function ContainersDatatable({ export function ContainersDatatable({ dataset, onRemoveClick }: Props) {
dataset, const settings = useStore(settingsStore);
tableKey, const [search, setSearch] = useSearchBarState(tableKey);
onRemoveClick,
}: Props) {
const { settings, setTableSettings } = useTableSettings<TableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
const columns = useColumns();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<ContainerGroup>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
selectCheckboxComponent: Checkbox,
autoResetSelectedRows: false,
getRowId(row) {
return row.id;
},
},
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const debouncedSearchValue = useDebouncedValue(searchBarValue);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<div className="row"> <Datatable
<div className="col-sm-12"> dataset={dataset}
<TableContainer> columns={columns}
<TableTitle icon={Box} label="Containers"> initialPageSize={settings.pageSize}
<SearchBar onPageSizeChange={settings.setPageSize}
value={searchBarValue} initialSortBy={settings.sortBy}
onChange={handleSearchBarChange} onSortByChange={settings.setSortBy}
/> searchValue={search}
onSearchChange={setSearch}
<TableActions> title="Containers"
titleIcon={Box}
getRowId={(container) => container.id}
emptyContentLabel="No container available."
renderTableActions={(selectedRows) => (
<>
<Authorized authorizations="AzureContainerGroupDelete"> <Authorized authorizations="AzureContainerGroupDelete">
<Button <Button
color="dangerlight" color="dangerlight"
disabled={selectedFlatRows.length === 0} disabled={selectedRows.length === 0}
onClick={() => onClick={() => handleRemoveClick(selectedRows.map((r) => r.id))}
handleRemoveClick(
selectedFlatRows.map((row) => row.original.id)
)
}
icon={Trash2} icon={Trash2}
> >
Remove Remove
@ -125,66 +57,9 @@ export function ContainersDatatable({
<Button icon={Plus}>Add container</Button> <Button icon={Plus}>Add container</Button>
</Link> </Link>
</Authorized> </Authorized>
</TableActions> </>
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<ContainerGroup>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content
prepareRow={prepareRow}
renderRow={(row, { key, className, role, style }) => (
<TableRow<ContainerGroup>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)} )}
rows={page}
emptyContent="No container available."
/> />
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={dataset.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
</div>
</div>
); );
async function handleRemoveClick(containerIds: string[]) { async function handleRemoveClick(containerIds: string[]) {
@ -197,20 +72,4 @@ export function ContainersDatatable({
return onRemoveClick(containerIds); return onRemoveClick(containerIds);
} }
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
} }

View file

@ -9,19 +9,10 @@ import { useContainerGroups } from '@/react/azure/queries/useContainerGroups';
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { ContainersDatatable } from './ContainersDatatable'; import { ContainersDatatable } from './ContainersDatatable';
import { TableSettings } from './types';
export function ListView() { export function ListView() {
const defaultSettings: TableSettings = {
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
const tableKey = 'containergroups';
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const subscriptionsQuery = useSubscriptions(environmentId); const subscriptionsQuery = useSubscriptions(environmentId);
@ -45,13 +36,11 @@ export function ListView() {
reload reload
title="Container list" title="Container list"
/> />
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
<ContainersDatatable <ContainersDatatable
tableKey={tableKey}
dataset={groupsQuery.containerGroups} dataset={groupsQuery.containerGroups}
onRemoveClick={handleRemove} onRemoveClick={handleRemove}
/> />
</TableSettingsProvider>
</> </>
); );
} }

View file

@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { name } from './name'; import { name } from './name';
import { location } from './location'; import { location } from './location';
import { ports } from './ports'; import { ports } from './ports';
import { ownership } from './ownership'; import { ownership } from './ownership';
export function useColumns() { export const columns = [name, location, ports, ownership];
return useMemo(() => [name, location, ports, ownership], []);
}

View file

@ -1,8 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
export interface TableSettings
extends PaginationTableSettings,
SortableTableSettings {}

View file

@ -8,79 +8,95 @@ import {
Row, Row,
TableInstance, TableInstance,
TableState, TableState,
TableRowProps,
useExpanded,
} from 'react-table'; } from 'react-table';
import { ReactNode, useEffect } from 'react'; import { ReactNode } from 'react';
import { useRowSelectColumn } from '@lineup-lite/hooks'; import { useRowSelectColumn } from '@lineup-lite/hooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { PaginationControls } from '@@/PaginationControls';
import { IconProps } from '@@/Icon'; import { IconProps } from '@@/Icon';
import { Table } from './Table'; import { Table } from './Table';
import { multiple } from './filter-types'; import { multiple } from './filter-types';
import { SearchBar, useSearchBarState } from './SearchBar';
import { SelectedRowsCount } from './SelectedRowsCount';
import { TableSettingsProvider } from './useZustandTableSettings';
import { useRowSelect } from './useRowSelect'; import { useRowSelect } from './useRowSelect';
import { PaginationTableSettings, SortableTableSettings } from './types'; import { BasicTableSettings } from './types';
import { DatatableHeader } from './DatatableHeader';
import { DatatableFooter } from './DatatableFooter';
import { DatatableContent } from './DatatableContent';
import { defaultGetRowId } from './defaultGetRowId';
import { emptyPlugin } from './emptyReactTablePlugin';
import { useGoToHighlightedRow } from './useGoToHighlightedRow';
interface DefaultTableSettings export interface Props<D extends Record<string, unknown>> {
extends SortableTableSettings,
PaginationTableSettings {}
interface TitleOptionsVisible {
title: string;
icon?: IconProps['icon'];
featherIcon?: IconProps['featherIcon'];
hide?: never;
}
type TitleOptions = TitleOptionsVisible | { hide: true };
interface Props<
D extends Record<string, unknown>,
TSettings extends DefaultTableSettings
> {
dataset: D[]; dataset: D[];
storageKey: string;
columns: readonly Column<D>[]; columns: readonly Column<D>[];
renderTableSettings?(instance: TableInstance<D>): ReactNode; renderTableSettings?(instance: TableInstance<D>): ReactNode;
renderTableActions?(selectedRows: D[]): ReactNode; renderTableActions?(selectedRows: D[]): ReactNode;
settingsStore: TSettings;
disableSelect?: boolean; disableSelect?: boolean;
getRowId?(row: D): string; getRowId?(row: D): string;
isRowSelectable?(row: Row<D>): boolean; isRowSelectable?(row: Row<D>): boolean;
emptyContentLabel?: string; emptyContentLabel?: string;
titleOptions: TitleOptions; title?: string;
titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState<D>>; initialTableState?: Partial<TableState<D>>;
isLoading?: boolean; isLoading?: boolean;
totalCount?: number; totalCount?: number;
description?: JSX.Element; description?: ReactNode;
initialActiveItem?: string; pageCount?: number;
initialSortBy?: BasicTableSettings['sortBy'];
initialPageSize?: BasicTableSettings['pageSize'];
highlightedItemId?: string;
searchValue: string;
onSearchChange(search: string): void;
onSortByChange(colId: string, desc: boolean): void;
onPageSizeChange(pageSize: number): void;
// send state up
onPageChange?(page: number): void;
renderRow?(
row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string
): ReactNode;
expandable?: boolean;
noWidget?: boolean;
} }
export function Datatable< export function Datatable<D extends Record<string, unknown>>({
D extends Record<string, unknown>,
TSettings extends DefaultTableSettings
>({
columns, columns,
dataset, dataset,
storageKey, renderTableSettings = () => null,
renderTableSettings, renderTableActions = () => null,
renderTableActions,
settingsStore,
disableSelect, disableSelect,
getRowId = defaultGetRowId, getRowId = defaultGetRowId,
isRowSelectable = () => true, isRowSelectable = () => true,
titleOptions, title,
titleIcon,
emptyContentLabel, emptyContentLabel,
initialTableState = {}, initialTableState = {},
isLoading, isLoading,
totalCount = dataset.length, totalCount = dataset.length,
description, description,
initialActiveItem, pageCount,
}: Props<D, TSettings>) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); initialSortBy,
initialPageSize = 10,
onPageChange = () => {},
onPageSizeChange,
onSortByChange,
searchValue,
onSearchChange,
renderRow = defaultRenderRow,
expandable = false,
highlightedItemId,
noWidget,
}: Props<D>) {
const isServerSidePagination = typeof pageCount !== 'undefined';
const tableInstance = useTable<D>( const tableInstance = useTable<D>(
{ {
@ -89,183 +105,104 @@ export function Datatable<
data: dataset, data: dataset,
filterTypes: { multiple }, filterTypes: { multiple },
initialState: { initialState: {
pageSize: settingsStore.pageSize || 10, pageSize: initialPageSize,
sortBy: [settingsStore.sortBy], sortBy: initialSortBy ? [initialSortBy] : [],
globalFilter: searchBarValue, globalFilter: searchValue,
...initialTableState, ...initialTableState,
}, },
isRowSelectable, isRowSelectable,
autoResetExpanded: false,
autoResetSelectedRows: false, autoResetSelectedRows: false,
getRowId, getRowId,
stateReducer: (newState, action) => { ...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
switch (action.type) {
case 'setGlobalFilter':
setSearchBarValue(action.filterValue);
break;
case 'toggleSortBy':
settingsStore.setSortBy(action.columnId, action.desc);
break;
case 'setPageSize':
settingsStore.setPageSize(action.pageSize);
break;
default:
break;
}
return newState;
},
}, },
useFilters, useFilters,
useGlobalFilter, useGlobalFilter,
useSortBy, useSortBy,
expandable ? useExpanded : emptyPlugin,
usePagination, usePagination,
useRowSelect, useRowSelect,
!disableSelect ? useRowSelectColumn : emptyPlugin !disableSelect ? useRowSelectColumn : emptyPlugin
); );
const { useGoToHighlightedRow(
rows, isServerSidePagination,
selectedFlatRows, tableInstance.state.pageSize,
getTableProps, tableInstance.rows,
getTableBodyProps, handlePageChange,
headerGroups, highlightedItemId
page,
prepareRow,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = tableInstance;
useEffect(() => {
if (initialActiveItem && pageSize !== rows.length) {
const paginatedData = [...Array(Math.ceil(rows.length / pageSize))].map(
(_, i) => rows.slice(pageSize * i, pageSize + pageSize * i)
); );
const itemPage = paginatedData.findIndex((sub) => const selectedItems = tableInstance.selectedFlatRows.map(
sub.some((row) => row.id === initialActiveItem) (row) => row.original
); );
gotoPage(itemPage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialActiveItem]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const selectedItems = selectedFlatRows.map((row) => row.original);
return ( return (
<div className="row"> <Table.Container noWidget={noWidget}>
<div className="col-sm-12"> <DatatableHeader
<TableSettingsProvider settings={settingsStore}> onSearchChange={handleSearchBarChange}
<Table.Container> searchValue={searchValue}
{isTitleVisible(titleOptions) && ( title={title}
<Table.Title titleIcon={titleIcon}
label={titleOptions.title} renderTableActions={() => renderTableActions(selectedItems)}
icon={titleOptions.icon} renderTableSettings={() => renderTableSettings(tableInstance)}
featherIcon={titleOptions.featherIcon}
description={description} description={description}
>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && (
<Table.Actions>
{renderTableActions(selectedItems)}
</Table.Actions>
)}
<Table.TitleActions>
{!!renderTableSettings && renderTableSettings(tableInstance)}
</Table.TitleActions>
</Table.Title>
)}
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<Table.HeaderRow<D>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/> />
); <DatatableContent<D>
})} tableInstance={tableInstance}
</thead> renderRow={(row, rowProps) =>
<tbody renderRow(row, rowProps, highlightedItemId)
className={tbodyProps.className} }
role={tbodyProps.role} emptyContentLabel={emptyContentLabel}
style={tbodyProps.style}
>
<Table.Content<D>
rows={page}
isLoading={isLoading} isLoading={isLoading}
prepareRow={prepareRow} onSortChange={handleSortChange}
emptyContent={emptyContentLabel}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<D>
cells={row.cells}
key={key}
className={clsx(
className,
initialActiveItem &&
initialActiveItem === row.id &&
'active'
)}
role={role}
style={style}
/> />
)}
/> <DatatableFooter
</tbody> onPageChange={handlePageChange}
</Table> onPageSizeChange={handlePageSizeChange}
<Table.Footer> page={tableInstance.state.pageIndex}
<SelectedRowsCount value={selectedFlatRows.length} /> pageSize={tableInstance.state.pageSize}
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={totalCount} totalCount={totalCount}
onPageLimitChange={setPageSize} totalSelected={selectedItems.length}
/> />
</Table.Footer>
</Table.Container> </Table.Container>
</TableSettingsProvider> );
</div>
</div> function handleSearchBarChange(value: string) {
tableInstance.setGlobalFilter(value);
onSearchChange(value);
}
function handlePageChange(page: number) {
tableInstance.gotoPage(page);
onPageChange(page);
}
function handleSortChange(colId: string, desc: boolean) {
onSortByChange(colId, desc);
}
function handlePageSizeChange(pageSize: number) {
tableInstance.setPageSize(pageSize);
onPageSizeChange(pageSize);
}
}
function defaultRenderRow<D extends Record<string, unknown>>(
row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string
) {
return (
<Table.Row<D>
key={rowProps.key}
cells={row.cells}
className={clsx(rowProps.className, {
active: highlightedItemId === row.id,
})}
role={rowProps.role}
style={rowProps.style}
/>
); );
} }
function isTitleVisible(
titleSettings: TitleOptions
): titleSettings is TitleOptionsVisible {
return !titleSettings.hide;
}
function defaultGetRowId<D extends Record<string, unknown>>(row: D): string {
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
return row.id.toString();
}
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
return row.Id.toString();
}
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
return row.ID.toString();
}
return '';
}
function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View file

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

View file

@ -0,0 +1,36 @@
import { PaginationControls } from '@@/PaginationControls';
import { Table } from './Table';
import { SelectedRowsCount } from './SelectedRowsCount';
interface Props {
totalSelected: number;
pageSize: number;
page: number;
onPageChange(page: number): void;
totalCount: number;
onPageSizeChange(pageSize: number): void;
}
export function DatatableFooter({
totalSelected,
pageSize,
page,
onPageChange,
totalCount,
onPageSizeChange,
}: Props) {
return (
<Table.Footer>
<SelectedRowsCount value={totalSelected} />
<PaginationControls
showAll
pageLimit={pageSize}
page={page + 1}
onPageChange={(page) => onPageChange(page - 1)}
totalCount={totalCount}
onPageLimitChange={onPageSizeChange}
/>
</Table.Footer>
);
}

View file

@ -0,0 +1,42 @@
import { ReactNode } from 'react';
import { IconProps } from '@@/Icon';
import { SearchBar } from './SearchBar';
import { Table } from './Table';
type Props = {
title?: string;
titleIcon?: IconProps['icon'];
searchValue: string;
onSearchChange(value: string): void;
renderTableSettings?(): ReactNode;
renderTableActions?(): ReactNode;
description?: ReactNode;
};
export function DatatableHeader({
onSearchChange,
renderTableActions,
renderTableSettings,
searchValue,
title,
titleIcon,
description,
}: Props) {
if (!title) {
return null;
}
return (
<Table.Title label={title} icon={titleIcon} description={description}>
<SearchBar value={searchValue} onChange={onSearchChange} />
{renderTableActions && (
<Table.Actions>{renderTableActions()}</Table.Actions>
)}
<Table.TitleActions>
{!!renderTableSettings && renderTableSettings()}
</Table.TitleActions>
</Table.Title>
);
}

View file

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

View file

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

View file

@ -0,0 +1,74 @@
import {
useTable,
useFilters,
useSortBy,
Column,
TableState,
usePagination,
} from 'react-table';
import { Table } from './Table';
import { multiple } from './filter-types';
import { NestedTable } from './NestedTable';
import { DatatableContent } from './DatatableContent';
import { defaultGetRowId } from './defaultGetRowId';
interface Props<D extends Record<string, unknown>> {
dataset: D[];
columns: readonly Column<D>[];
getRowId?(row: D): string;
emptyContentLabel?: string;
initialTableState?: Partial<TableState<D>>;
isLoading?: boolean;
defaultSortBy?: string;
}
export function NestedDatatable<D extends Record<string, unknown>>({
columns,
dataset,
getRowId = defaultGetRowId,
emptyContentLabel,
initialTableState = {},
isLoading,
defaultSortBy,
}: Props<D>) {
const tableInstance = useTable<D>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
sortBy: defaultSortBy ? [{ id: defaultSortBy, desc: true }] : [],
...initialTableState,
},
autoResetSelectedRows: false,
getRowId,
},
useFilters,
useSortBy,
usePagination
);
return (
<NestedTable>
<Table.Container>
<DatatableContent<D>
tableInstance={tableInstance}
isLoading={isLoading}
emptyContentLabel={emptyContentLabel}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<D>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
/>
</Table.Container>
</NestedTable>
);
}

View file

@ -1,7 +1,7 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import './InnerDatatable.css'; import './NestedTable.css';
export function InnerDatatable({ children }: PropsWithChildren<unknown>) { export function NestedTable({ children }: PropsWithChildren<unknown>) {
return <div className="inner-datatable">{children}</div>; return <div className="inner-datatable">{children}</div>;
} }

View file

@ -5,7 +5,7 @@ import {
import { Checkbox } from '@@/form-components/Checkbox'; import { Checkbox } from '@@/form-components/Checkbox';
import { useTableSettings } from './useZustandTableSettings'; import { useTableSettings } from './useTableSettings';
export interface Action { export interface Action {
id: QuickAction; id: QuickAction;
@ -17,7 +17,7 @@ interface Props {
} }
export function QuickActionsSettings({ actions }: Props) { export function QuickActionsSettings({ actions }: Props) {
const { settings } = const settings =
useTableSettings<SettableQuickActionsTableSettings<QuickAction>>(); useTableSettings<SettableQuickActionsTableSettings<QuickAction>>();
return ( return (

View file

@ -35,6 +35,8 @@ function MainComponent({
); );
} }
MainComponent.displayName = 'Table';
interface SubComponents { interface SubComponents {
Container: typeof TableContainer; Container: typeof TableContainer;
Actions: typeof TableActions; Actions: typeof TableActions;

View file

@ -2,12 +2,28 @@ import { PropsWithChildren } from 'react';
import { Widget, WidgetBody } from '@@/Widget'; import { Widget, WidgetBody } from '@@/Widget';
export function TableContainer({ children }: PropsWithChildren<unknown>) { interface Props {
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
noWidget?: boolean;
}
export function TableContainer({
children,
noWidget = false,
}: PropsWithChildren<Props>) {
if (noWidget) {
return <div className="datatable">{children}</div>;
}
return ( return (
<div className="row">
<div className="col-sm-12">
<div className="datatable"> <div className="datatable">
<Widget> <Widget>
<WidgetBody className="no-padding">{children}</WidgetBody> <WidgetBody className="no-padding">{children}</WidgetBody>
</Widget> </Widget>
</div> </div>
</div>
</div>
); );
} }

View file

@ -6,7 +6,7 @@ interface Props {
icon?: ReactNode | ComponentType<unknown>; icon?: ReactNode | ComponentType<unknown>;
featherIcon?: boolean; featherIcon?: boolean;
label: string; label: string;
description?: JSX.Element; description?: ReactNode;
} }
export function TableTitle({ export function TableTitle({
@ -34,7 +34,7 @@ export function TableTitle({
</div> </div>
{children} {children}
</div> </div>
{description && description} {description}
</div> </div>
); );
} }

View file

@ -0,0 +1,17 @@
export function defaultGetRowId<D extends Record<string, unknown>>(
row: D
): string {
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
return row.id.toString();
}
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
return row.Id.toString();
}
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
return row.ID.toString();
}
return '';
}

View file

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

View file

@ -0,0 +1,49 @@
import { ChevronDown, ChevronUp } from 'react-feather';
import { CellProps, Column, HeaderProps } from 'react-table';
import { Button } from '@@/buttons';
export function buildExpandColumn<T extends Record<string, unknown>>(
isExpandable: (item: T) => boolean
): Column<T> {
return {
id: 'expand',
Header: ({
filteredFlatRows,
getToggleAllRowsExpandedProps,
isAllRowsExpanded,
}: HeaderProps<T>) => {
const hasExpandableItems = filteredFlatRows.some((item) =>
isExpandable(item.original)
);
return (
hasExpandableItems && (
<Button
// eslint-disable-next-line react/jsx-props-no-spreading
{...getToggleAllRowsExpandedProps()}
color="none"
icon={isAllRowsExpanded ? ChevronDown : ChevronUp}
/>
)
);
},
Cell: ({ row }: CellProps<T>) => (
<div className="vertical-center">
{isExpandable(row.original) && (
<Button
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...row.getToggleRowExpandedProps()}
color="none"
icon={row.isExpanded ? ChevronDown : ChevronUp}
/>
)}
</div>
),
disableFilters: true,
Filter: () => null,
canHide: false,
width: 30,
disableResizing: true,
};
}

View file

@ -1,15 +0,0 @@
export interface PaginationTableSettings {
pageSize: number;
}
export interface SortableTableSettings {
sortBy: { id: string; desc: boolean };
}
export interface SettableColumnsTableSettings {
hiddenColumns: string[];
}
export interface RefreshableTableSettings {
autoRefreshRate: number;
}

View file

@ -1,15 +1,20 @@
import { createStore } from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
export interface PaginationTableSettings { export interface PaginationTableSettings {
pageSize: number; pageSize: number;
setPageSize: (pageSize: number) => void; setPageSize: (pageSize: number) => void;
} }
type Set<T> = ( type ZustandSetFunc<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>), partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined replace?: boolean | undefined
) => void; ) => void;
export function paginationSettings( export function paginationSettings(
set: Set<PaginationTableSettings> set: ZustandSetFunc<PaginationTableSettings>
): PaginationTableSettings { ): PaginationTableSettings {
return { return {
pageSize: 10, pageSize: 10,
@ -23,12 +28,14 @@ export interface SortableTableSettings {
} }
export function sortableSettings( export function sortableSettings(
set: Set<SortableTableSettings>, set: ZustandSetFunc<SortableTableSettings>,
initialSortBy = 'name', initialSortBy: string | { id: string; desc: boolean }
desc = false
): SortableTableSettings { ): SortableTableSettings {
return { return {
sortBy: { id: initialSortBy, desc }, sortBy:
typeof initialSortBy === 'string'
? { id: initialSortBy, desc: false }
: initialSortBy,
setSortBy: (id: string, desc: boolean) => set({ sortBy: { id, desc } }), setSortBy: (id: string, desc: boolean) => set({ sortBy: { id, desc } }),
}; };
} }
@ -39,7 +46,7 @@ export interface SettableColumnsTableSettings {
} }
export function hiddenColumnsSettings( export function hiddenColumnsSettings(
set: Set<SettableColumnsTableSettings> set: ZustandSetFunc<SettableColumnsTableSettings>
): SettableColumnsTableSettings { ): SettableColumnsTableSettings {
return { return {
hiddenColumns: [], hiddenColumns: [],
@ -53,10 +60,38 @@ export interface RefreshableTableSettings {
} }
export function refreshableSettings( export function refreshableSettings(
set: Set<RefreshableTableSettings> set: ZustandSetFunc<RefreshableTableSettings>
): RefreshableTableSettings { ): RefreshableTableSettings {
return { return {
autoRefreshRate: 0, autoRefreshRate: 0,
setAutoRefreshRate: (autoRefreshRate: number) => set({ autoRefreshRate }), setAutoRefreshRate: (autoRefreshRate: number) => set({ autoRefreshRate }),
}; };
} }
export interface BasicTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export function createPersistedStore<T extends BasicTableSettings>(
storageKey: string,
initialSortBy: string | { id: string; desc: boolean } = 'name',
create: (set: ZustandSetFunc<T>) => Omit<T, keyof BasicTableSettings> = () =>
({} as T)
) {
return createStore<T>()(
persist(
(set) =>
({
...sortableSettings(
set as ZustandSetFunc<SortableTableSettings>,
initialSortBy
),
...paginationSettings(set as ZustandSetFunc<PaginationTableSettings>),
...create(set),
} as T),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -0,0 +1,51 @@
import _ from 'lodash';
import { useRef, useLayoutEffect, useEffect } from 'react';
export function useGoToHighlightedRow<T extends { id: string }>(
isServerSidePagination: boolean,
pageSize: number,
rows: Array<T>,
goToPage: (page: number) => void,
highlightedItemId?: string
) {
const handlePageChangeRef = useRef(goToPage);
useLayoutEffect(() => {
handlePageChangeRef.current = goToPage;
});
const highlightedItemIdRef = useRef(highlightedItemId);
useEffect(() => {
if (
!isServerSidePagination &&
highlightedItemId &&
highlightedItemId !== highlightedItemIdRef.current
) {
const page = getRowPage(highlightedItemId, pageSize, rows);
if (page) {
handlePageChangeRef.current(page);
}
highlightedItemIdRef.current = highlightedItemId;
}
}, [highlightedItemId, isServerSidePagination, rows, pageSize]);
}
function getRowPage<T extends { id: string }>(
rowID: string,
pageSize: number,
rows: Array<T>
) {
const totalRows = rows.length;
if (!rowID || pageSize > totalRows) {
return 0;
}
const paginatedData = _.chunk(rows, pageSize);
const itemPage = paginatedData.findIndex((sub) =>
sub.some((row) => row.id === rowID)
);
return itemPage;
}

View file

@ -1,91 +1,34 @@
import { import { Context, createContext, ReactNode, useContext } from 'react';
Context, import { StoreApi, useStore } from 'zustand';
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { useLocalStorage } from '@/react/hooks/useLocalStorage'; const TableSettingsContext = createContext<StoreApi<object> | null>(null);
interface TableSettingsContextInterface<T> {
settings: T;
setTableSettings(partialSettings: Partial<T>): void;
setTableSettings(mutation: (settings: T) => T): void;
}
const TableSettingsContext = createContext<TableSettingsContextInterface<
Record<string, unknown>
> | null>(null);
TableSettingsContext.displayName = 'TableSettingsContext'; TableSettingsContext.displayName = 'TableSettingsContext';
export function useTableSettings<T>() { export function useTableSettings<T extends object>() {
const Context = getContextType<T>(); const Context = getContextType<T>();
const context = useContext(Context); const context = useContext(Context);
if (context === null) { if (context === null) {
throw new Error('must be nested under TableSettingsProvider'); throw new Error('must be nested under TableSettingsProvider');
} }
return context; return useStore(context);
} }
interface ProviderProps<T> { interface ProviderProps<T extends object> {
children: ReactNode; children: ReactNode;
defaults?: T; settings: StoreApi<T>;
storageKey: string;
} }
export function TableSettingsProvider<T>({ export function TableSettingsProvider<T extends object>({
children, children,
defaults, settings,
storageKey,
}: ProviderProps<T>) { }: ProviderProps<T>) {
const Context = getContextType<T>(); const Context = getContextType<T>();
const [storage, setStorage] = useLocalStorage<T>( return <Context.Provider value={settings}>{children}</Context.Provider>;
keyBuilder(storageKey),
defaults as T
);
const [settings, setTableSettings] = useState(storage);
const handleChange = useCallback(
(mutation: Partial<T> | ((settings: T) => T)): void => {
setTableSettings((settings) => {
const newTableSettings =
mutation instanceof Function
? mutation(settings)
: { ...settings, ...mutation };
setStorage(newTableSettings);
return newTableSettings;
});
},
[setStorage]
);
const contextValue = useMemo(
() => ({
settings,
setTableSettings: handleChange,
}),
[settings, handleChange]
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
function keyBuilder(key: string) {
return `datatable_TableSettings_${key}`;
}
} }
function getContextType<T>() { function getContextType<T extends object>() {
return TableSettingsContext as unknown as Context< return TableSettingsContext as unknown as Context<StoreApi<T>>;
TableSettingsContextInterface<T>
>;
} }

View file

@ -1,49 +0,0 @@
import { Context, createContext, ReactNode, useContext, useMemo } from 'react';
interface TableSettingsContextInterface<T> {
settings: T;
}
const TableSettingsContext = createContext<TableSettingsContextInterface<
Record<string, unknown>
> | null>(null);
TableSettingsContext.displayName = 'TableSettingsContext';
export function useTableSettings<T>() {
const Context = getContextType<T>();
const context = useContext(Context);
if (context === null) {
throw new Error('must be nested under TableSettingsProvider');
}
return context;
}
interface ProviderProps<T> {
children: ReactNode;
settings: T;
}
export function TableSettingsProvider<T>({
children,
settings,
}: ProviderProps<T>) {
const Context = getContextType<T>();
const contextValue = useMemo(
() => ({
settings,
}),
[settings]
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
function getContextType<T>() {
return TableSettingsContext as unknown as Context<
TableSettingsContextInterface<T>
>;
}

View file

@ -1,4 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'react-feather';
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';
@ -10,6 +12,8 @@ import {
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 { useContainers } from '../../queries/containers'; import { useContainers } from '../../queries/containers';
@ -20,7 +24,7 @@ import { ContainersDatatableActions } from './ContainersDatatableActions';
import { RowProvider } from './RowContext'; import { RowProvider } from './RowContext';
const storageKey = 'containers'; const storageKey = 'containers';
const useStore = createStore(storageKey); const settingsStore = createStore(storageKey);
const actions = [ const actions = [
buildAction('logs', 'Logs'), buildAction('logs', 'Logs'),
@ -39,13 +43,15 @@ export function ContainersDatatable({
isHostColumnVisible, isHostColumnVisible,
environment, environment,
}: Props) { }: Props) {
const settings = useStore(); 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 hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id) 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,
@ -55,12 +61,16 @@ export function ContainersDatatable({
return ( return (
<RowProvider context={{ environment }}> <RowProvider context={{ environment }}>
<TableSettingsProvider settings={settingsStore}>
<Datatable <Datatable
titleOptions={{ titleIcon={Box}
icon: 'svg-cubes', title="Containers"
title: 'Containers', initialPageSize={settings.pageSize}
}} onPageSizeChange={settings.setPageSize}
settingsStore={settings} initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
columns={columns} columns={columns}
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
<ContainersDatatableActions <ContainersDatatableActions
@ -73,8 +83,8 @@ export function ContainersDatatable({
isRowSelectable={(row) => !row.original.IsPortainer} isRowSelectable={(row) => !row.original.IsPortainer}
initialTableState={{ hiddenColumns: settings.hiddenColumns }} initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => { renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter((colInstance) => const columnsToHide = tableInstance.allColumns.filter(
hidableColumns?.includes(colInstance.id) (colInstance) => hidableColumns?.includes(colInstance.id)
); );
return ( return (
@ -98,10 +108,10 @@ export function ContainersDatatable({
</> </>
); );
}} }}
storageKey={storageKey}
dataset={containersQuery.data || []} dataset={containersQuery.data || []}
emptyContentLabel="No containers found" emptyContentLabel="No containers found"
/> />
</TableSettingsProvider>
</RowProvider> </RowProvider>
); );
} }

View file

@ -4,7 +4,7 @@ import { useSref } from '@uirouter/react';
import type { DockerContainer } from '@/react/docker/containers/types'; import type { DockerContainer } from '@/react/docker/containers/types';
import { useTableSettings } from '@@/datatables/useZustandTableSettings'; import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types'; import { TableSettings } from '../types';
@ -31,7 +31,7 @@ export function NameCell({
nodeName: container.NodeName, nodeName: container.NodeName,
}); });
const { settings } = useTableSettings<TableSettings>(); const settings = useTableSettings<TableSettings>();
const truncate = settings.truncateContainerName; const truncate = settings.truncateContainerName;
let shortName = name; let shortName = name;

View file

@ -4,7 +4,7 @@ import { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { DockerContainer } from '@/react/docker/containers/types'; import { DockerContainer } from '@/react/docker/containers/types';
import { useTableSettings } from '@@/datatables/useZustandTableSettings'; import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types'; import { TableSettings } from '../types';
@ -22,7 +22,7 @@ export const quickActions: Column<DockerContainer> = {
function QuickActionsCell({ function QuickActionsCell({
row: { original: container }, row: { original: container },
}: CellProps<DockerContainer>) { }: CellProps<DockerContainer>) {
const { settings } = useTableSettings<TableSettings>(); const settings = useTableSettings<TableSettings>();
const { hiddenQuickActions = [] } = settings; const { hiddenQuickActions = [] } = settings;

View file

@ -1,24 +1,15 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import { import {
paginationSettings,
sortableSettings,
refreshableSettings, refreshableSettings,
hiddenColumnsSettings, hiddenColumnsSettings,
} from '@/react/components/datatables/types'; createPersistedStore,
} from '@@/datatables/types';
import { QuickAction, TableSettings } from './types'; 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 create<TableSettings>()( return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set), ...hiddenColumnsSettings(set),
...refreshableSettings(set), ...refreshableSettings(set),
truncateContainerName: TRUNCATE_LENGTH, truncateContainerName: TRUNCATE_LENGTH,
@ -31,10 +22,5 @@ export function createStore(storageKey: string) {
hiddenQuickActions: [] as QuickAction[], hiddenQuickActions: [] as QuickAction[],
setHiddenQuickActions: (hiddenQuickActions: QuickAction[]) => setHiddenQuickActions: (hiddenQuickActions: QuickAction[]) =>
set({ hiddenQuickActions }), set({ hiddenQuickActions }),
}), }));
{
name: keyBuilder(storageKey),
}
)
);
} }

View file

@ -1,9 +1,8 @@
import { import {
PaginationTableSettings, BasicTableSettings,
RefreshableTableSettings, RefreshableTableSettings,
SettableColumnsTableSettings, SettableColumnsTableSettings,
SortableTableSettings, } from '@@/datatables/types';
} from '@/react/components/datatables/types';
export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats'; export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
@ -13,8 +12,7 @@ export interface SettableQuickActionsTableSettings<TAction> {
} }
export interface TableSettings export interface TableSettings
extends SortableTableSettings, extends BasicTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings, SettableColumnsTableSettings,
SettableQuickActionsTableSettings<QuickAction>, SettableQuickActionsTableSettings<QuickAction>,
RefreshableTableSettings { RefreshableTableSettings {

View file

@ -38,8 +38,6 @@ export function NetworkContainersTable({
} }
return ( return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<TableContainer> <TableContainer>
<TableTitle label="Containers in network" icon="server" featherIcon /> <TableTitle label="Containers in network" icon="server" featherIcon />
<Table className="nopadding"> <Table className="nopadding">
@ -94,7 +92,5 @@ export function NetworkContainersTable({
</DetailsTable> </DetailsTable>
</Table> </Table>
</TableContainer> </TableContainer>
</div>
</div>
); );
} }

View file

@ -29,8 +29,6 @@ export function NetworkDetailsTable({
); );
return ( return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<TableContainer> <TableContainer>
<TableTitle label="Network details" icon="share-2" featherIcon /> <TableTitle label="Network details" icon="share-2" featherIcon />
<Table className="nopadding"> <Table className="nopadding">
@ -58,9 +56,7 @@ export function NetworkDetailsTable({
</Authorized> </Authorized>
)} )}
</DetailsTable.Row> </DetailsTable.Row>
<DetailsTable.Row label="Driver"> <DetailsTable.Row label="Driver">{network.Driver}</DetailsTable.Row>
{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)}
@ -107,8 +103,6 @@ export function NetworkDetailsTable({
</DetailsTable> </DetailsTable>
</Table> </Table>
</TableContainer> </TableContainer>
</div>
</div>
); );
function getConfigDetails(configValue?: string) { function getConfigDetails(configValue?: string) {

View file

@ -15,8 +15,6 @@ export function NetworkOptionsTable({ options }: Props) {
} }
return ( return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<TableContainer> <TableContainer>
<TableTitle label="Network options" icon="share-2" featherIcon /> <TableTitle label="Network options" icon="share-2" featherIcon />
<Table className="nopadding"> <Table className="nopadding">
@ -29,7 +27,5 @@ export function NetworkOptionsTable({ options }: Props) {
</DetailsTable> </DetailsTable>
</Table> </Table>
</TableContainer> </TableContainer>
</div>
</div>
); );
} }

View file

@ -1,4 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'react-feather';
import { DockerContainer } from '@/react/docker/containers/types'; import { DockerContainer } from '@/react/docker/containers/types';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
@ -14,12 +16,14 @@ import {
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 { useContainers } from '../../containers/queries/containers'; import { useContainers } from '../../containers/queries/containers';
import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext'; import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext';
const storageKey = 'stack-containers'; const storageKey = 'stack-containers';
const useStore = createStore(storageKey); const settingsStore = createStore(storageKey);
const actions = [ const actions = [
buildAction('logs', 'Logs'), buildAction('logs', 'Logs'),
@ -35,9 +39,12 @@ export interface Props {
} }
export function StackContainersDatatable({ environment, stackName }: Props) { export function StackContainersDatatable({ environment, stackName }: Props) {
const settings = useStore(); const settings = useStore(settingsStore);
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( const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id) columns.filter((col) => col.canHide).map((col) => col.id)
); );
@ -53,12 +60,16 @@ export function StackContainersDatatable({ environment, stackName }: Props) {
return ( return (
<RowProvider context={{ environment }}> <RowProvider context={{ environment }}>
<TableSettingsProvider settings={settingsStore}>
<Datatable <Datatable
titleOptions={{ title="Containers"
icon: 'fa-cubes', titleIcon={Box}
title: 'Containers', initialPageSize={settings.pageSize}
}} onPageSizeChange={settings.setPageSize}
settingsStore={settings} initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
columns={columns} columns={columns}
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
<ContainersDatatableActions <ContainersDatatableActions
@ -69,8 +80,8 @@ export function StackContainersDatatable({ environment, stackName }: Props) {
)} )}
initialTableState={{ hiddenColumns: settings.hiddenColumns }} initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => { renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter((colInstance) => const columnsToHide = tableInstance.allColumns.filter(
hidableColumns?.includes(colInstance.id) (colInstance) => hidableColumns?.includes(colInstance.id)
); );
return ( return (
@ -91,11 +102,11 @@ export function StackContainersDatatable({ environment, stackName }: Props) {
</> </>
); );
}} }}
storageKey={storageKey}
dataset={containersQuery.data || []} dataset={containersQuery.data || []}
isLoading={containersQuery.isLoading} isLoading={containersQuery.isLoading}
emptyContentLabel="No containers found" emptyContentLabel="No containers found"
/> />
</TableSettingsProvider>
</RowProvider> </RowProvider>
); );
} }

View file

@ -1,101 +1,30 @@
import { usePagination, useTable } from 'react-table';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import PortainerError from '@/portainer/error'; import PortainerError from '@/portainer/error';
import { InnerDatatable } from '@@/datatables/InnerDatatable'; import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables';
import { useAMTDevices } from './useAMTDevices'; import { useAMTDevices } from './useAMTDevices';
import { RowProvider } from './columns/RowContext'; import { columns } from './columns';
import { useColumns } from './columns';
export interface AMTDevicesTableProps { export interface AMTDevicesTableProps {
environmentId: EnvironmentId; environmentId: EnvironmentId;
} }
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) { export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
const columns = useColumns(); const devicesQuery = useAMTDevices(environmentId);
const { isLoading, devices, error } = useAMTDevices(environmentId);
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
useTable<Device>(
{
columns,
data: devices,
},
usePagination
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<InnerDatatable> <NestedDatatable
<TableContainer> columns={columns}
<Table dataset={devicesQuery.devices}
className={tableProps.className} isLoading={devicesQuery.isLoading}
role={tableProps.role} emptyContentLabel={userMessage(devicesQuery.error)}
style={tableProps.style} defaultSortBy="hostname"
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Device>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/> />
); );
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && devices && devices.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<RowProvider key={key} environmentId={environmentId}>
<TableRow<Device>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
</RowProvider>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
</TableContainer>
</InnerDatatable>
);
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
} }
function userMessage(error?: PortainerError) {
if (error) { if (error) {
return error.message; return error.message;
} }

View file

@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { hostname } from './hostname'; import { hostname } from './hostname';
import { status } from './status'; import { status } from './status';
import { powerState } from './power-state'; import { powerState } from './power-state';
import { actions } from './actions'; import { actions } from './actions';
export function useColumns() { export const columns = [hostname, status, powerState, actions];
return useMemo(() => [hostname, status, powerState, actions], []);
}

View file

@ -1,37 +1,26 @@
import { useTable, useExpanded, useSortBy, useFilters } from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import _ from 'lodash'; import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'react-feather';
import { useState } from 'react';
import { Environment } from '@/react/portainer/environments/types'; import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { PaginationControls } from '@@/PaginationControls'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { import { TableSettingsMenu } from '@@/datatables';
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableSettingsMenu,
TableTitle,
TableTitleActions,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu'; import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { SearchBar } from '@@/datatables/SearchBar'; import { InformationPanel } from '@@/InformationPanel';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { AMTDevicesDatatable } from './AMTDevicesDatatable'; import { AMTDevicesDatatable } from './AMTDevicesDatatable';
import { columns } from './columns';
import { EdgeDevicesDatatableActions } from './EdgeDevicesDatatableActions'; import { EdgeDevicesDatatableActions } from './EdgeDevicesDatatableActions';
import { EdgeDevicesDatatableSettings } from './EdgeDevicesDatatableSettings'; import { EdgeDevicesDatatableSettings } from './EdgeDevicesDatatableSettings';
import { RowProvider } from './columns/RowContext'; import { RowProvider } from './columns/RowContext';
import { useColumns } from './columns';
import styles from './EdgeDevicesDatatable.module.css'; import styles from './EdgeDevicesDatatable.module.css';
import { EdgeDeviceTableSettings, Pagination } from './types'; import { createStore } from './datatable-store';
export interface EdgeDevicesTableProps { export interface EdgeDevicesTableProps {
storageKey: string; storageKey: string;
@ -39,231 +28,123 @@ export interface EdgeDevicesTableProps {
isOpenAmtEnabled: boolean; isOpenAmtEnabled: boolean;
showWaitingRoomLink: boolean; showWaitingRoomLink: boolean;
mpsServer: string; mpsServer: string;
dataset: Environment[];
groups: EnvironmentGroup[]; groups: EnvironmentGroup[];
setLoadingMessage(message: string): void;
pagination: Pagination;
onChangePagination(pagination: Partial<Pagination>): void;
totalCount: number;
search: string;
onChangeSearch(search: string): void;
} }
const storageKey = 'edgeDevices';
const settingsStore = createStore(storageKey);
export function EdgeDevicesDatatable({ export function EdgeDevicesDatatable({
isFdoEnabled, isFdoEnabled,
isOpenAmtEnabled, isOpenAmtEnabled,
showWaitingRoomLink, showWaitingRoomLink,
mpsServer, mpsServer,
dataset,
onChangeSearch,
search,
groups, groups,
setLoadingMessage,
pagination,
onChangePagination,
totalCount,
}: EdgeDevicesTableProps) { }: EdgeDevicesTableProps) {
const { settings, setTableSettings } = const settings = useStore(settingsStore);
useTableSettings<EdgeDeviceTableSettings>(); const [page, setPage] = useState(0);
const columns = useColumns(); const [search, setSearch] = useSearchBarState(storageKey);
const { const hidableColumns = _.compact(
getTableProps, columns.filter((col) => col.canHide).map((col) => col.id)
getTableBodyProps,
headerGroups,
rows,
prepareRow,
selectedFlatRows,
allColumns,
setHiddenColumns,
} = useTable<Environment>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
hiddenColumns: settings.hiddenColumns,
sortBy: [settings.sortBy],
},
isRowSelectable() {
return true;
},
autoResetExpanded: false,
autoResetSelectedRows: false,
getRowId(originalRow: Environment) {
return originalRow.Id.toString();
},
selectColumnWidth: 5,
},
useFilters,
useSortBy,
useExpanded,
useRowSelect,
useRowSelectColumn
); );
const columnsToHide = allColumns.filter((colInstance) => { const { environments, isLoading, totalCount } = useEnvironmentList(
const columnDef = columns.find((c) => c.id === colInstance.id); {
return columnDef?.canHide; edgeDevice: true,
}); search,
types: EdgeTypes,
excludeSnapshots: true,
page: page + 1,
pageLimit: settings.pageSize,
sort: settings.sortBy.id,
order: settings.sortBy.desc ? 'desc' : 'asc',
},
settings.autoRefreshRate * 1000
);
const tableProps = getTableProps(); const someDeviceHasAMTActivated = environments.some(
const tbodyProps = getTableBodyProps();
const someDeviceHasAMTActivated = dataset.some(
(environment) => (environment) =>
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== '' environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
); );
const groupsById = _.groupBy(groups, 'Id');
return ( return (
<div className="row"> <>
<div className="col-sm-12">
<TableContainer>
<TableTitle icon="box" featherIcon label="Edge Devices">
<SearchBar value={search} onChange={handleSearchBarChange} />
<TableActions>
<EdgeDevicesDatatableActions
selectedItems={selectedFlatRows.map((row) => row.original)}
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
setLoadingMessage={setLoadingMessage}
showWaitingRoomLink={showWaitingRoomLink}
/>
</TableActions>
<TableTitleActions>
<ColumnVisibilityMenu<Environment>
columns={columnsToHide}
onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns}
/>
<TableSettingsMenu>
<EdgeDevicesDatatableSettings />
</TableSettingsMenu>
</TableTitleActions>
</TableTitle>
{isOpenAmtEnabled && someDeviceHasAMTActivated && ( {isOpenAmtEnabled && someDeviceHasAMTActivated && (
<InformationPanel>
<div className={styles.kvmTip}> <div className={styles.kvmTip}>
<TextTip color="blue"> <TextTip color="blue">
For the KVM function to work you need to have the MPS server For the KVM function to work you need to have the MPS server added
added to your trusted site list, browse to this{' '} to your trusted site list, browse to this
<a <a
href={`https://${mpsServer}`} href={`https://${mpsServer}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="space-right" className="mx-px"
> >
site site
</a> </a>
and add to your trusted site list and add to your trusted site list
</TextTip> </TextTip>
</div> </div>
</InformationPanel>
)} )}
<Table <RowProvider context={{ isOpenAmtEnabled, groups }}>
className={tableProps.className} <ExpandableDatatable
role={tableProps.role} dataset={environments}
style={tableProps.style} columns={columns}
> isLoading={isLoading}
<thead> totalCount={totalCount}
{headerGroups.map((headerGroup) => { title="Edge Devices"
const { key, className, role, style } = titleIcon={Box}
headerGroup.getHeaderGroupProps(); initialPageSize={settings.pageSize}
return ( onPageSizeChange={settings.setPageSize}
<TableHeaderRow<Environment> initialSortBy={settings.sortBy}
key={key} onSortByChange={settings.setSortBy}
className={className} searchValue={search}
role={role} onSearchChange={setSearch}
style={style} renderSubRow={(row) => (
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content
prepareRow={prepareRow}
rows={rows}
renderRow={(row, { key, className, role, style }) => {
const group = groupsById[row.original.GroupId];
return (
<RowProvider
key={key}
context={{ isOpenAmtEnabled, groupName: group[0]?.Name }}
>
<TableRow<Environment>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr> <tr>
<td /> <td />
<td colSpan={row.cells.length - 1}> <td colSpan={row.cells.length - 1}>
<AMTDevicesDatatable <AMTDevicesDatatable environmentId={row.original.Id} />
environmentId={row.original.Id}
/>
</td> </td>
</tr> </tr>
)} )}
</RowProvider> initialTableState={{ pageIndex: page }}
pageCount={Math.ceil(totalCount / settings.pageSize)}
renderTableActions={(selectedRows) => (
<EdgeDevicesDatatableActions
selectedItems={selectedRows}
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
showWaitingRoomLink={showWaitingRoomLink}
/>
)}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter(
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return (
<>
<ColumnVisibilityMenu<Environment>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
<TableSettingsMenu>
<EdgeDevicesDatatableSettings settings={settings} />
</TableSettingsMenu>
</>
); );
}} }}
onPageChange={setPage}
/> />
</tbody> </RowProvider>
</Table> </>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
isPageInputVisible
pageLimit={pagination.pageLimit}
page={pagination.page}
onPageChange={(p) => gotoPage(p)}
totalCount={totalCount}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
</div>
</div>
); );
function gotoPage(pageIndex: number) {
onChangePagination({ page: pageIndex });
}
function setPageSize(pageSize: number) {
onChangePagination({ pageLimit: pageSize });
}
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleChangeColumnsVisibility(hiddenColumns: string[]) {
setHiddenColumns(hiddenColumns);
setTableSettings((settings) => ({ ...settings, hiddenColumns }));
}
function handleSearchBarChange(value: string) {
onChangeSearch(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
} }

View file

@ -7,8 +7,8 @@ import {
} from '@/portainer/services/modal.service/confirm'; } from '@/portainer/services/modal.service/confirm';
import { promptAsync } from '@/portainer/services/modal.service/prompt'; import { promptAsync } from '@/portainer/services/modal.service/prompt';
import * as notifications from '@/portainer/services/notifications'; import * as notifications from '@/portainer/services/notifications';
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import { deleteEndpoint } from '@/react/portainer/environments/environment.service'; import { deleteEndpoint } from '@/react/portainer/environments/environment.service';
import { useActivateDeviceMutation } from '@/portainer/hostmanagement/open-amt/queries';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
@ -17,7 +17,6 @@ interface Props {
selectedItems: Environment[]; selectedItems: Environment[];
isFDOEnabled: boolean; isFDOEnabled: boolean;
isOpenAMTEnabled: boolean; isOpenAMTEnabled: boolean;
setLoadingMessage(message: string): void;
showWaitingRoomLink: boolean; showWaitingRoomLink: boolean;
} }
@ -30,10 +29,10 @@ export function EdgeDevicesDatatableActions({
selectedItems, selectedItems,
isOpenAMTEnabled, isOpenAMTEnabled,
isFDOEnabled, isFDOEnabled,
setLoadingMessage,
showWaitingRoomLink, showWaitingRoomLink,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const activateDeviceMutation = useActivateDeviceMutation();
return ( return (
<div className="actionBar"> <div className="actionBar">
@ -169,23 +168,13 @@ export function EdgeDevicesDatatableActions({
return; return;
} }
try { activateDeviceMutation.mutate(selectedEnvironment.Id, {
setLoadingMessage( onSuccess() {
'Activating Active Management Technology on selected device...' notifications.notifySuccess(
);
await activateDevice(selectedEnvironment.Id);
notifications.success(
'Successfully associated with OpenAMT', 'Successfully associated with OpenAMT',
selectedEnvironment.Name selectedEnvironment.Name
); );
} catch (err) { },
notifications.error( });
'Failure',
err as Error,
'Unable to associate with OpenAMT'
);
} finally {
setLoadingMessage('');
}
} }
} }

View file

@ -1,121 +0,0 @@
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { useSearchBarState } from '@@/datatables/SearchBar';
import {
TableSettingsProvider,
useTableSettings,
} from '@@/datatables/useTableSettings';
import {
EdgeDevicesDatatable,
EdgeDevicesTableProps,
} from './EdgeDevicesDatatable';
import { EdgeDeviceTableSettings, Pagination } from './types';
export function EdgeDevicesDatatableContainer({
...props
}: Omit<
EdgeDevicesTableProps,
| 'dataset'
| 'pagination'
| 'onChangePagination'
| 'totalCount'
| 'search'
| 'onChangeSearch'
>) {
const defaultSettings = {
autoRefreshRate: 0,
hiddenQuickActions: [],
hiddenColumns: [],
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
const storageKey = 'edgeDevices';
return (
<TableSettingsProvider defaults={defaultSettings} storageKey={storageKey}>
<Loader storageKey={storageKey}>
{({
environments,
pagination,
totalCount,
setPagination,
search,
setSearch,
}) => (
<EdgeDevicesDatatable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
storageKey={storageKey}
dataset={environments}
pagination={pagination}
onChangePagination={setPagination}
totalCount={totalCount}
search={search}
onChangeSearch={setSearch}
/>
)}
</Loader>
</TableSettingsProvider>
);
}
interface LoaderProps {
storageKey: string;
children: (options: {
environments: Environment[];
totalCount: number;
pagination: Pagination;
setPagination(value: Partial<Pagination>): void;
search: string;
setSearch: (value: string) => void;
}) => React.ReactNode;
}
function Loader({ children, storageKey }: LoaderProps) {
const { settings } = useTableSettings<EdgeDeviceTableSettings>();
const [pagination, setPagination] = useState({
pageLimit: settings.pageSize,
page: 1,
});
const [search, setSearch] = useSearchBarState(storageKey);
const debouncedSearchValue = useDebouncedValue(search);
const { environments, isLoading, totalCount } = useEnvironmentList(
{
edgeDevice: true,
search: debouncedSearchValue,
types: EdgeTypes,
excludeSnapshots: true,
...pagination,
},
settings.autoRefreshRate * 1000
);
if (isLoading) {
return null;
}
return (
<>
{children({
environments,
totalCount,
pagination,
setPagination: handleSetPagination,
search,
setSearch,
})}
</>
);
function handleSetPagination(value: Partial<Pagination>) {
setPagination((prev) => ({ ...prev, ...value }));
}
}

View file

@ -1,12 +1,11 @@
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@@/datatables/useTableSettings'; import { RefreshableTableSettings } from '@@/datatables/types';
import { EdgeDeviceTableSettings } from './types'; interface Props {
settings: RefreshableTableSettings;
export function EdgeDevicesDatatableSettings() { }
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
export function EdgeDevicesDatatableSettings({ settings }: Props) {
return ( return (
<TableSettingsMenuAutoRefresh <TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate} value={settings.autoRefreshRate}
@ -15,6 +14,6 @@ export function EdgeDevicesDatatableSettings() {
); );
function handleRefreshRateChange(autoRefreshRate: number) { function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate }); settings.setAutoRefreshRate(autoRefreshRate);
} }
} }

View file

@ -1,8 +1,10 @@
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { createRowContext } from '@@/datatables/RowContext'; import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState { interface RowContextState {
isOpenAmtEnabled: boolean; isOpenAmtEnabled: boolean;
groupName?: string; groups: EnvironmentGroup[];
} }
const { RowProvider, useRowContext } = createRowContext<RowContextState>(); const { RowProvider, useRowContext } = createRowContext<RowContextState>();

View file

@ -1,6 +1,7 @@
import { Column } from 'react-table'; import { Column } from 'react-table';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { DefaultFilter } from '@@/datatables/Filter'; import { DefaultFilter } from '@@/datatables/Filter';
@ -15,8 +16,9 @@ export const group: Column<Environment> = {
canHide: true, canHide: true,
}; };
function GroupCell() { function GroupCell({ value }: { value: EnvironmentGroupId }) {
const { groupName } = useRowContext(); const { groups } = useRowContext();
const group = groups.find((g) => g.Id === value);
return groupName; return group?.Name || '';
} }

View file

@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { name } from './name'; import { name } from './name';
import { heartbeat } from './heartbeat'; import { heartbeat } from './heartbeat';
import { group } from './group'; import { group } from './group';
import { actions } from './actions'; import { actions } from './actions';
export function useColumns() { export const columns = [name, heartbeat, group, actions];
return useMemo(() => [name, heartbeat, group, actions], []);
}

View file

@ -0,0 +1,14 @@
import {
refreshableSettings,
hiddenColumnsSettings,
createPersistedStore,
} from '@@/datatables/types';
import { TableSettings } from './types';
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}));
}

View file

@ -1,17 +1,10 @@
import { import {
PaginationTableSettings, BasicTableSettings,
RefreshableTableSettings, RefreshableTableSettings,
SettableColumnsTableSettings, SettableColumnsTableSettings,
SortableTableSettings, } from '@@/datatables/types';
} from '@@/datatables/types-old';
export interface Pagination { export interface TableSettings
pageLimit: number; extends BasicTableSettings,
page: number;
}
export interface EdgeDeviceTableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings, SettableColumnsTableSettings,
RefreshableTableSettings {} RefreshableTableSettings {}

View file

@ -1,16 +1,16 @@
import { useState } from 'react'; import { useIsMutating } from 'react-query';
import { useSettings } from '@/react/portainer/settings/queries'; import { useSettings } from '@/react/portainer/settings/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { activateDeviceMutationKey } from '@/portainer/hostmanagement/open-amt/queries';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { ViewLoading } from '@@/ViewLoading'; import { ViewLoading } from '@@/ViewLoading';
import { EdgeDevicesDatatableContainer } from './EdgeDevicesDatatable/EdgeDevicesDatatableContainer'; import { EdgeDevicesDatatable } from './EdgeDevicesDatatable/EdgeDevicesDatatable';
export function ListView() { export function ListView() {
const [loadingMessage, setLoadingMessage] = useState(''); const isActivatingDevice = useIsActivatingDevice();
const settingsQuery = useSettings(); const settingsQuery = useSettings();
const groupsQuery = useGroups(); const groupsQuery = useGroups();
@ -28,11 +28,10 @@ export function ListView() {
breadcrumbs={[{ label: 'EdgeDevices' }]} breadcrumbs={[{ label: 'EdgeDevices' }]}
/> />
{loadingMessage ? ( {isActivatingDevice ? (
<ViewLoading message={loadingMessage} /> <ViewLoading message="Activating Active Management Technology on selected device..." />
) : ( ) : (
<EdgeDevicesDatatableContainer <EdgeDevicesDatatable
setLoadingMessage={setLoadingMessage}
isFdoEnabled={ isFdoEnabled={
settings.EnableEdgeComputeFeatures && settings.EnableEdgeComputeFeatures &&
settings.fdoConfiguration.enabled settings.fdoConfiguration.enabled
@ -54,3 +53,8 @@ export function ListView() {
</> </>
); );
} }
function useIsActivatingDevice() {
const count = useIsMutating({ mutationKey: activateDeviceMutationKey });
return count > 0;
}

View file

@ -1,207 +1,70 @@
import { import { useStore } from 'zustand';
Column,
useGlobalFilter,
usePagination,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
import { Datatable as GenericDatatable } from '@@/datatables';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Table } from '@@/datatables'; import { TextTip } from '@@/Tip/TextTip';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; import { createPersistedStore } from '@@/datatables/types';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount'; import { useSearchBarState } from '@@/datatables/SearchBar';
import { PaginationControls } from '@@/PaginationControls';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { useAssociateDeviceMutation } from '../queries'; import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
import { TableSettings } from './types'; import { columns } from './columns';
const columns: readonly Column<Environment>[] = [ const storageKey = 'edge-devices-waiting-room';
{
Header: 'Name', const settingsStore = createPersistedStore(storageKey, 'Name');
accessor: (row) => row.Name,
id: 'name',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
{
Header: 'Edge ID',
accessor: (row) => row.EdgeID,
id: 'edge-id',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
] as const;
interface Props { interface Props {
devices: Environment[]; devices: Environment[];
isLoading: boolean; isLoading: boolean;
totalCount: number; totalCount: number;
storageKey: string;
} }
export function DataTable({ export function Datatable({ devices, isLoading, totalCount }: Props) {
devices,
storageKey,
isLoading,
totalCount,
}: Props) {
const associateMutation = useAssociateDeviceMutation(); const associateMutation = useAssociateDeviceMutation();
const licenseOverused = useLicenseOverused();
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); const settings = useStore(settingsStore);
const { settings, setTableSettings } = useTableSettings<TableSettings>(); const [search, setSearch] = useSearchBarState(storageKey);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Environment>(
{
defaultCanFilter: false,
columns,
data: devices,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable() {
return true;
},
autoResetSelectedRows: false,
getRowId(originalRow: Environment) {
return originalRow.Id.toString();
},
selectColumnWidth: 5,
},
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<div className="row"> <GenericDatatable
<div className="col-sm-12"> columns={columns}
<Table.Container> dataset={devices}
<Table.Title label="Edge Devices Waiting Room" icon=""> initialPageSize={settings.pageSize}
<SearchBar onPageSizeChange={settings.setPageSize}
onChange={handleSearchBarChange} initialSortBy={settings.sortBy}
value={searchBarValue} onSortByChange={settings.setSortBy}
/> searchValue={search}
<Table.Actions> onSearchChange={setSearch}
title="Edge Devices Waiting Room"
emptyContentLabel="No Edge Devices found"
renderTableActions={(selectedRows) => (
<>
<Button <Button
onClick={() => onClick={() => handleAssociateDevice(selectedRows)}
handleAssociateDevice(selectedFlatRows.map((r) => r.original)) disabled={selectedRows.length === 0}
}
disabled={selectedFlatRows.length === 0}
> >
Associate Device Associate Device
</Button> </Button>
</Table.Actions>
</Table.Title>
<Table {licenseOverused ? (
className={tableProps.className} <div className="ml-2 mt-2">
role={tableProps.role} <TextTip color="orange">
style={tableProps.style} Associating devices is disabled as your node count exceeds your
> license limit
<thead> </TextTip>
{headerGroups.map((headerGroup) => { </div>
const { key, className, role, style } = ) : null}
headerGroup.getHeaderGroupProps(); </>
return (
<Table.HeaderRow<Environment>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content
emptyContent="No Edge Devices found"
prepareRow={prepareRow}
rows={page}
isLoading={isLoading}
renderRow={(row, { key, className, role, style }) => (
<Table.Row
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)} )}
/> isLoading={isLoading}
</tbody>
</Table>
<Table.Footer>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={totalCount} totalCount={totalCount}
onPageLimitChange={handlePageLimitChange}
/> />
</Table.Footer>
</Table.Container>
</div>
</div>
); );
function handleSortChange(colId: string, desc: boolean) {
setTableSettings({ sortBy: { id: colId, desc } });
}
function handlePageLimitChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings({ pageSize });
}
function handleSearchBarChange(value: string) {
setGlobalFilter(value);
setSearchBarValue(value);
}
function handleAssociateDevice(devices: Environment[]) { function handleAssociateDevice(devices: Environment[]) {
associateMutation.mutate( associateMutation.mutate(
devices.map((d) => d.Id), devices.map((d) => d.Id),

View file

@ -0,0 +1,24 @@
import { Column } from 'react-table';
import { Environment } from '@/react/portainer/environments/types';
export const columns: readonly Column<Environment>[] = [
{
Header: 'Name',
accessor: (row) => row.Name,
id: 'name',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
{
Header: 'Edge ID',
accessor: (row) => row.EdgeID,
id: 'edge-id',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
] as const;

View file

@ -0,0 +1 @@
export { Datatable } from './Datatable';

View file

@ -1,8 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}

View file

@ -5,14 +5,11 @@ import { EdgeTypes } from '@/react/portainer/environments/types';
import { InformationPanel } from '@@/InformationPanel'; import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { DataTable } from './Datatable/Datatable'; import { Datatable } from './Datatable';
import { TableSettings } from './Datatable/types';
export function WaitingRoomView() { export function WaitingRoomView() {
const storageKey = 'edge-devices-waiting-room';
const router = useRouter(); const router = useRouter();
const { environments, isLoading, totalCount } = useEnvironmentList({ const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDevice: true, edgeDevice: true,
@ -44,17 +41,11 @@ export function WaitingRoomView() {
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
<TableSettingsProvider<TableSettings> <Datatable
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
storageKey={storageKey}
>
<DataTable
devices={environments} devices={environments}
totalCount={totalCount} totalCount={totalCount}
isLoading={isLoading} isLoading={isLoading}
storageKey={storageKey}
/> />
</TableSettingsProvider>
</> </>
); );
} }

View file

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { promiseSequence } from '@/portainer/helpers/promise-utils'; import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { useIntegratedLicenseInfo } from '@/portainer/license-management/use-license.service';
export function useAssociateDeviceMutation() { export function useAssociateDeviceMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -31,3 +32,11 @@ async function associateDevice(environmentId: EnvironmentId) {
throw parseAxiosError(e as Error, 'Failed to associate device'); throw parseAxiosError(e as Error, 'Failed to associate device');
} }
} }
export function useLicenseOverused() {
const integratedInfo = useIntegratedLicenseInfo();
if (integratedInfo && integratedInfo.licenseInfo.enforcedAt > 0) {
return true;
}
return false;
}

View file

@ -1,17 +1,21 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Database } from 'react-feather';
import { useStore } from 'zustand';
import { confirmWarn } from '@/portainer/services/modal.service/confirm'; import { confirmWarn } from '@/portainer/services/modal.service/confirm';
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 { IngressControllerClassMap } from '../types'; import { IngressControllerClassMap } from '../types';
import { useColumns } from './columns'; import { useColumns } from './columns';
import { createStore } from './datatable-store';
const useStore = createStore('ingressClasses'); const storageKey = 'ingressClasses';
const settingsStore = createPersistedStore(storageKey);
interface Props { interface Props {
onChangeControllers: ( onChangeControllers: (
@ -34,10 +38,11 @@ export function IngressClassDatatable({
noIngressControllerLabel, noIngressControllerLabel,
view, view,
}: Props) { }: Props) {
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const [ingControllerFormValues, setIngControllerFormValues] = useState( const [ingControllerFormValues, setIngControllerFormValues] = useState(
ingressControllers || [] ingressControllers || []
); );
const settings = useStore();
const columns = useColumns(); const columns = useColumns();
useEffect(() => { useEffect(() => {
@ -76,19 +81,20 @@ export function IngressClassDatatable({
<div className="-mx-[15px]"> <div className="-mx-[15px]">
<Datatable <Datatable
dataset={ingControllerFormValues || []} dataset={ingControllerFormValues || []}
storageKey="ingressClasses"
columns={columns} columns={columns}
settingsStore={settings}
isLoading={isLoading} isLoading={isLoading}
emptyContentLabel={noIngressControllerLabel} emptyContentLabel={noIngressControllerLabel}
titleOptions={{ title="Ingress Controllers"
icon: 'database', titleIcon={Database}
title: 'Ingress controllers',
featherIcon: true,
}}
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,26 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from './types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -1,5 +1,6 @@
import { Plus, Trash2 } from 'react-feather'; import { Plus, Trash2 } from 'react-feather';
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';
@ -9,11 +10,12 @@ import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm
import { Datatable } from '@@/datatables'; 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 { useSearchBarState } from '@@/datatables/SearchBar';
import { DeleteIngressesRequest, Ingress } from '../types'; import { DeleteIngressesRequest, Ingress } from '../types';
import { useDeleteIngresses, useIngresses } from '../queries'; import { useDeleteIngresses, useIngresses } from '../queries';
import { createStore } from './datatable-store';
import { useColumns } from './columns'; import { useColumns } from './columns';
import '../style.css'; import '../style.css';
@ -22,10 +24,11 @@ interface SelectedIngress {
Namespace: string; Namespace: string;
Name: string; Name: string;
} }
const storageKey = 'ingressClassesNameSpace';
const useStore = createStore('ingresses'); const settingsStore = createPersistedStore(storageKey);
export function IngressDataTable() { export function IngressDatatable() {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const nsResult = useNamespaces(environmentId); const nsResult = useNamespaces(environmentId);
@ -34,28 +37,30 @@ export function IngressDataTable() {
Object.keys(nsResult?.data || {}) Object.keys(nsResult?.data || {})
); );
const settings = useStore();
const columns = useColumns(); const columns = useColumns();
const deleteIngressesMutation = useDeleteIngresses(); const deleteIngressesMutation = useDeleteIngresses();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const router = useRouter(); const router = useRouter();
return ( return (
<Datatable <Datatable
dataset={ingressesQuery.data || []} dataset={ingressesQuery.data || []}
storageKey="ingressClassesNameSpace"
columns={columns} columns={columns}
settingsStore={settings}
isLoading={ingressesQuery.isLoading} isLoading={ingressesQuery.isLoading}
emptyContentLabel="No supported ingresses found" emptyContentLabel="No supported ingresses found"
titleOptions={{ title="Ingresses"
icon: 'svg-route', titleIcon="svg-route"
title: 'Ingresses',
}}
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}
/> />
); );
@ -67,14 +72,7 @@ export function IngressDataTable() {
className="btn-wrapper" className="btn-wrapper"
color="dangerlight" color="dangerlight"
disabled={selectedFlatRows.length === 0} disabled={selectedFlatRows.length === 0}
onClick={() => onClick={() => handleRemoveClick(selectedFlatRows)}
handleRemoveClick(
selectedFlatRows.map((row) => ({
Name: row.Name,
Namespace: row.Namespace,
}))
)
}
icon={Trash2} icon={Trash2}
> >
Remove Remove

View file

@ -1,26 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from '../types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -1,6 +1,6 @@
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { IngressDataTable } from './IngressDataTable'; import { IngressDatatable } from './IngressDatatable';
export function IngressesDatatableView() { export function IngressesDatatableView() {
return ( return (
@ -14,7 +14,7 @@ export function IngressesDatatableView() {
]} ]}
reload reload
/> />
<IngressDataTable /> <IngressDatatable />
</> </>
); );
} }

View file

@ -1,28 +1,10 @@
import { Fragment, useEffect } from 'react'; import { useStore } from 'zustand';
import {
useFilters,
useGlobalFilter,
usePagination,
useSortBy,
useTable,
} from 'react-table';
import { NomadEvent } from '@/react/nomad/types'; import { NomadEvent } from '@/react/nomad/types';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PaginationControls } from '@@/PaginationControls'; import { Datatable } from '@@/datatables';
import { import { useSearchBarState } from '@@/datatables/SearchBar';
Table, import { createPersistedStore } from '@@/datatables/types';
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { TableFooter } from '@@/datatables/TableFooter';
import { TableContent } from '@@/datatables/TableContent';
import { useColumns } from './columns'; import { useColumns } from './columns';
@ -31,133 +13,31 @@ export interface EventsDatatableProps {
isLoading: boolean; isLoading: boolean;
} }
export interface EventsTableSettings { const storageKey = 'events';
autoRefreshRate: number;
pageSize: number; const settingsStore = createPersistedStore(storageKey, 'Date');
sortBy: { id: string; desc: boolean };
}
export function EventsDatatable({ data, isLoading }: EventsDatatableProps) { export function EventsDatatable({ data, isLoading }: EventsDatatableProps) {
const { settings, setTableSettings } =
useTableSettings<EventsTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState('events');
const columns = useColumns(); const columns = useColumns();
const debouncedSearchValue = useDebouncedValue(searchBarValue); const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<NomadEvent>(
{
defaultCanFilter: false,
columns,
data,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
},
useFilters,
useGlobalFilter,
useSortBy,
usePagination
);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<TableContainer> <Datatable
<TableTitle icon="fa-history" label="Events" />
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<NomadEvent>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<TableContent
rows={page}
prepareRow={prepareRow}
isLoading={isLoading} isLoading={isLoading}
emptyContent="No events found" columns={columns}
renderRow={(row, { key, className, role, style }) => ( dataset={data}
<Fragment key={key}> initialPageSize={settings.pageSize}
<TableRow<NomadEvent> onPageSizeChange={settings.setPageSize}
cells={row.cells} initialSortBy={settings.sortBy}
key={key} onSortByChange={settings.setSortBy}
className={className} searchValue={search}
role={role} onSearchChange={setSearch}
style={style} titleIcon="fa-history"
/> title="Events"
</Fragment>
)}
/>
</tbody>
</Table>
<TableFooter>
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={data.length} totalCount={data.length}
onPageLimitChange={handlePageSizeChange} getRowId={(row) => `${row.Date}-${row.Message}-${row.Type}`}
disableSelect
/> />
</TableFooter>
</TableContainer>
); );
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
} }

View file

@ -1,9 +1,7 @@
import { useCurrentStateAndParams } from '@uirouter/react'; import { useCurrentStateAndParams } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { NomadEventsList } from '@/react/nomad/types';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { EventsDatatable } from './EventsDatatable'; import { EventsDatatable } from './EventsDatatable';
@ -27,14 +25,8 @@ export function EventsView() {
{ label: 'Events' }, { label: 'Events' },
]; ];
const defaultSettings = {
pageSize: 10,
sortBy: {},
};
return ( return (
<> <>
{/* header */}
<PageHeader <PageHeader
title="Event list" title="Event list"
breadcrumbs={breadcrumbs} breadcrumbs={breadcrumbs}
@ -43,20 +35,7 @@ export function EventsView() {
onReload={invalidateQuery} onReload={invalidateQuery}
/> />
<div className="row"> <EventsDatatable data={query.data || []} isLoading={query.isLoading} />
<div className="col-sm-12">
<TableSettingsProvider
defaults={defaultSettings}
storageKey="nomad-events"
>
{/* events table */}
<EventsDatatable
data={(query.data || []) as NomadEventsList}
isLoading={query.isLoading}
/>
</TableSettingsProvider>
</div>
</div>
</> </>
); );
} }

View file

@ -1,40 +1,16 @@
import { Fragment, useEffect } from 'react'; import { useStore } from 'zustand';
import { import { Clock } from 'react-feather';
useExpanded,
useFilters,
useGlobalFilter,
usePagination,
useSortBy,
useTable,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Job } from '@/react/nomad/types'; import { Job } from '@/react/nomad/types';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PaginationControls } from '@@/PaginationControls';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
TableSettingsMenu,
TableTitleActions,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { TableContent } from '@@/datatables/TableContent';
import { useRepeater } from '@@/datatables/useRepeater'; import { useRepeater } from '@@/datatables/useRepeater';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { TableSettingsMenu } from '@@/datatables';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { JobsTableSettings } from './types';
import { TasksDatatable } from './TasksDatatable'; import { TasksDatatable } from './TasksDatatable';
import { useColumns } from './columns'; import { columns } from './columns';
import { createStore } from './datatable-store';
import { JobsDatatableSettings } from './JobsDatatableSettings'; import { JobsDatatableSettings } from './JobsDatatableSettings';
export interface JobsDatatableProps { export interface JobsDatatableProps {
@ -43,162 +19,39 @@ export interface JobsDatatableProps {
isLoading?: boolean; isLoading?: boolean;
} }
const storageKey = 'jobs';
const settingsStore = createStore(storageKey);
export function JobsDatatable({ export function JobsDatatable({
jobs, jobs,
refreshData, refreshData,
isLoading, isLoading,
}: JobsDatatableProps) { }: JobsDatatableProps) {
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>(); const [search, setSearch] = useSearchBarState(storageKey);
const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs'); const settings = useStore(settingsStore);
const columns = useColumns();
const debouncedSearchValue = useDebouncedValue(searchBarValue);
useRepeater(settings.autoRefreshRate, refreshData); useRepeater(settings.autoRefreshRate, refreshData);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Job>(
{
defaultCanFilter: false,
columns,
data: jobs,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable() {
return false;
},
autoResetExpanded: false,
autoResetSelectedRows: false,
selectColumnWidth: 5,
getRowId(job, relativeIndex) {
return `${job.ID}-${relativeIndex}`;
},
},
useFilters,
useGlobalFilter,
useSortBy,
useExpanded,
usePagination,
useRowSelect,
useRowSelectColumn
);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<TableContainer> <ExpandableDatatable<Job>
<TableTitle icon="fa-cubes" label="Nomad Jobs"> dataset={jobs}
<TableTitleActions> columns={columns}
<TableSettingsMenu> initialPageSize={settings.pageSize}
<JobsDatatableSettings /> onPageSizeChange={settings.setPageSize}
</TableSettingsMenu> initialSortBy={settings.sortBy}
</TableTitleActions> onSortByChange={settings.setSortBy}
</TableTitle> searchValue={search}
onSearchChange={setSearch}
<TableActions /> title="Nomad Jobs"
titleIcon={Clock}
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} /> disableSelect
emptyContentLabel="No jobs found"
<Table renderSubRow={(row) => <TasksDatatable data={row.original.Tasks} />}
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Job>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<TableContent
rows={page}
prepareRow={prepareRow}
isLoading={isLoading} isLoading={isLoading}
emptyContent="No jobs found" renderTableSettings={() => (
renderRow={(row, { key, className, role, style }) => ( <TableSettingsMenu>
<Fragment key={key}> <JobsDatatableSettings settings={settings} />
<TableRow<Job> </TableSettingsMenu>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr>
<td />
<td colSpan={row.cells.length - 1}>
<TasksDatatable data={row.original.Tasks} />
</td>
</tr>
)}
</Fragment>
)} )}
/> />
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={jobs.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
); );
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
} }

View file

@ -1,11 +1,12 @@
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { JobsTableSettings } from './types'; import { TableSettings } from './types';
export function JobsDatatableSettings() { interface Props {
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>(); settings: TableSettings;
}
export function JobsDatatableSettings({ settings }: Props) {
return ( return (
<TableSettingsMenuAutoRefresh <TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate} value={settings.autoRefreshRate}
@ -14,6 +15,6 @@ export function JobsDatatableSettings() {
); );
function handleRefreshRateChange(autoRefreshRate: number) { function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate }); settings.setAutoRefreshRate(autoRefreshRate);
} }
} }

View file

@ -1,97 +1,21 @@
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { useState } from 'react';
import { Task } from '@/react/nomad/types'; import { Task } from '@/react/nomad/types';
import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables'; import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { InnerDatatable } from '@@/datatables/InnerDatatable';
import { useColumns } from './columns'; import { useColumns } from './columns';
export interface TasksTableProps { export interface Props {
data: Task[]; data: Task[];
} }
export function TasksDatatable({ data }: TasksTableProps) { export function TasksDatatable({ data }: Props) {
const columns = useColumns(); const columns = useColumns();
const [sortBy, setSortBy] = useState({ id: 'taskName', desc: false });
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
useTable<Task>(
{
columns,
data,
initialState: {
sortBy: [sortBy],
},
},
useFilters,
useSortBy,
usePagination
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<InnerDatatable> <NestedDatatable
<TableContainer> columns={columns}
<Table dataset={data}
className={tableProps.className} defaultSortBy="taskName"
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Task>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/> />
); );
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{data.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<Task>
key={key}
cells={row.cells}
className={className}
role={role}
style={style}
/>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
no tasks
</td>
</tr>
)}
</tbody>
</Table>
</TableContainer>
</InnerDatatable>
);
function handleSortChange(id: string, desc: boolean) {
setSortBy({ id, desc });
}
} }

View file

@ -3,6 +3,7 @@ import { CellProps, Column } from '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';
export const actions: Column<Task> = { export const actions: Column<Task> = {
Header: 'Task Actions', Header: 'Task Actions',
@ -25,7 +26,7 @@ export function ActionsCell({ row }: CellProps<Task>) {
}; };
return ( return (
<div className="text-center"> <div className="text-center vertical-center">
{/* events */} {/* events */}
<Link <Link
to="nomad.events" to="nomad.events"
@ -33,12 +34,12 @@ export function ActionsCell({ row }: CellProps<Task>) {
title="Events" title="Events"
className="space-right" className="space-right"
> >
<i className="fa fa-history space-right" aria-hidden="true" /> <Icon icon="clock" feather className="space-right icon" />
</Link> </Link>
{/* logs */} {/* logs */}
<Link to="nomad.logs" params={params} title="Logs"> <Link to="nomad.logs" params={params} title="Logs">
<i className="fa fa-file-alt space-right" aria-hidden="true" /> <Icon icon="file-text" feather className="space-right icon" />
</Link> </Link>
</div> </div>
); );

View file

@ -1,8 +1,7 @@
import * as notifications from '@/portainer/services/notifications'; import * as notifications from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { Job } from '@/react/nomad/types'; import { Job } from '@/react/nomad/types';
import { deleteJob } from '@/react/nomad/jobs/jobs.service';
import { deleteJob } from '../../../jobs.service';
export async function deleteJobs(environmentID: EnvironmentId, jobs: Job[]) { export async function deleteJobs(environmentID: EnvironmentId, jobs: Job[]) {
return Promise.all( return Promise.all(

View file

@ -1,4 +1,5 @@
import { CellProps, Column } from 'react-table'; import { CellProps, Column } from 'react-table';
import { Clock } from 'react-feather';
import { Job } from '@/react/nomad/types'; import { Job } from '@/react/nomad/types';
@ -18,7 +19,7 @@ export function ActionsCell({ row }: CellProps<Job>) {
return ( return (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<div className="text-center" {...row.getToggleRowExpandedProps()}> <div className="text-center" {...row.getToggleRowExpandedProps()}>
<i className="fa fa-history space-right" aria-hidden="true" /> <Clock className="feather" />
</div> </div>
); );
} }

View file

@ -1,11 +1,7 @@
import { useMemo } from 'react';
import { name } from './name'; import { name } from './name';
import { status } from './status'; import { status } from './status';
import { created } from './created'; import { created } from './created';
import { actions } from './actions'; import { actions } from './actions';
import { namespace } from './namespace'; import { namespace } from './namespace';
export function useColumns() { export const columns = [name, status, namespace, actions, created];
return useMemo(() => [name, status, namespace, actions, created], []);
}

View file

@ -0,0 +1,13 @@
import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
import { TableSettings } from './types';
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(
storageKey,
'SubmitTime',
(set) => ({
...refreshableSettings(set),
})
);
}

View file

@ -1,5 +1,8 @@
export interface JobsTableSettings { import {
autoRefreshRate: number; BasicTableSettings,
pageSize: number; RefreshableTableSettings,
sortBy: { id: string; desc: boolean }; } from '@@/datatables/types';
}
export interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings {}

View file

@ -1,7 +1,6 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useJobs } from './useJobs'; import { useJobs } from './useJobs';
import { JobsDatatable } from './JobsDatatable'; import { JobsDatatable } from './JobsDatatable';
@ -10,12 +9,6 @@ export function JobsView() {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const jobsQuery = useJobs(environmentId); const jobsQuery = useJobs(environmentId);
const defaultSettings = {
autoRefreshRate: 10,
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
async function reloadData() { async function reloadData() {
await jobsQuery.refetch(); await jobsQuery.refetch();
} }
@ -30,17 +23,11 @@ export function JobsView() {
onReload={reloadData} onReload={reloadData}
/> />
<div className="row">
<div className="col-sm-12">
<TableSettingsProvider defaults={defaultSettings} storageKey="jobs">
<JobsDatatable <JobsDatatable
jobs={jobsQuery.data || []} jobs={jobsQuery.data || []}
refreshData={reloadData} refreshData={reloadData}
isLoading={jobsQuery.isLoading} isLoading={jobsQuery.isLoading}
/> />
</TableSettingsProvider>
</div>
</div>
</> </>
); );
} }

View file

@ -175,8 +175,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
return ( return (
<> <>
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />} {totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
<div className="row">
<div className="col-sm-12">
<TableContainer> <TableContainer>
<TableTitle icon="hard-drive" featherIcon label="Environments" /> <TableTitle icon="hard-drive" featherIcon label="Environments" />
@ -326,8 +325,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
/> />
</TableFooter> </TableFooter>
</TableContainer> </TableContainer>
</div>
</div>
</> </>
); );

View file

@ -46,8 +46,6 @@ export function AccessControlPanel({
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams); (!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
return ( return (
<div className="row">
<div className="col-sm-12">
<TableContainer> <TableContainer>
<TableTitle label="Access control" icon="eye" featherIcon /> <TableTitle label="Access control" icon="eye" featherIcon />
<AccessControlPanelDetails <AccessControlPanelDetails
@ -77,8 +75,6 @@ export function AccessControlPanel({
/> />
)} )}
</TableContainer> </TableContainer>
</div>
</div>
); );
async function handleUpdateSuccess() { async function handleUpdateSuccess() {

View file

@ -1,4 +1,5 @@
import { Clock, Trash2 } from 'react-feather'; import { Clock, Trash2 } from 'react-feather';
import { useStore } from 'zustand';
import { import {
FeatureFlag, FeatureFlag,
@ -11,6 +12,7 @@ import { Datatable } from '@@/datatables';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { useList } from '../queries/list'; import { useList } from '../queries/list';
import { EdgeUpdateSchedule } from '../types'; import { EdgeUpdateSchedule } from '../types';
@ -20,12 +22,15 @@ import { columns } from './columns';
import { createStore } from './datatable-store'; import { createStore } from './datatable-store';
const storageKey = 'update-schedules-list'; const storageKey = 'update-schedules-list';
const useStore = createStore(storageKey); const settingsStore = createStore(storageKey);
export function ListView() { export function ListView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate); useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const listQuery = useList(); const listQuery = useList();
const store = useStore();
if (!listQuery.data) { if (!listQuery.data) {
return null; return null;
@ -40,20 +45,22 @@ export function ListView() {
/> />
<Datatable <Datatable
columns={columns}
titleOptions={{
title: 'Update & rollback',
icon: Clock,
}}
dataset={listQuery.data} dataset={listQuery.data}
settingsStore={store} columns={columns}
storageKey={storageKey} title="Update & rollback"
titleIcon={Clock}
emptyContentLabel="No schedules found" emptyContentLabel="No schedules found"
isLoading={listQuery.isLoading} isLoading={listQuery.isLoading}
totalCount={listQuery.data.length} totalCount={listQuery.data.length}
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} /> <TableActions selectedRows={selectedRows} />
)} )}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
/> />
</> </>
); );

View file

@ -1,36 +1,25 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import { import {
paginationSettings,
sortableSettings,
refreshableSettings, refreshableSettings,
hiddenColumnsSettings, hiddenColumnsSettings,
PaginationTableSettings,
RefreshableTableSettings, RefreshableTableSettings,
SettableColumnsTableSettings, SettableColumnsTableSettings,
SortableTableSettings, createPersistedStore,
BasicTableSettings,
} from '@/react/components/datatables/types'; } from '@/react/components/datatables/types';
interface TableSettings interface TableSettings
extends SortableTableSettings, extends BasicTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings, SettableColumnsTableSettings,
RefreshableTableSettings {} RefreshableTableSettings {}
export function createStore(storageKey: string) { export function createStore(storageKey: string) {
return create<TableSettings>()( return createPersistedStore<TableSettings>(
persist( storageKey,
'time',
(set) => ({ (set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set), ...hiddenColumnsSettings(set),
...refreshableSettings(set), ...refreshableSettings(set),
}), })
{
name: keyBuilder(storageKey),
}
)
); );
} }

View file

@ -11,17 +11,20 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { Datatable } from '@@/datatables'; import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { notificationsStore } from './notifications-store'; import { notificationsStore } from './notifications-store';
import { ToastNotification } from './types'; import { ToastNotification } from './types';
import { columns } from './columns'; import { columns } from './columns';
import { createStore } from './datatable-store';
const storageKey = 'notifications-list'; const storageKey = 'notifications-list';
const useSettingsStore = createStore(storageKey, 'time', true); const settingsStore = createPersistedStore(storageKey, {
id: 'time',
desc: true,
});
export function NotificationsView() { export function NotificationsView() {
const settingsStore = useSettingsStore();
const { user } = useUser(); const { user } = useUser();
const userNotifications: ToastNotification[] = const userNotifications: ToastNotification[] =
@ -29,9 +32,11 @@ export function NotificationsView() {
[]; [];
const breadcrumbs = 'Notifications'; const breadcrumbs = 'Notifications';
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const { const {
params: { id }, params: { id: activeItemId },
} = useCurrentStateAndParams(); } = useCurrentStateAndParams();
return ( return (
@ -39,19 +44,22 @@ export function NotificationsView() {
<PageHeader title="Notifications" breadcrumbs={breadcrumbs} reload /> <PageHeader title="Notifications" breadcrumbs={breadcrumbs} reload />
<Datatable <Datatable
columns={columns} columns={columns}
titleOptions={{ title="Notifications"
title: 'Notifications', titleIcon={Bell}
icon: Bell,
}}
dataset={userNotifications} dataset={userNotifications}
settingsStore={settingsStore}
storageKey="notifications"
emptyContentLabel="No notifications found" emptyContentLabel="No notifications found"
totalCount={userNotifications.length} totalCount={userNotifications.length}
renderTableActions={(selectedRows) => ( renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} /> <TableActions selectedRows={selectedRows} />
)} )}
initialActiveItem={id} initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
getRowId={(row) => row.id}
highlightedItemId={activeItemId}
/> />
</> </>
); );

View file

@ -1,40 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export function createStore(
storageKey: string,
initialSortBy?: string,
desc?: boolean
) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set, initialSortBy, desc),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -1,33 +1,17 @@
import { useTable, usePagination, useSortBy } from 'react-table'; import { List } from 'react-feather';
import { useRowSelectColumn } from '@lineup-lite/hooks'; import { useStore } from 'zustand';
import { Profile } from '@/portainer/hostmanagement/fdo/model'; import { Datatable } from '@@/datatables';
import PortainerError from '@/portainer/error'; import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { PaginationControls } from '@@/PaginationControls';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { TableFooter } from '@@/datatables/TableFooter';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { useRowSelect } from '@@/datatables/useRowSelect';
import {
Table,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
import { useFDOProfiles } from './useFDOProfiles';
import { useColumns } from './columns'; import { useColumns } from './columns';
import { FDOProfilesDatatableActions } from './FDOProfilesDatatableActions'; import { FDOProfilesDatatableActions } from './FDOProfilesDatatableActions';
import { useFDOProfiles } from './useFDOProfiles';
export interface FDOProfilesTableSettings const storageKey = 'fdoProfiles';
extends SortableTableSettings,
PaginationTableSettings {} const settingsStore = createPersistedStore(storageKey, 'name');
export interface FDOProfilesDatatableProps { export interface FDOProfilesDatatableProps {
isFDOEnabled: boolean; isFDOEnabled: boolean;
@ -36,132 +20,33 @@ export interface FDOProfilesDatatableProps {
export function FDOProfilesDatatable({ export function FDOProfilesDatatable({
isFDOEnabled, isFDOEnabled,
}: FDOProfilesDatatableProps) { }: FDOProfilesDatatableProps) {
const { settings, setTableSettings } =
useTableSettings<FDOProfilesTableSettings>();
const columns = useColumns(); const columns = useColumns();
const settings = useStore(settingsStore);
const { isLoading, profiles, error } = useFDOProfiles(); const [search, setSearch] = useSearchBarState(storageKey);
const { isLoading, profiles } = useFDOProfiles();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable<Profile>(
{
defaultCanFilter: false,
columns,
data: profiles,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
},
isRowSelectable() {
return isFDOEnabled;
},
selectColumnWidth: 5,
},
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<TableContainer> <Datatable
<TableTitle icon="list" featherIcon label="Device Profiles"> columns={columns}
dataset={profiles}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Device Profiles"
titleIcon={List}
disableSelect={!isFDOEnabled}
emptyContentLabel="No profiles found"
getRowId={(row) => row.id.toString()}
isLoading={isLoading}
renderTableActions={(selectedItems) => (
<FDOProfilesDatatableActions <FDOProfilesDatatableActions
isFDOEnabled={isFDOEnabled} isFDOEnabled={isFDOEnabled}
selectedItems={selectedFlatRows.map((row) => row.original)} selectedItems={selectedItems}
/> />
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Profile>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && profiles && profiles.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<Profile>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)} )}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={profiles ? profiles.length : 0}
onPageLimitChange={handlePageSizeChange}
/> />
</TableFooter>
</TableContainer>
); );
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
if (error) {
return error.message;
}
return 'No profiles found';
} }

View file

@ -1,22 +0,0 @@
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import {
FDOProfilesDatatable,
FDOProfilesDatatableProps,
} from './FDOProfilesDatatable';
export function FDOProfilesDatatableContainer({
...props
}: FDOProfilesDatatableProps) {
const defaultSettings = {
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="fdoProfiles">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FDOProfilesDatatable {...props} />
</TableSettingsProvider>
);
}

View file

@ -0,0 +1 @@
export { FDOProfilesDatatable } from './FDOProfilesDatatable';

View file

@ -11,7 +11,7 @@ import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { FDOProfilesDatatableContainer } from '../FDOProfilesDatatable/FDOProfilesDatatableContainer'; import { FDOProfilesDatatable } from '../FDOProfilesDatatable';
import styles from './SettingsFDO.module.css'; import styles from './SettingsFDO.module.css';
import { validationSchema } from './SettingsFDO.validation'; import { validationSchema } from './SettingsFDO.validation';
@ -165,7 +165,7 @@ export function SettingsFDO({ settings, onSubmit }: Props) {
Add, Edit and Manage the list of device profiles available Add, Edit and Manage the list of device profiles available
during FDO device setup during FDO device setup
</TextTip> </TextTip>
<FDOProfilesDatatableContainer isFDOEnabled={initialFDOEnabled} /> <FDOProfilesDatatable isFDOEnabled={initialFDOEnabled} />
</div> </div>
)} )}
</WidgetBody> </WidgetBody>

View file

@ -6,7 +6,7 @@ import { PageHeader } from '@@/PageHeader';
import { useTeams } from '../queries'; import { useTeams } from '../queries';
import { CreateTeamForm } from './CreateTeamForm'; import { CreateTeamForm } from './CreateTeamForm';
import { TeamsDatatableContainer } from './TeamsDatatable/TeamsDatatable'; import { TeamsDatatable } from './TeamsDatatable';
export function ListView() { export function ListView() {
const { isAdmin } = useUser(); const { isAdmin } = useUser();
@ -23,7 +23,7 @@ export function ListView() {
)} )}
{teamsQuery.data && ( {teamsQuery.data && (
<TeamsDatatableContainer teams={teamsQuery.data} isAdmin={isAdmin} /> <TeamsDatatable teams={teamsQuery.data} isAdmin={isAdmin} />
)} )}
</> </>
); );

View file

@ -1,14 +1,7 @@
import { useRowSelectColumn } from '@lineup-lite/hooks'; import { Column } from 'react-table';
import {
Column,
useGlobalFilter,
usePagination,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { useMutation, useQueryClient } from 'react-query'; import { useMutation, useQueryClient } from 'react-query';
import { Trash2, Users } from 'react-feather'; import { Trash2, Users } from 'react-feather';
import { useStore } from 'zustand';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
import { promiseSequence } from '@/portainer/helpers/promise-utils'; import { promiseSequence } from '@/portainer/helpers/promise-utils';
@ -16,23 +9,13 @@ import { Team, TeamId } from '@/react/portainer/users/teams/types';
import { deleteTeam } from '@/react/portainer/users/teams/teams.service'; import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { PaginationControls } from '@@/PaginationControls'; import { Datatable } from '@@/datatables';
import { Checkbox } from '@@/form-components/Checkbox';
import { Table } from '@@/datatables';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import {
TableSettingsProvider,
useTableSettings,
} from '@@/datatables/useTableSettings';
import { TableContent } from '@@/datatables/TableContent';
import { buildNameColumn } from '@@/datatables/NameCell'; import { buildNameColumn } from '@@/datatables/NameCell';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettings } from './types'; const storageKey = 'teams';
const tableKey = 'teams';
const columns: readonly Column<Team>[] = [ const columns: readonly Column<Team>[] = [
buildNameColumn('Name', 'Id', 'portainer.teams.team'), buildNameColumn('Name', 'Id', 'portainer.teams.team'),
@ -43,168 +26,47 @@ interface Props {
isAdmin: boolean; isAdmin: boolean;
} }
const settingsStore = createPersistedStore(storageKey);
export function TeamsDatatable({ teams, isAdmin }: Props) { export function TeamsDatatable({ teams, isAdmin }: Props) {
const { handleRemove } = useRemoveMutation(); const { handleRemove } = useRemoveMutation();
const settings = useStore(settingsStore);
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey); const [search, setSearch] = useSearchBarState(storageKey);
const { settings, setTableSettings } = useTableSettings<TableSettings>();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Team>(
{
defaultCanFilter: false,
columns,
data: teams,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
selectCheckboxComponent: Checkbox,
},
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
isAdmin ? useRowSelectColumn : emptyPlugin
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return ( return (
<div className="row"> <Datatable
<div className="col-sm-12"> dataset={teams}
<Table.Container> columns={columns}
<Table.Title icon={Users} label="Teams"> initialPageSize={settings.pageSize}
<SearchBar onPageSizeChange={settings.setPageSize}
value={searchBarValue} initialSortBy={settings.sortBy}
onChange={handleSearchBarChange} onSortByChange={settings.setSortBy}
/> searchValue={search}
onSearchChange={setSearch}
{isAdmin && ( title="Teams"
<Table.Actions> titleIcon={Users}
renderTableActions={(selectedRows) =>
isAdmin && (
<Button <Button
color="dangerlight" color="dangerlight"
onClick={handleRemoveClick} onClick={() => handleRemoveClick(selectedRows)}
disabled={selectedFlatRows.length === 0} disabled={selectedRows.length === 0}
icon={Trash2} icon={Trash2}
> >
Remove Remove
</Button> </Button>
</Table.Actions> )
)} }
</Table.Title> emptyContentLabel="No teams found"
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<Table.HeaderRow<Team>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/> />
); );
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<TableContent
prepareRow={prepareRow}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<Team>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
rows={page}
emptyContent="No teams found"
/>
</tbody>
</Table>
<TableFooter> function handleRemoveClick(selectedRows: Team[]) {
<SelectedRowsCount value={selectedFlatRows.length} /> const ids = selectedRows.map((row) => row.Id);
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={teams.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</Table.Container>
</div>
</div>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings({ pageSize });
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
setGlobalFilter(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings({ sortBy: { id, desc } });
}
function handleRemoveClick() {
const ids = selectedFlatRows.map((row) => row.original.Id);
handleRemove(ids); handleRemove(ids);
} }
} }
const defaultSettings: TableSettings = {
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
export function TeamsDatatableContainer(props: Props) {
return (
<TableSettingsProvider<TableSettings>
defaults={defaultSettings}
storageKey={tableKey}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<TeamsDatatable {...props} />
</TableSettingsProvider>
);
}
function useRemoveMutation() { function useRemoveMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -239,6 +101,3 @@ function useRemoveMutation() {
}); });
} }
} }
function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View file

@ -1,8 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
export interface TableSettings
extends PaginationTableSettings,
SortableTableSettings {}

View file

@ -30,6 +30,8 @@ const licenseInfo: LicenseInfo = {
expiresAt: Number.MAX_SAFE_INTEGER, expiresAt: Number.MAX_SAFE_INTEGER,
productEdition: Edition.EE, productEdition: Edition.EE,
valid: true, valid: true,
enforcedAt: 0,
enforced: false,
}; };
export const handlers = [ export const handlers = [

View file

@ -143,7 +143,7 @@
"xterm": "^3.8.0", "xterm": "^3.8.0",
"yaml": "^1.10.2", "yaml": "^1.10.2",
"yup": "^0.32.11", "yup": "^0.32.11",
"zustand": "^4.0.0" "zustand": "^4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@apidevtools/swagger-cli": "^4.0.4", "@apidevtools/swagger-cli": "^4.0.4",

View file

@ -19129,10 +19129,10 @@ z-schema@^5.0.1:
optionalDependencies: optionalDependencies:
commander "^2.7.1" commander "^2.7.1"
zustand@^4.0.0: zustand@^4.1.1:
version "4.0.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.0.0.tgz#739cba69209ffe67b31e7d6741c25b51496114a7" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.1.1.tgz#5a61cc755a002df5f041840a414ae6e9a99ee22b"
integrity sha512-OrsfQTnRXF1LZ9/vR/IqN9ws5EXUhb149xmPjErZnUrkgxS/gAHGy2dPNIVkVvoxrVe1sIydn4JjF0dYHmGeeQ== integrity sha512-h4F3WMqsZgvvaE0n3lThx4MM81Ls9xebjvrABNzf5+jb3/03YjNTSgZXeyrvXDArMeV9untvWXRw1tY+ntPYbA==
dependencies: dependencies:
use-sync-external-store "1.2.0" use-sync-external-store "1.2.0"