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

pull/8101/head
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,190 +1,65 @@
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"
<Authorized authorizations="AzureContainerGroupDelete"> titleIcon={Box}
<Button getRowId={(container) => container.id}
color="dangerlight" emptyContentLabel="No container available."
disabled={selectedFlatRows.length === 0} renderTableActions={(selectedRows) => (
onClick={() => <>
handleRemoveClick( <Authorized authorizations="AzureContainerGroupDelete">
selectedFlatRows.map((row) => row.original.id) <Button
) color="dangerlight"
} disabled={selectedRows.length === 0}
icon={Trash2} onClick={() => handleRemoveClick(selectedRows.map((r) => r.id))}
> icon={Trash2}
Remove
</Button>
</Authorized>
<Authorized authorizations="AzureContainerGroupCreate">
<Link to="azure.containerinstances.new" className="space-left">
<Button icon={Plus}>Add container</Button>
</Link>
</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 Remove
prepareRow={prepareRow} </Button>
renderRow={(row, { key, className, role, style }) => ( </Authorized>
<TableRow<ContainerGroup>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
rows={page}
emptyContent="No container available."
/>
</tbody>
</Table>
<TableFooter> <Authorized authorizations="AzureContainerGroupCreate">
<SelectedRowsCount value={selectedFlatRows.length} /> <Link to="azure.containerinstances.new" className="space-left">
<PaginationControls <Button icon={Plus}>Add container</Button>
showAll </Link>
pageLimit={pageSize} </Authorized>
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(() => { const selectedItems = tableInstance.selectedFlatRows.map(
if (initialActiveItem && pageSize !== rows.length) { (row) => row.original
const paginatedData = [...Array(Math.ceil(rows.length / pageSize))].map( );
(_, i) => rows.slice(pageSize * i, pageSize + pageSize * i)
);
const itemPage = paginatedData.findIndex((sub) =>
sub.some((row) => row.id === initialActiveItem)
);
gotoPage(itemPage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialActiveItem]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const selectedItems = selectedFlatRows.map((row) => row.original);
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} />
> <DatatableContent<D>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} /> tableInstance={tableInstance}
{renderTableActions && ( renderRow={(row, rowProps) =>
<Table.Actions> renderRow(row, rowProps, highlightedItemId)
{renderTableActions(selectedItems)} }
</Table.Actions> emptyContentLabel={emptyContentLabel}
)} isLoading={isLoading}
<Table.TitleActions> onSortChange={handleSortChange}
{!!renderTableSettings && renderTableSettings(tableInstance)} />
</Table.TitleActions>
</Table.Title> <DatatableFooter
)} onPageChange={handlePageChange}
<Table onPageSizeChange={handlePageSizeChange}
className={tableProps.className} page={tableInstance.state.pageIndex}
role={tableProps.role} pageSize={tableInstance.state.pageSize}
style={tableProps.style} totalCount={totalCount}
> totalSelected={selectedItems.length}
<thead> />
{headerGroups.map((headerGroup) => { </Table.Container>
const { key, className, role, style } = );
headerGroup.getHeaderGroupProps();
return ( function handleSearchBarChange(value: string) {
<Table.HeaderRow<D> tableInstance.setGlobalFilter(value);
key={key} onSearchChange(value);
className={className} }
role={role}
style={style} function handlePageChange(page: number) {
headers={headerGroup.headers} tableInstance.gotoPage(page);
/> onPageChange(page);
); }
})}
</thead> function handleSortChange(colId: string, desc: boolean) {
<tbody onSortByChange(colId, desc);
className={tbodyProps.className} }
role={tbodyProps.role}
style={tbodyProps.style} function handlePageSizeChange(pageSize: number) {
> tableInstance.setPageSize(pageSize);
<Table.Content<D> onPageSizeChange(pageSize);
rows={page} }
isLoading={isLoading} }
prepareRow={prepareRow}
emptyContent={emptyContentLabel} function defaultRenderRow<D extends Record<string, unknown>>(
renderRow={(row, { key, className, role, style }) => ( row: Row<D>,
<Table.Row<D> rowProps: TableRowProps,
cells={row.cells} highlightedItemId?: string
key={key} ) {
className={clsx( return (
className, <Table.Row<D>
initialActiveItem && key={rowProps.key}
initialActiveItem === row.id && cells={row.cells}
'active' className={clsx(rowProps.className, {
)} active: highlightedItemId === row.id,
role={role} })}
style={style} role={rowProps.role}
/> style={rowProps.style}
)} />
/>
</tbody>
</Table>
<Table.Footer>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={totalCount}
onPageLimitChange={setPageSize}
/>
</Table.Footer>
</Table.Container>
</TableSettingsProvider>
</div>
</div>
); );
} }
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="datatable"> <div className="row">
<Widget> <div className="col-sm-12">
<WidgetBody className="no-padding">{children}</WidgetBody> <div className="datatable">
</Widget> <Widget>
<WidgetBody className="no-padding">{children}</WidgetBody>
</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,53 +61,57 @@ export function ContainersDatatable({
return ( return (
<RowProvider context={{ environment }}> <RowProvider context={{ environment }}>
<Datatable <TableSettingsProvider settings={settingsStore}>
titleOptions={{ <Datatable
icon: 'svg-cubes', titleIcon={Box}
title: 'Containers', title="Containers"
}} initialPageSize={settings.pageSize}
settingsStore={settings} onPageSizeChange={settings.setPageSize}
columns={columns} initialSortBy={settings.sortBy}
renderTableActions={(selectedRows) => ( onSortByChange={settings.setSortBy}
<ContainersDatatableActions searchValue={search}
selectedItems={selectedRows} onSearchChange={setSearch}
isAddActionVisible columns={columns}
endpointId={environment.Id} renderTableActions={(selectedRows) => (
/> <ContainersDatatableActions
)} selectedItems={selectedRows}
isLoading={containersQuery.isLoading} isAddActionVisible
isRowSelectable={(row) => !row.original.IsPortainer} endpointId={environment.Id}
initialTableState={{ hiddenColumns: settings.hiddenColumns }} />
renderTableSettings={(tableInstance) => { )}
const columnsToHide = tableInstance.allColumns.filter((colInstance) => isLoading={containersQuery.isLoading}
hidableColumns?.includes(colInstance.id) isRowSelectable={(row) => !row.original.IsPortainer}
); initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter(
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return ( return (
<> <>
<ColumnVisibilityMenu<DockerContainer> <ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide} columns={columnsToHide}
onChange={(hiddenColumns) => { onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns); settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns); tableInstance.setHiddenColumns(hiddenColumns);
}} }}
value={settings.hiddenColumns} value={settings.hiddenColumns}
/>
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings
isRefreshVisible
settings={settings}
/> />
</TableSettingsMenu> <TableSettingsMenu
</> quickActions={<QuickActionsSettings actions={actions} />}
); >
}} <ContainersDatatableSettings
storageKey={storageKey} isRefreshVisible
dataset={containersQuery.data || []} settings={settings}
emptyContentLabel="No containers found" />
/> </TableSettingsMenu>
</>
);
}}
dataset={containersQuery.data || []}
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,40 +1,26 @@
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( ...hiddenColumnsSettings(set),
(set) => ({ ...refreshableSettings(set),
...sortableSettings(set), truncateContainerName: TRUNCATE_LENGTH,
...paginationSettings(set), setTruncateContainerName(truncateContainerName: number) {
...hiddenColumnsSettings(set), set({
...refreshableSettings(set), truncateContainerName,
truncateContainerName: TRUNCATE_LENGTH, });
setTruncateContainerName(truncateContainerName: number) { },
set({
truncateContainerName,
});
},
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,63 +38,59 @@ export function NetworkContainersTable({
} }
return ( return (
<div className="row"> <TableContainer>
<div className="col-lg-12 col-md-12 col-xs-12"> <TableTitle label="Containers in network" icon="server" featherIcon />
<TableContainer> <Table className="nopadding">
<TableTitle label="Containers in network" icon="server" featherIcon /> <DetailsTable
<Table className="nopadding"> headers={tableHeaders}
<DetailsTable dataCy="networkDetails-networkContainers"
headers={tableHeaders} >
dataCy="networkDetails-networkContainers" {networkContainers.map((container) => (
> <tr key={container.Id}>
{networkContainers.map((container) => ( <td>
<tr key={container.Id}> <Link
<td> to="docker.containers.container"
<Link params={{
to="docker.containers.container" id: container.Id,
params={{ nodeName,
id: container.Id, }}
nodeName, title={container.Name}
}} >
title={container.Name} {container.Name}
> </Link>
{container.Name} </td>
</Link> <td>{container.IPv4Address || '-'}</td>
</td> <td>{container.IPv6Address || '-'}</td>
<td>{container.IPv4Address || '-'}</td> <td>{container.MacAddress || '-'}</td>
<td>{container.IPv6Address || '-'}</td> <td>
<td>{container.MacAddress || '-'}</td> <Authorized authorizations="DockerNetworkDisconnect">
<td> <Button
<Authorized authorizations="DockerNetworkDisconnect"> data-cy={`networkDetails-disconnect${container.Name}`}
<Button size="xsmall"
data-cy={`networkDetails-disconnect${container.Name}`} color="dangerlight"
size="xsmall" onClick={() => {
color="dangerlight" if (container.Id) {
onClick={() => { disconnectContainer.mutate({
if (container.Id) { containerId: container.Id,
disconnectContainer.mutate({ environmentId,
containerId: container.Id, networkId,
environmentId, });
networkId, }
}); }}
} >
}} <Icon
> icon="trash-2"
<Icon feather
icon="trash-2" class-name="icon-secondary icon-md"
feather />
class-name="icon-secondary icon-md" Leave Network
/> </Button>
Leave Network </Authorized>
</Button> </td>
</Authorized> </tr>
</td> ))}
</tr> </DetailsTable>
))} </Table>
</DetailsTable> </TableContainer>
</Table>
</TableContainer>
</div>
</div>
); );
} }

View File

@ -29,86 +29,80 @@ export function NetworkDetailsTable({
); );
return ( return (
<div className="row"> <TableContainer>
<div className="col-lg-12 col-md-12 col-xs-12"> <TableTitle label="Network details" icon="share-2" featherIcon />
<TableContainer> <Table className="nopadding">
<TableTitle label="Network details" icon="share-2" featherIcon /> <DetailsTable dataCy="networkDetails-detailsTable">
<Table className="nopadding"> {/* networkRowContent */}
<DetailsTable dataCy="networkDetails-detailsTable"> <DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row>
{/* networkRowContent */} <DetailsTable.Row label="Id">
<DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row> {network.Id}
<DetailsTable.Row label="Id"> {allowRemoveNetwork && (
{network.Id} <Authorized authorizations="DockerNetworkDelete">
{allowRemoveNetwork && ( <Button
<Authorized authorizations="DockerNetworkDelete"> data-cy="networkDetails-deleteNetwork"
<Button size="xsmall"
data-cy="networkDetails-deleteNetwork" color="danger"
size="xsmall" onClick={() => onRemoveNetworkClicked()}
color="danger" >
onClick={() => onRemoveNetworkClicked()} <Icon
> icon="trash-2"
<Icon feather
icon="trash-2" className="space-right"
feather aria-hidden="true"
className="space-right" />
aria-hidden="true" Delete this network
/> </Button>
Delete this network </Authorized>
</Button> )}
</Authorized> </DetailsTable.Row>
)} <DetailsTable.Row label="Driver">{network.Driver}</DetailsTable.Row>
</DetailsTable.Row> <DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row>
<DetailsTable.Row label="Driver"> <DetailsTable.Row label="Attachable">
{network.Driver} {String(network.Attachable)}
</DetailsTable.Row> </DetailsTable.Row>
<DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row> <DetailsTable.Row label="Internal">
<DetailsTable.Row label="Attachable"> {String(network.Internal)}
{String(network.Attachable)} </DetailsTable.Row>
</DetailsTable.Row>
<DetailsTable.Row label="Internal">
{String(network.Internal)}
</DetailsTable.Row>
{/* IPV4 ConfigRowContent */} {/* IPV4 ConfigRowContent */}
{ipv4Configs.map((config) => ( {ipv4Configs.map((config) => (
<Fragment key={config.Subnet}> <Fragment key={config.Subnet}>
<DetailsTable.Row <DetailsTable.Row
label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`} label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`}
> >
{`IPV4 Gateway${getConfigDetails(config.Gateway)}`} {`IPV4 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row> </DetailsTable.Row>
<DetailsTable.Row <DetailsTable.Row
label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`} label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`}
> >
{`IPV4 Excluded IPs${getAuxiliaryAddresses( {`IPV4 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses config.AuxiliaryAddresses
)}`} )}`}
</DetailsTable.Row> </DetailsTable.Row>
</Fragment> </Fragment>
))} ))}
{/* IPV6 ConfigRowContent */} {/* IPV6 ConfigRowContent */}
{ipv6Configs.map((config) => ( {ipv6Configs.map((config) => (
<Fragment key={config.Subnet}> <Fragment key={config.Subnet}>
<DetailsTable.Row <DetailsTable.Row
label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`} label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`}
> >
{`IPV6 Gateway${getConfigDetails(config.Gateway)}`} {`IPV6 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row> </DetailsTable.Row>
<DetailsTable.Row <DetailsTable.Row
label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`} label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`}
> >
{`IPV6 Excluded IPs${getAuxiliaryAddresses( {`IPV6 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses config.AuxiliaryAddresses
)}`} )}`}
</DetailsTable.Row> </DetailsTable.Row>
</Fragment> </Fragment>
))} ))}
</DetailsTable> </DetailsTable>
</Table> </Table>
</TableContainer> </TableContainer>
</div>
</div>
); );
function getConfigDetails(configValue?: string) { function getConfigDetails(configValue?: string) {

View File

@ -15,21 +15,17 @@ export function NetworkOptionsTable({ options }: Props) {
} }
return ( return (
<div className="row"> <TableContainer>
<div className="col-lg-12 col-md-12 col-xs-12"> <TableTitle label="Network options" icon="share-2" featherIcon />
<TableContainer> <Table className="nopadding">
<TableTitle label="Network options" icon="share-2" featherIcon /> <DetailsTable dataCy="networkDetails-networkOptionsTable">
<Table className="nopadding"> {networkEntries.map(([key, value]) => (
<DetailsTable dataCy="networkDetails-networkOptionsTable"> <DetailsTable.Row key={key} label={key}>
{networkEntries.map(([key, value]) => ( {value}
<DetailsTable.Row key={key} label={key}> </DetailsTable.Row>
{value} ))}
</DetailsTable.Row> </DetailsTable>
))} </Table>
</DetailsTable> </TableContainer>
</Table>
</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,49 +60,53 @@ export function StackContainersDatatable({ environment, stackName }: Props) {
return ( return (
<RowProvider context={{ environment }}> <RowProvider context={{ environment }}>
<Datatable <TableSettingsProvider settings={settingsStore}>
titleOptions={{ <Datatable
icon: 'fa-cubes', title="Containers"
title: 'Containers', titleIcon={Box}
}} initialPageSize={settings.pageSize}
settingsStore={settings} onPageSizeChange={settings.setPageSize}
columns={columns} initialSortBy={settings.sortBy}
renderTableActions={(selectedRows) => ( onSortByChange={settings.setSortBy}
<ContainersDatatableActions searchValue={search}
selectedItems={selectedRows} onSearchChange={setSearch}
isAddActionVisible={false} columns={columns}
endpointId={environment.Id} renderTableActions={(selectedRows) => (
/> <ContainersDatatableActions
)} selectedItems={selectedRows}
initialTableState={{ hiddenColumns: settings.hiddenColumns }} isAddActionVisible={false}
renderTableSettings={(tableInstance) => { endpointId={environment.Id}
const columnsToHide = tableInstance.allColumns.filter((colInstance) => />
hidableColumns?.includes(colInstance.id) )}
); initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter(
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return ( return (
<> <>
<ColumnVisibilityMenu<DockerContainer> <ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide} columns={columnsToHide}
onChange={(hiddenColumns) => { onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns); settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns); tableInstance.setHiddenColumns(hiddenColumns);
}} }}
value={settings.hiddenColumns} value={settings.hiddenColumns}
/> />
<TableSettingsMenu <TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />} quickActions={<QuickActionsSettings actions={actions} />}
> >
<ContainersDatatableSettings settings={settings} /> <ContainersDatatableSettings settings={settings} />
</TableSettingsMenu> </TableSettingsMenu>
</> </>
); );
}} }}
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) { function userMessage(error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
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"> {isOpenAmtEnabled && someDeviceHasAMTActivated && (
<TableContainer> <InformationPanel>
<TableTitle icon="box" featherIcon label="Edge Devices"> <div className={styles.kvmTip}>
<SearchBar value={search} onChange={handleSearchBarChange} /> <TextTip color="blue">
<TableActions> For the KVM function to work you need to have the MPS server added
<EdgeDevicesDatatableActions to your trusted site list, browse to this
selectedItems={selectedFlatRows.map((row) => row.original)} <a
isFDOEnabled={isFdoEnabled} href={`https://${mpsServer}`}
isOpenAMTEnabled={isOpenAmtEnabled} target="_blank"
setLoadingMessage={setLoadingMessage} rel="noreferrer"
showWaitingRoomLink={showWaitingRoomLink} className="mx-px"
/> >
</TableActions> site
<TableTitleActions> </a>
<ColumnVisibilityMenu<Environment> and add to your trusted site list
columns={columnsToHide} </TextTip>
onChange={handleChangeColumnsVisibility} </div>
value={settings.hiddenColumns} </InformationPanel>
/> )}
<TableSettingsMenu> <RowProvider context={{ isOpenAmtEnabled, groups }}>
<EdgeDevicesDatatableSettings /> <ExpandableDatatable
</TableSettingsMenu> dataset={environments}
</TableTitleActions> columns={columns}
</TableTitle> isLoading={isLoading}
{isOpenAmtEnabled && someDeviceHasAMTActivated && ( totalCount={totalCount}
<div className={styles.kvmTip}> title="Edge Devices"
<TextTip color="blue"> titleIcon={Box}
For the KVM function to work you need to have the MPS server initialPageSize={settings.pageSize}
added to your trusted site list, browse to this{' '} onPageSizeChange={settings.setPageSize}
<a initialSortBy={settings.sortBy}
href={`https://${mpsServer}`} onSortByChange={settings.setSortBy}
target="_blank" searchValue={search}
rel="noreferrer" onSearchChange={setSearch}
className="space-right" renderSubRow={(row) => (
> <tr>
site <td />
</a> <td colSpan={row.cells.length - 1}>
and add to your trusted site list <AMTDevicesDatatable environmentId={row.original.Id} />
</TextTip> </td>
</div> </tr>
)} )}
<Table initialTableState={{ pageIndex: page }}
className={tableProps.className} pageCount={Math.ceil(totalCount / settings.pageSize)}
role={tableProps.role} renderTableActions={(selectedRows) => (
style={tableProps.style} <EdgeDevicesDatatableActions
> selectedItems={selectedRows}
<thead> isFDOEnabled={isFdoEnabled}
{headerGroups.map((headerGroup) => { isOpenAMTEnabled={isOpenAmtEnabled}
const { key, className, role, style } = showWaitingRoomLink={showWaitingRoomLink}
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<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
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>
<td />
<td colSpan={row.cells.length - 1}>
<AMTDevicesDatatable
environmentId={row.original.Id}
/>
</td>
</tr>
)}
</RowProvider>
);
}}
/>
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
isPageInputVisible
pageLimit={pagination.pageLimit}
page={pagination.page}
onPageChange={(p) => gotoPage(p)}
totalCount={totalCount}
onPageLimitChange={handlePageSizeChange}
/> />
</TableFooter> )}
</TableContainer> renderTableSettings={(tableInstance) => {
</div> const columnsToHide = tableInstance.allColumns.filter(
</div> (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}
/>
</RowProvider>
</>
); );
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(
); 'Successfully associated with OpenAMT',
await activateDevice(selectedEnvironment.Id); selectedEnvironment.Name
notifications.success( );
'Successfully associated with OpenAMT', },
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}
<Button title="Edge Devices Waiting Room"
onClick={() => emptyContentLabel="No Edge Devices found"
handleAssociateDevice(selectedFlatRows.map((r) => r.original)) renderTableActions={(selectedRows) => (
} <>
disabled={selectedFlatRows.length === 0} <Button
> onClick={() => handleAssociateDevice(selectedRows)}
Associate Device disabled={selectedRows.length === 0}
</Button>
</Table.Actions>
</Table.Title>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
> >
<thead> Associate Device
{headerGroups.map((headerGroup) => { </Button>
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return ( {licenseOverused ? (
<Table.HeaderRow<Environment> <div className="ml-2 mt-2">
key={key} <TextTip color="orange">
className={className} Associating devices is disabled as your node count exceeds your
role={role} license limit
style={style} </TextTip>
headers={headerGroup.headers} </div>
onSortChange={handleSortChange} ) : null}
/> </>
); )}
})} isLoading={isLoading}
</thead> totalCount={totalCount}
/>
<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}
/>
)}
/>
</tbody>
</Table>
<Table.Footer>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
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' } }} devices={environments}
storageKey={storageKey} totalCount={totalCount}
> isLoading={isLoading}
<DataTable />
devices={environments}
totalCount={totalCount}
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" /> isLoading={isLoading}
columns={columns}
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} /> dataset={data}
initialPageSize={settings.pageSize}
<Table onPageSizeChange={settings.setPageSize}
className={tableProps.className} initialSortBy={settings.sortBy}
role={tableProps.role} onSortByChange={settings.setSortBy}
style={tableProps.style} searchValue={search}
> onSearchChange={setSearch}
<thead> titleIcon="fa-history"
{headerGroups.map((headerGroup) => { title="Events"
const { key, className, role, style } = totalCount={data.length}
headerGroup.getHeaderGroupProps(); getRowId={(row) => `${row.Date}-${row.Message}-${row.Type}`}
disableSelect
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}
emptyContent="No events found"
renderRow={(row, { key, className, role, style }) => (
<Fragment key={key}>
<TableRow<NomadEvent>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
</Fragment>
)}
/>
</tbody>
</Table>
<TableFooter>
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={data.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,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} isLoading={isLoading}
role={tableProps.role} renderTableSettings={() => (
style={tableProps.style} <TableSettingsMenu>
> <JobsDatatableSettings settings={settings} />
<thead> </TableSettingsMenu>
{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}
emptyContent="No jobs found"
renderRow={(row, { key, className, role, style }) => (
<Fragment key={key}>
<TableRow<Job>
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"> <JobsDatatable
<div className="col-sm-12"> jobs={jobsQuery.data || []}
<TableSettingsProvider defaults={defaultSettings} storageKey="jobs"> refreshData={reloadData}
<JobsDatatable isLoading={jobsQuery.isLoading}
jobs={jobsQuery.data || []} />
refreshData={reloadData}
isLoading={jobsQuery.isLoading}
/>
</TableSettingsProvider>
</div>
</div>
</> </>
); );
} }

View File

@ -175,159 +175,156 @@ 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>
<TableTitle icon="hard-drive" featherIcon label="Environments" />
<TableActions className={styles.actionBar}> <TableContainer>
<div className={styles.description}> <TableTitle icon="hard-drive" featherIcon label="Environments" />
Click on an environment to manage
</div> <TableActions className={styles.actionBar}>
<div className={styles.actionButton}> <div className={styles.description}>
<div className={styles.refreshButton}> Click on an environment to manage
{isAdmin && ( </div>
<Button <div className={styles.actionButton}>
onClick={onRefresh} <div className={styles.refreshButton}>
data-cy="home-refreshEndpointsButton" {isAdmin && (
size="medium" <Button
color="secondary" onClick={onRefresh}
className={clsx( data-cy="home-refreshEndpointsButton"
'vertical-center !ml-0', size="medium"
styles.refreshEnvironmentsButton color="secondary"
)} className={clsx(
> 'vertical-center !ml-0',
<RefreshCcw styles.refreshEnvironmentsButton
className="feather icon-sm icon-white"
aria-hidden="true"
/>
Refresh
</Button>
)} )}
</div> >
<div className={styles.kubeconfigButton}> <RefreshCcw
<KubeconfigButton className="feather icon-sm icon-white"
environments={environments} aria-hidden="true"
envQueryParams={{
...environmentsQueryParams,
sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc',
}}
/> />
</div> Refresh
<div className={clsx(styles.filterSearchbar, 'ml-3')}> </Button>
<FilterSearchBar
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
</div>
</div>
</TableActions>
<div className={styles.filterContainer}>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={platformTypeOptions}
onChange={setPlatformTypes}
placeHolder="Platform"
value={platformTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={connectionTypeOptions}
onChange={setConnectionTypes}
placeHolder="Connection Type"
value={connectionTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={status}
onChange={statusOnChange}
placeHolder="Status"
value={statusState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueTag}
onChange={tagOnChange}
placeHolder="Tags"
value={tagState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueGroup}
onChange={groupOnChange}
placeHolder="Groups"
value={groupState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter<string>
filterOptions={
agentVersionsQuery.data?.map((v) => ({
label: v,
value: v,
})) || []
}
onChange={setAgentVersions}
placeHolder="Agent Version"
value={agentVersions}
/>
</div>
<button
type="button"
className={styles.clearButton}
onClick={clearFilter}
>
Clear all
</button>
<div className={styles.filterRight}>
<SortbySelector
filterOptions={sortByOptions}
onChange={sortOnchange}
onDescending={sortOnDescending}
placeHolder="Sort By"
sortByDescending={sortByDescending}
sortByButton={sortByButton}
value={sortByState}
/>
</div>
</div>
<div className="blocklist" data-cy="home-endpointList">
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
}
onClick={onClickItem}
/>
))
)} )}
</div> </div>
<div className={styles.kubeconfigButton}>
<TableFooter> <KubeconfigButton
<PaginationControls environments={environments}
showAll={totalCount <= 100} envQueryParams={{
pageLimit={pageLimit} ...environmentsQueryParams,
page={page} sort: sortByFilter,
onPageChange={setPage} order: sortByDescending ? 'desc' : 'asc',
totalCount={totalCount} }}
onPageLimitChange={setPageLimit}
/> />
</TableFooter> </div>
</TableContainer> <div className={clsx(styles.filterSearchbar, 'ml-3')}>
<FilterSearchBar
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
</div>
</div>
</TableActions>
<div className={styles.filterContainer}>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={platformTypeOptions}
onChange={setPlatformTypes}
placeHolder="Platform"
value={platformTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={connectionTypeOptions}
onChange={setConnectionTypes}
placeHolder="Connection Type"
value={connectionTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={status}
onChange={statusOnChange}
placeHolder="Status"
value={statusState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueTag}
onChange={tagOnChange}
placeHolder="Tags"
value={tagState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueGroup}
onChange={groupOnChange}
placeHolder="Groups"
value={groupState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter<string>
filterOptions={
agentVersionsQuery.data?.map((v) => ({
label: v,
value: v,
})) || []
}
onChange={setAgentVersions}
placeHolder="Agent Version"
value={agentVersions}
/>
</div>
<button
type="button"
className={styles.clearButton}
onClick={clearFilter}
>
Clear all
</button>
<div className={styles.filterRight}>
<SortbySelector
filterOptions={sortByOptions}
onChange={sortOnchange}
onDescending={sortOnDescending}
placeHolder="Sort By"
sortByDescending={sortByDescending}
sortByButton={sortByButton}
value={sortByState}
/>
</div>
</div> </div>
</div> <div className="blocklist" data-cy="home-endpointList">
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
}
onClick={onClickItem}
/>
))
)}
</div>
<TableFooter>
<PaginationControls
showAll={totalCount <= 100}
pageLimit={pageLimit}
page={page}
onPageChange={setPage}
totalCount={totalCount}
onPageLimitChange={setPageLimit}
/>
</TableFooter>
</TableContainer>
</> </>
); );

View File

@ -46,39 +46,35 @@ export function AccessControlPanel({
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams); (!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
return ( return (
<div className="row"> <TableContainer>
<div className="col-sm-12"> <TableTitle label="Access control" icon="eye" featherIcon />
<TableContainer> <AccessControlPanelDetails
<TableTitle label="Access control" icon="eye" featherIcon /> resourceType={resourceType}
<AccessControlPanelDetails resourceControl={resourceControl}
resourceType={resourceType} />
resourceControl={resourceControl}
/>
{!isEditDisabled && !isEditMode && ( {!isEditDisabled && !isEditMode && (
<div className="row"> <div className="row">
<div> <div>
<Button color="link" onClick={toggleEditMode}> <Button color="link" onClick={toggleEditMode}>
<Icon icon="edit" className="space-right" feather /> <Icon icon="edit" className="space-right" feather />
Change ownership Change ownership
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{isEditMode && ( {isEditMode && (
<AccessControlPanelForm <AccessControlPanelForm
resourceControl={resourceControl} resourceControl={resourceControl}
onCancelClick={() => toggleEditMode()} onCancelClick={() => toggleEditMode()}
resourceId={resourceId} resourceId={resourceId}
resourceType={resourceType} resourceType={resourceType}
environmentId={environmentId} environmentId={environmentId}
onUpdateSuccess={handleUpdateSuccess} onUpdateSuccess={handleUpdateSuccess}
/> />
)} )}
</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,
(set) => ({ 'time',
...sortableSettings(set),
...paginationSettings(set), (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}
<Button renderTableActions={(selectedRows) =>
color="dangerlight" isAdmin && (
onClick={handleRemoveClick} <Button
disabled={selectedFlatRows.length === 0} color="dangerlight"
icon={Trash2} onClick={() => handleRemoveClick(selectedRows)}
> disabled={selectedRows.length === 0}
Remove icon={Trash2}
</Button>
</Table.Actions>
)}
</Table.Title>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
> >
<thead> Remove
{headerGroups.map((headerGroup) => { </Button>
const { key, className, role, style } = )
headerGroup.getHeaderGroupProps(); }
emptyContentLabel="No teams found"
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>
<SelectedRowsCount value={selectedFlatRows.length} />
<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) { function handleRemoveClick(selectedRows: Team[]) {
setPageSize(pageSize); const ids = selectedRows.map((row) => row.Id);
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"