diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 73de737f0..7f7a78d77 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -123,6 +123,10 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta endpointCount := len(endpoints) + if start < 0 { + start = 0 + } + if start > endpointCount { start = endpointCount } diff --git a/app/portainer/hostmanagement/open-amt/queries.ts b/app/portainer/hostmanagement/open-amt/queries.ts new file mode 100644 index 000000000..ab6f9ba5e --- /dev/null +++ b/app/portainer/hostmanagement/open-amt/queries.ts @@ -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', + }, + }); +} diff --git a/app/portainer/license-management/types.ts b/app/portainer/license-management/types.ts index da30c6c2a..fac1d326c 100644 --- a/app/portainer/license-management/types.ts +++ b/app/portainer/license-management/types.ts @@ -40,4 +40,6 @@ export interface LicenseInfo { nodes: number; type: LicenseType; valid: boolean; + enforcedAt: number; + enforced: boolean; } diff --git a/app/portainer/license-management/use-license.service.ts b/app/portainer/license-management/use-license.service.ts index 39dc43ea8..7c5c9495d 100644 --- a/app/portainer/license-management/use-license.service.ts +++ b/app/portainer/license-management/use-license.service.ts @@ -2,8 +2,10 @@ import { useQuery } from 'react-query'; import { error as notifyError } from '@/portainer/services/notifications'; +import { getNodesCount } from '../services/api/status.service'; + import { getLicenseInfo } from './license.service'; -import { LicenseInfo } from './types'; +import { LicenseInfo, LicenseType } from './types'; export function useLicenseInfo() { const { isLoading, data: info } = useQuery( @@ -18,3 +20,33 @@ export function useLicenseInfo() { 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 }; +} diff --git a/app/portainer/views/account/account.html b/app/portainer/views/account/account.html index 9a7ad3662..54a972610 100644 --- a/app/portainer/views/account/account.html +++ b/app/portainer/views/account/account.html @@ -77,6 +77,11 @@ + + + +
+
+
+
+ +
+
diff --git a/app/react/azure/container-instances/ListView/ContainersDatatable.tsx b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx index a58e99d6e..605314a84 100644 --- a/app/react/azure/container-instances/ListView/ContainersDatatable.tsx +++ b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx @@ -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 { useStore } from 'zustand'; -import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; import { ContainerGroup } from '@/react/azure/types'; import { Authorized } from '@/react/hooks/useUser'; import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; -import { PaginationControls } from '@@/PaginationControls'; -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 { Datatable } from '@@/datatables'; import { Button } from '@@/buttons'; import { Link } from '@@/Link'; +import { createPersistedStore } from '@@/datatables/types'; +import { useSearchBarState } from '@@/datatables/SearchBar'; -import { TableSettings } from './types'; -import { useColumns } from './columns'; +import { columns } from './columns'; +const tableKey = 'containergroups'; + +const settingsStore = createPersistedStore(tableKey, 'name'); export interface Props { - tableKey: string; dataset: ContainerGroup[]; onRemoveClick(containerIds: string[]): void; } -export function ContainersDatatable({ - dataset, - tableKey, - onRemoveClick, -}: Props) { - const { settings, setTableSettings } = useTableSettings(); - const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey); - - const columns = useColumns(); - const { - getTableProps, - getTableBodyProps, - headerGroups, - page, - prepareRow, - selectedFlatRows, - gotoPage, - setPageSize, - setGlobalFilter, - state: { pageIndex, pageSize }, - } = useTable( - { - 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(); +export function ContainersDatatable({ dataset, onRemoveClick }: Props) { + const settings = useStore(settingsStore); + const [search, setSearch] = useSearchBarState(tableKey); return ( -
-
- - - - - - - - - - - - - - - - - - - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); - - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - onSortChange={handleSortChange} - /> - ); - })} - - container.id} + emptyContentLabel="No container available." + renderTableActions={(selectedRows) => ( + <> + + -
+ Remove + + - - - gotoPage(p - 1)} - totalCount={dataset.length} - onPageLimitChange={handlePageSizeChange} - /> - -
-
-
+ + + + + + + )} + /> ); async function handleRemoveClick(containerIds: string[]) { @@ -197,20 +72,4 @@ export function ContainersDatatable({ 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 }, - })); - } } diff --git a/app/react/azure/container-instances/ListView/ListView.tsx b/app/react/azure/container-instances/ListView/ListView.tsx index 21f0b726e..4c24acddb 100644 --- a/app/react/azure/container-instances/ListView/ListView.tsx +++ b/app/react/azure/container-instances/ListView/ListView.tsx @@ -9,19 +9,10 @@ import { useContainerGroups } from '@/react/azure/queries/useContainerGroups'; import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; import { PageHeader } from '@@/PageHeader'; -import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { ContainersDatatable } from './ContainersDatatable'; -import { TableSettings } from './types'; export function ListView() { - const defaultSettings: TableSettings = { - pageSize: 10, - sortBy: { id: 'state', desc: false }, - }; - - const tableKey = 'containergroups'; - const environmentId = useEnvironmentId(); const subscriptionsQuery = useSubscriptions(environmentId); @@ -45,13 +36,11 @@ export function ListView() { reload title="Container list" /> - - - + + ); } diff --git a/app/react/azure/container-instances/ListView/columns/index.ts b/app/react/azure/container-instances/ListView/columns/index.ts index a9b259d5e..835b81664 100644 --- a/app/react/azure/container-instances/ListView/columns/index.ts +++ b/app/react/azure/container-instances/ListView/columns/index.ts @@ -1,10 +1,6 @@ -import { useMemo } from 'react'; - import { name } from './name'; import { location } from './location'; import { ports } from './ports'; import { ownership } from './ownership'; -export function useColumns() { - return useMemo(() => [name, location, ports, ownership], []); -} +export const columns = [name, location, ports, ownership]; diff --git a/app/react/azure/container-instances/ListView/types.ts b/app/react/azure/container-instances/ListView/types.ts deleted file mode 100644 index 37da06ab7..000000000 --- a/app/react/azure/container-instances/ListView/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - PaginationTableSettings, - SortableTableSettings, -} from '@@/datatables/types-old'; - -export interface TableSettings - extends PaginationTableSettings, - SortableTableSettings {} diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index 4c8da66d0..d40ba06ed 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -8,79 +8,95 @@ import { Row, TableInstance, TableState, + TableRowProps, + useExpanded, } from 'react-table'; -import { ReactNode, useEffect } from 'react'; +import { ReactNode } from 'react'; import { useRowSelectColumn } from '@lineup-lite/hooks'; import clsx from 'clsx'; -import { PaginationControls } from '@@/PaginationControls'; import { IconProps } from '@@/Icon'; import { Table } from './Table'; import { multiple } from './filter-types'; -import { SearchBar, useSearchBarState } from './SearchBar'; -import { SelectedRowsCount } from './SelectedRowsCount'; -import { TableSettingsProvider } from './useZustandTableSettings'; import { useRowSelect } from './useRowSelect'; -import { PaginationTableSettings, SortableTableSettings } from './types'; +import { BasicTableSettings } from './types'; +import { DatatableHeader } from './DatatableHeader'; +import { DatatableFooter } from './DatatableFooter'; +import { DatatableContent } from './DatatableContent'; +import { defaultGetRowId } from './defaultGetRowId'; +import { emptyPlugin } from './emptyReactTablePlugin'; +import { useGoToHighlightedRow } from './useGoToHighlightedRow'; -interface DefaultTableSettings - extends SortableTableSettings, - PaginationTableSettings {} - -interface TitleOptionsVisible { - title: string; - icon?: IconProps['icon']; - featherIcon?: IconProps['featherIcon']; - hide?: never; -} - -type TitleOptions = TitleOptionsVisible | { hide: true }; - -interface Props< - D extends Record, - TSettings extends DefaultTableSettings -> { +export interface Props> { dataset: D[]; - storageKey: string; columns: readonly Column[]; renderTableSettings?(instance: TableInstance): ReactNode; renderTableActions?(selectedRows: D[]): ReactNode; - settingsStore: TSettings; disableSelect?: boolean; getRowId?(row: D): string; isRowSelectable?(row: Row): boolean; emptyContentLabel?: string; - titleOptions: TitleOptions; + title?: string; + titleIcon?: IconProps['icon']; initialTableState?: Partial>; isLoading?: boolean; totalCount?: number; - description?: JSX.Element; - initialActiveItem?: string; + description?: ReactNode; + pageCount?: number; + initialSortBy?: BasicTableSettings['sortBy']; + initialPageSize?: BasicTableSettings['pageSize']; + highlightedItemId?: string; + + searchValue: string; + onSearchChange(search: string): void; + onSortByChange(colId: string, desc: boolean): void; + onPageSizeChange(pageSize: number): void; + + // send state up + onPageChange?(page: number): void; + + renderRow?( + row: Row, + rowProps: TableRowProps, + highlightedItemId?: string + ): ReactNode; + expandable?: boolean; + noWidget?: boolean; } -export function Datatable< - D extends Record, - TSettings extends DefaultTableSettings ->({ +export function Datatable>({ columns, dataset, - storageKey, - renderTableSettings, - renderTableActions, - settingsStore, + renderTableSettings = () => null, + renderTableActions = () => null, disableSelect, getRowId = defaultGetRowId, isRowSelectable = () => true, - titleOptions, + title, + titleIcon, emptyContentLabel, initialTableState = {}, isLoading, totalCount = dataset.length, description, - initialActiveItem, -}: Props) { - const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); + pageCount, + + initialSortBy, + initialPageSize = 10, + onPageChange = () => {}, + + onPageSizeChange, + onSortByChange, + searchValue, + onSearchChange, + + renderRow = defaultRenderRow, + expandable = false, + highlightedItemId, + noWidget, +}: Props) { + const isServerSidePagination = typeof pageCount !== 'undefined'; const tableInstance = useTable( { @@ -89,183 +105,104 @@ export function Datatable< data: dataset, filterTypes: { multiple }, initialState: { - pageSize: settingsStore.pageSize || 10, - sortBy: [settingsStore.sortBy], - globalFilter: searchBarValue, + pageSize: initialPageSize, + sortBy: initialSortBy ? [initialSortBy] : [], + globalFilter: searchValue, ...initialTableState, }, isRowSelectable, + autoResetExpanded: false, autoResetSelectedRows: false, getRowId, - stateReducer: (newState, action) => { - switch (action.type) { - case 'setGlobalFilter': - setSearchBarValue(action.filterValue); - break; - case 'toggleSortBy': - settingsStore.setSortBy(action.columnId, action.desc); - break; - case 'setPageSize': - settingsStore.setPageSize(action.pageSize); - break; - default: - break; - } - return newState; - }, + ...(isServerSidePagination ? { manualPagination: true, pageCount } : {}), }, useFilters, useGlobalFilter, useSortBy, + expandable ? useExpanded : emptyPlugin, usePagination, useRowSelect, !disableSelect ? useRowSelectColumn : emptyPlugin ); - const { - rows, - selectedFlatRows, - getTableProps, - getTableBodyProps, - headerGroups, - page, - prepareRow, - gotoPage, - setPageSize, - setGlobalFilter, - state: { pageIndex, pageSize }, - } = tableInstance; + useGoToHighlightedRow( + isServerSidePagination, + tableInstance.state.pageSize, + tableInstance.rows, + handlePageChange, + highlightedItemId + ); - useEffect(() => { - if (initialActiveItem && pageSize !== rows.length) { - const paginatedData = [...Array(Math.ceil(rows.length / pageSize))].map( - (_, i) => rows.slice(pageSize * i, pageSize + pageSize * i) - ); - - const itemPage = paginatedData.findIndex((sub) => - sub.some((row) => row.id === initialActiveItem) - ); - - gotoPage(itemPage); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialActiveItem]); - - const tableProps = getTableProps(); - const tbodyProps = getTableBodyProps(); - - const selectedItems = selectedFlatRows.map((row) => row.original); + const selectedItems = tableInstance.selectedFlatRows.map( + (row) => row.original + ); return ( -
-
- - - {isTitleVisible(titleOptions) && ( - - - {renderTableActions && ( - - {renderTableActions(selectedItems)} - - )} - - {!!renderTableSettings && renderTableSettings(tableInstance)} - - - )} - - - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - /> - ); - })} - - - - rows={page} - isLoading={isLoading} - prepareRow={prepareRow} - emptyContent={emptyContentLabel} - renderRow={(row, { key, className, role, style }) => ( - - cells={row.cells} - key={key} - className={clsx( - className, - initialActiveItem && - initialActiveItem === row.id && - 'active' - )} - role={role} - style={style} - /> - )} - /> - -
- - - gotoPage(p - 1)} - totalCount={totalCount} - onPageLimitChange={setPageSize} - /> - -
-
-
-
+ + renderTableActions(selectedItems)} + renderTableSettings={() => renderTableSettings(tableInstance)} + description={description} + /> + + tableInstance={tableInstance} + renderRow={(row, rowProps) => + renderRow(row, rowProps, highlightedItemId) + } + emptyContentLabel={emptyContentLabel} + isLoading={isLoading} + onSortChange={handleSortChange} + /> + + + + ); + + function handleSearchBarChange(value: string) { + tableInstance.setGlobalFilter(value); + onSearchChange(value); + } + + function handlePageChange(page: number) { + tableInstance.gotoPage(page); + onPageChange(page); + } + + function handleSortChange(colId: string, desc: boolean) { + onSortByChange(colId, desc); + } + + function handlePageSizeChange(pageSize: number) { + tableInstance.setPageSize(pageSize); + onPageSizeChange(pageSize); + } +} + +function defaultRenderRow>( + row: Row, + rowProps: TableRowProps, + highlightedItemId?: string +) { + return ( + + key={rowProps.key} + cells={row.cells} + className={clsx(rowProps.className, { + active: highlightedItemId === row.id, + })} + role={rowProps.role} + style={rowProps.style} + /> ); } - -function isTitleVisible( - titleSettings: TitleOptions -): titleSettings is TitleOptionsVisible { - return !titleSettings.hide; -} - -function defaultGetRowId>(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'; diff --git a/app/react/components/datatables/DatatableContent.tsx b/app/react/components/datatables/DatatableContent.tsx new file mode 100644 index 000000000..2f645454c --- /dev/null +++ b/app/react/components/datatables/DatatableContent.tsx @@ -0,0 +1,62 @@ +import { Row, TableInstance, TableRowProps } from 'react-table'; + +import { Table } from './Table'; + +interface Props> { + tableInstance: TableInstance; + renderRow(row: Row, rowProps: TableRowProps): React.ReactNode; + onSortChange?(colId: string, desc: boolean): void; + isLoading?: boolean; + emptyContentLabel?: string; +} + +export function DatatableContent>({ + tableInstance, + renderRow, + onSortChange, + isLoading, + emptyContentLabel, +}: Props) { + const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } = + tableInstance; + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + return ( + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={onSortChange} + /> + ); + })} + + + + rows={page} + isLoading={isLoading} + prepareRow={prepareRow} + emptyContent={emptyContentLabel} + renderRow={renderRow} + /> + +
+ ); +} diff --git a/app/react/components/datatables/DatatableFooter.tsx b/app/react/components/datatables/DatatableFooter.tsx new file mode 100644 index 000000000..056772c0d --- /dev/null +++ b/app/react/components/datatables/DatatableFooter.tsx @@ -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 ( + + + onPageChange(page - 1)} + totalCount={totalCount} + onPageLimitChange={onPageSizeChange} + /> + + ); +} diff --git a/app/react/components/datatables/DatatableHeader.tsx b/app/react/components/datatables/DatatableHeader.tsx new file mode 100644 index 000000000..a3a6f1265 --- /dev/null +++ b/app/react/components/datatables/DatatableHeader.tsx @@ -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 ( + + + {renderTableActions && ( + {renderTableActions()} + )} + + {!!renderTableSettings && renderTableSettings()} + + + ); +} diff --git a/app/react/components/datatables/ExpandableDatatable.tsx b/app/react/components/datatables/ExpandableDatatable.tsx new file mode 100644 index 000000000..3dcf7634c --- /dev/null +++ b/app/react/components/datatables/ExpandableDatatable.tsx @@ -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> + extends Omit, 'renderRow' | 'expandable'> { + renderSubRow(row: Row): ReactNode; +} + +export function ExpandableDatatable>({ + renderSubRow, + ...props +}: Props) { + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + expandable + renderRow={(row, { key, className, role, style }) => ( + + key={key} + row={row} + className={className} + role={role} + style={style} + renderSubRow={renderSubRow} + /> + )} + /> + ); +} diff --git a/app/react/components/datatables/ExpandableDatatableRow.tsx b/app/react/components/datatables/ExpandableDatatableRow.tsx new file mode 100644 index 000000000..7a945075d --- /dev/null +++ b/app/react/components/datatables/ExpandableDatatableRow.tsx @@ -0,0 +1,41 @@ +import { CSSProperties, ReactNode } from 'react'; +import { Row } from 'react-table'; + +import { TableRow } from './TableRow'; + +interface Props> { + row: Row; + className?: string; + role?: string; + style?: CSSProperties; + disableSelect?: boolean; + renderSubRow(row: Row): ReactNode; +} + +export function ExpandableDatatableTableRow>({ + row, + className, + role, + style, + disableSelect, + renderSubRow, +}: Props) { + return ( + <> + + cells={row.cells} + className={className} + role={role} + style={style} + /> + {row.isExpanded && ( + + {!disableSelect && } + + {renderSubRow(row)} + + + )} + + ); +} diff --git a/app/react/components/datatables/NestedDatatable.tsx b/app/react/components/datatables/NestedDatatable.tsx new file mode 100644 index 000000000..d7fa50039 --- /dev/null +++ b/app/react/components/datatables/NestedDatatable.tsx @@ -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> { + dataset: D[]; + columns: readonly Column[]; + + getRowId?(row: D): string; + emptyContentLabel?: string; + initialTableState?: Partial>; + isLoading?: boolean; + defaultSortBy?: string; +} + +export function NestedDatatable>({ + columns, + dataset, + getRowId = defaultGetRowId, + emptyContentLabel, + initialTableState = {}, + isLoading, + defaultSortBy, +}: Props) { + const tableInstance = useTable( + { + defaultCanFilter: false, + columns, + data: dataset, + filterTypes: { multiple }, + initialState: { + sortBy: defaultSortBy ? [{ id: defaultSortBy, desc: true }] : [], + ...initialTableState, + }, + autoResetSelectedRows: false, + getRowId, + }, + useFilters, + useSortBy, + usePagination + ); + + return ( + + + + tableInstance={tableInstance} + isLoading={isLoading} + emptyContentLabel={emptyContentLabel} + renderRow={(row, { key, className, role, style }) => ( + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + )} + /> + + + ); +} diff --git a/app/react/components/datatables/InnerDatatable.css b/app/react/components/datatables/NestedTable.css similarity index 100% rename from app/react/components/datatables/InnerDatatable.css rename to app/react/components/datatables/NestedTable.css diff --git a/app/react/components/datatables/InnerDatatable.tsx b/app/react/components/datatables/NestedTable.tsx similarity index 50% rename from app/react/components/datatables/InnerDatatable.tsx rename to app/react/components/datatables/NestedTable.tsx index e0be535da..54a2f6e35 100644 --- a/app/react/components/datatables/InnerDatatable.tsx +++ b/app/react/components/datatables/NestedTable.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from 'react'; -import './InnerDatatable.css'; +import './NestedTable.css'; -export function InnerDatatable({ children }: PropsWithChildren) { +export function NestedTable({ children }: PropsWithChildren) { return
{children}
; } diff --git a/app/react/components/datatables/QuickActionsSettings.tsx b/app/react/components/datatables/QuickActionsSettings.tsx index be7fc03e0..4a538eae1 100644 --- a/app/react/components/datatables/QuickActionsSettings.tsx +++ b/app/react/components/datatables/QuickActionsSettings.tsx @@ -5,7 +5,7 @@ import { import { Checkbox } from '@@/form-components/Checkbox'; -import { useTableSettings } from './useZustandTableSettings'; +import { useTableSettings } from './useTableSettings'; export interface Action { id: QuickAction; @@ -17,7 +17,7 @@ interface Props { } export function QuickActionsSettings({ actions }: Props) { - const { settings } = + const settings = useTableSettings>(); return ( diff --git a/app/react/components/datatables/Table.tsx b/app/react/components/datatables/Table.tsx index 28b856253..4ff490486 100644 --- a/app/react/components/datatables/Table.tsx +++ b/app/react/components/datatables/Table.tsx @@ -35,6 +35,8 @@ function MainComponent({ ); } +MainComponent.displayName = 'Table'; + interface SubComponents { Container: typeof TableContainer; Actions: typeof TableActions; diff --git a/app/react/components/datatables/TableContainer.tsx b/app/react/components/datatables/TableContainer.tsx index 76ae3cef0..e4a3a676a 100644 --- a/app/react/components/datatables/TableContainer.tsx +++ b/app/react/components/datatables/TableContainer.tsx @@ -2,12 +2,28 @@ import { PropsWithChildren } from 'react'; import { Widget, WidgetBody } from '@@/Widget'; -export function TableContainer({ children }: PropsWithChildren) { +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) { + if (noWidget) { + return
{children}
; + } + return ( -
- - {children} - +
+
+
+ + {children} + +
+
); } diff --git a/app/react/components/datatables/TableTitle.tsx b/app/react/components/datatables/TableTitle.tsx index e9a9199e6..1144abfd6 100644 --- a/app/react/components/datatables/TableTitle.tsx +++ b/app/react/components/datatables/TableTitle.tsx @@ -6,7 +6,7 @@ interface Props { icon?: ReactNode | ComponentType; featherIcon?: boolean; label: string; - description?: JSX.Element; + description?: ReactNode; } export function TableTitle({ @@ -34,7 +34,7 @@ export function TableTitle({
{children} - {description && description} + {description} ); } diff --git a/app/react/components/datatables/defaultGetRowId.ts b/app/react/components/datatables/defaultGetRowId.ts new file mode 100644 index 000000000..dbde4b085 --- /dev/null +++ b/app/react/components/datatables/defaultGetRowId.ts @@ -0,0 +1,17 @@ +export function defaultGetRowId>( + 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 ''; +} diff --git a/app/react/components/datatables/emptyReactTablePlugin.ts b/app/react/components/datatables/emptyReactTablePlugin.ts new file mode 100644 index 000000000..377ecc7a9 --- /dev/null +++ b/app/react/components/datatables/emptyReactTablePlugin.ts @@ -0,0 +1,3 @@ +export function emptyPlugin() {} + +emptyPlugin.pluginName = 'emptyPlugin'; diff --git a/app/react/components/datatables/expand-column.tsx b/app/react/components/datatables/expand-column.tsx new file mode 100644 index 000000000..816c5734a --- /dev/null +++ b/app/react/components/datatables/expand-column.tsx @@ -0,0 +1,49 @@ +import { ChevronDown, ChevronUp } from 'react-feather'; +import { CellProps, Column, HeaderProps } from 'react-table'; + +import { Button } from '@@/buttons'; + +export function buildExpandColumn>( + isExpandable: (item: T) => boolean +): Column { + return { + id: 'expand', + Header: ({ + filteredFlatRows, + getToggleAllRowsExpandedProps, + isAllRowsExpanded, + }: HeaderProps) => { + const hasExpandableItems = filteredFlatRows.some((item) => + isExpandable(item.original) + ); + + return ( + hasExpandableItems && ( + - - - - ( + <> + - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); + Associate Device + - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - onSortChange={handleSortChange} - /> - ); - })} - - - - ( - - )} - /> - -
- - - - gotoPage(p - 1)} - totalCount={totalCount} - onPageLimitChange={handlePageLimitChange} - /> - - - - + {licenseOverused ? ( +
+ + Associating devices is disabled as your node count exceeds your + license limit + +
+ ) : null} + + )} + isLoading={isLoading} + totalCount={totalCount} + /> ); - 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[]) { associateMutation.mutate( devices.map((d) => d.Id), diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts new file mode 100644 index 000000000..8a5078247 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts @@ -0,0 +1,24 @@ +import { Column } from 'react-table'; + +import { Environment } from '@/react/portainer/environments/types'; + +export const columns: readonly Column[] = [ + { + 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; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/index.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/index.tsx new file mode 100644 index 000000000..08d7ecde2 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/index.tsx @@ -0,0 +1 @@ +export { Datatable } from './Datatable'; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/types.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/types.ts deleted file mode 100644 index 02692a4a3..000000000 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - PaginationTableSettings, - SortableTableSettings, -} from '@@/datatables/types-old'; - -export interface TableSettings - extends SortableTableSettings, - PaginationTableSettings {} diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx index 627021e7c..37a7f2883 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx @@ -5,14 +5,11 @@ import { EdgeTypes } from '@/react/portainer/environments/types'; import { InformationPanel } from '@@/InformationPanel'; import { TextTip } from '@@/Tip/TextTip'; -import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { PageHeader } from '@@/PageHeader'; -import { DataTable } from './Datatable/Datatable'; -import { TableSettings } from './Datatable/types'; +import { Datatable } from './Datatable'; export function WaitingRoomView() { - const storageKey = 'edge-devices-waiting-room'; const router = useRouter(); const { environments, isLoading, totalCount } = useEnvironmentList({ edgeDevice: true, @@ -44,17 +41,11 @@ export function WaitingRoomView() { - - defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }} - storageKey={storageKey} - > - - + ); } diff --git a/app/react/edge/edge-devices/WaitingRoomView/queries.ts b/app/react/edge/edge-devices/WaitingRoomView/queries.ts index a930de9af..ddf9a866a 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/queries.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/queries.ts @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { useIntegratedLicenseInfo } from '@/portainer/license-management/use-license.service'; export function useAssociateDeviceMutation() { const queryClient = useQueryClient(); @@ -31,3 +32,11 @@ async function associateDevice(environmentId: EnvironmentId) { 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; +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx index 2d71df02c..ef8d7dca6 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx @@ -1,17 +1,21 @@ import { useEffect, useState } from 'react'; +import { Database } from 'react-feather'; +import { useStore } from 'zustand'; import { confirmWarn } from '@/portainer/services/modal.service/confirm'; import { Datatable } from '@@/datatables'; import { Button, ButtonGroup } from '@@/buttons'; import { Icon } from '@@/Icon'; +import { useSearchBarState } from '@@/datatables/SearchBar'; +import { createPersistedStore } from '@@/datatables/types'; import { IngressControllerClassMap } from '../types'; import { useColumns } from './columns'; -import { createStore } from './datatable-store'; -const useStore = createStore('ingressClasses'); +const storageKey = 'ingressClasses'; +const settingsStore = createPersistedStore(storageKey); interface Props { onChangeControllers: ( @@ -34,10 +38,11 @@ export function IngressClassDatatable({ noIngressControllerLabel, view, }: Props) { + const settings = useStore(settingsStore); + const [search, setSearch] = useSearchBarState(storageKey); const [ingControllerFormValues, setIngControllerFormValues] = useState( ingressControllers || [] ); - const settings = useStore(); const columns = useColumns(); useEffect(() => { @@ -76,19 +81,20 @@ export function IngressClassDatatable({
`${row.Name}-${row.ClassName}-${row.Type}`} renderTableActions={(selectedRows) => renderTableActions(selectedRows)} description={renderIngressClassDescription()} + initialPageSize={settings.pageSize} + onPageSizeChange={settings.setPageSize} + initialSortBy={settings.sortBy} + onSortByChange={settings.setSortBy} + searchValue={search} + onSearchChange={setSearch} />
); diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/datatable-store.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/datatable-store.ts deleted file mode 100644 index ee4ac76c1..000000000 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/datatable-store.ts +++ /dev/null @@ -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()( - persist( - (set) => ({ - ...sortableSettings(set), - ...paginationSettings(set), - }), - { - name: keyBuilder(storageKey), - } - ) - ); -} diff --git a/app/react/kubernetes/ingresses/IngressDatatable/IngressDataTable.tsx b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx similarity index 81% rename from app/react/kubernetes/ingresses/IngressDatatable/IngressDataTable.tsx rename to app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx index 901714a88..2667980f3 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/IngressDataTable.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx @@ -1,5 +1,6 @@ import { Plus, Trash2 } from 'react-feather'; import { useRouter } from '@uirouter/react'; +import { useStore } from 'zustand'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; @@ -9,11 +10,12 @@ import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm import { Datatable } from '@@/datatables'; import { Button } from '@@/buttons'; import { Link } from '@@/Link'; +import { createPersistedStore } from '@@/datatables/types'; +import { useSearchBarState } from '@@/datatables/SearchBar'; import { DeleteIngressesRequest, Ingress } from '../types'; import { useDeleteIngresses, useIngresses } from '../queries'; -import { createStore } from './datatable-store'; import { useColumns } from './columns'; import '../style.css'; @@ -22,10 +24,11 @@ interface SelectedIngress { Namespace: string; Name: string; } +const storageKey = 'ingressClassesNameSpace'; -const useStore = createStore('ingresses'); +const settingsStore = createPersistedStore(storageKey); -export function IngressDataTable() { +export function IngressDatatable() { const environmentId = useEnvironmentId(); const nsResult = useNamespaces(environmentId); @@ -34,28 +37,30 @@ export function IngressDataTable() { Object.keys(nsResult?.data || {}) ); - const settings = useStore(); - const columns = useColumns(); const deleteIngressesMutation = useDeleteIngresses(); + const settings = useStore(settingsStore); + const [search, setSearch] = useSearchBarState(storageKey); const router = useRouter(); return ( row.Name + row.Type + row.Namespace} renderTableActions={tableActions} 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" color="dangerlight" disabled={selectedFlatRows.length === 0} - onClick={() => - handleRemoveClick( - selectedFlatRows.map((row) => ({ - Name: row.Name, - Namespace: row.Namespace, - })) - ) - } + onClick={() => handleRemoveClick(selectedFlatRows)} icon={Trash2} > Remove diff --git a/app/react/kubernetes/ingresses/IngressDatatable/datatable-store.ts b/app/react/kubernetes/ingresses/IngressDatatable/datatable-store.ts deleted file mode 100644 index a90b92fe4..000000000 --- a/app/react/kubernetes/ingresses/IngressDatatable/datatable-store.ts +++ /dev/null @@ -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()( - persist( - (set) => ({ - ...sortableSettings(set), - ...paginationSettings(set), - }), - { - name: keyBuilder(storageKey), - } - ) - ); -} diff --git a/app/react/kubernetes/ingresses/IngressDatatable/index.tsx b/app/react/kubernetes/ingresses/IngressDatatable/index.tsx index 555c32c30..4ccf540d3 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/index.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/index.tsx @@ -1,6 +1,6 @@ import { PageHeader } from '@@/PageHeader'; -import { IngressDataTable } from './IngressDataTable'; +import { IngressDatatable } from './IngressDatatable'; export function IngressesDatatableView() { return ( @@ -14,7 +14,7 @@ export function IngressesDatatableView() { ]} reload /> - + ); } diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx index e699f336c..457ffb183 100644 --- a/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx @@ -1,28 +1,10 @@ -import { Fragment, useEffect } from 'react'; -import { - useFilters, - useGlobalFilter, - usePagination, - useSortBy, - useTable, -} from 'react-table'; +import { useStore } from 'zustand'; import { NomadEvent } from '@/react/nomad/types'; -import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; -import { PaginationControls } from '@@/PaginationControls'; -import { - Table, - 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 { Datatable } from '@@/datatables'; +import { useSearchBarState } from '@@/datatables/SearchBar'; +import { createPersistedStore } from '@@/datatables/types'; import { useColumns } from './columns'; @@ -31,133 +13,31 @@ export interface EventsDatatableProps { isLoading: boolean; } -export interface EventsTableSettings { - autoRefreshRate: number; - pageSize: number; - sortBy: { id: string; desc: boolean }; -} +const storageKey = 'events'; + +const settingsStore = createPersistedStore(storageKey, 'Date'); export function EventsDatatable({ data, isLoading }: EventsDatatableProps) { - const { settings, setTableSettings } = - useTableSettings(); - const [searchBarValue, setSearchBarValue] = useSearchBarState('events'); const columns = useColumns(); - const debouncedSearchValue = useDebouncedValue(searchBarValue); - - const { - getTableProps, - getTableBodyProps, - headerGroups, - page, - prepareRow, - gotoPage, - setPageSize, - setGlobalFilter, - state: { pageIndex, pageSize }, - } = useTable( - { - 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(); + const settings = useStore(settingsStore); + const [search, setSearch] = useSearchBarState(storageKey); return ( - - - - - - - - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); - - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - onSortChange={handleSortChange} - /> - ); - })} - - - ( - - - cells={row.cells} - key={key} - className={className} - role={role} - style={style} - /> - - )} - /> - -
- - - gotoPage(p - 1)} - totalCount={data.length} - onPageLimitChange={handlePageSizeChange} - /> - -
+ `${row.Date}-${row.Message}-${row.Type}`} + disableSelect + /> ); - - 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 }, - })); - } } diff --git a/app/react/nomad/jobs/EventsView/EventsView.tsx b/app/react/nomad/jobs/EventsView/EventsView.tsx index ec5eb59b2..3f33e1e82 100644 --- a/app/react/nomad/jobs/EventsView/EventsView.tsx +++ b/app/react/nomad/jobs/EventsView/EventsView.tsx @@ -1,9 +1,7 @@ import { useCurrentStateAndParams } from '@uirouter/react'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { NomadEventsList } from '@/react/nomad/types'; -import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { PageHeader } from '@@/PageHeader'; import { EventsDatatable } from './EventsDatatable'; @@ -27,14 +25,8 @@ export function EventsView() { { label: 'Events' }, ]; - const defaultSettings = { - pageSize: 10, - sortBy: {}, - }; - return ( <> - {/* header */} -
-
- - {/* events table */} - - -
-
+ ); } diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx index ed946a1bb..18fd12263 100644 --- a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx @@ -1,40 +1,16 @@ -import { Fragment, useEffect } from 'react'; -import { - useExpanded, - useFilters, - useGlobalFilter, - usePagination, - useSortBy, - useTable, -} from 'react-table'; -import { useRowSelectColumn } from '@lineup-lite/hooks'; +import { useStore } from 'zustand'; +import { Clock } from 'react-feather'; 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 { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; +import { TableSettingsMenu } from '@@/datatables'; +import { useSearchBarState } from '@@/datatables/SearchBar'; -import { JobsTableSettings } from './types'; import { TasksDatatable } from './TasksDatatable'; -import { useColumns } from './columns'; +import { columns } from './columns'; +import { createStore } from './datatable-store'; import { JobsDatatableSettings } from './JobsDatatableSettings'; export interface JobsDatatableProps { @@ -43,162 +19,39 @@ export interface JobsDatatableProps { isLoading?: boolean; } +const storageKey = 'jobs'; +const settingsStore = createStore(storageKey); + export function JobsDatatable({ jobs, refreshData, isLoading, }: JobsDatatableProps) { - const { settings, setTableSettings } = useTableSettings(); - const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs'); - const columns = useColumns(); - const debouncedSearchValue = useDebouncedValue(searchBarValue); + const [search, setSearch] = useSearchBarState(storageKey); + const settings = useStore(settingsStore); useRepeater(settings.autoRefreshRate, refreshData); - const { - getTableProps, - getTableBodyProps, - headerGroups, - page, - prepareRow, - selectedFlatRows, - gotoPage, - setPageSize, - setGlobalFilter, - state: { pageIndex, pageSize }, - } = useTable( - { - 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 ( - - - - - - - - - - - - - - - - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); - - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - onSortChange={handleSortChange} - /> - ); - })} - - - ( - - - cells={row.cells} - key={key} - className={className} - role={role} - style={style} - /> - - {row.isExpanded && ( - - - - )} - - )} - /> - -
- - -
- - - - gotoPage(p - 1)} - totalCount={jobs.length} - onPageLimitChange={handlePageSizeChange} - /> - -
+ + dataset={jobs} + columns={columns} + initialPageSize={settings.pageSize} + onPageSizeChange={settings.setPageSize} + initialSortBy={settings.sortBy} + onSortByChange={settings.setSortBy} + searchValue={search} + onSearchChange={setSearch} + title="Nomad Jobs" + titleIcon={Clock} + disableSelect + emptyContentLabel="No jobs found" + renderSubRow={(row) => } + isLoading={isLoading} + renderTableSettings={() => ( + + + + )} + /> ); - - 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 }, - })); - } } diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx index 8f0c3bcb1..14e6d2924 100644 --- a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx @@ -1,11 +1,12 @@ import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; -import { useTableSettings } from '@@/datatables/useTableSettings'; -import { JobsTableSettings } from './types'; +import { TableSettings } from './types'; -export function JobsDatatableSettings() { - const { settings, setTableSettings } = useTableSettings(); +interface Props { + settings: TableSettings; +} +export function JobsDatatableSettings({ settings }: Props) { return ( ( - { - columns, - data, - initialState: { - sortBy: [sortBy], - }, - }, - useFilters, - useSortBy, - usePagination - ); - - const tableProps = getTableProps(); - const tbodyProps = getTableBodyProps(); return ( - - - - - {headerGroups.map((headerGroup) => { - const { key, className, role, style } = - headerGroup.getHeaderGroupProps(); - - return ( - - key={key} - className={className} - role={role} - style={style} - headers={headerGroup.headers} - onSortChange={handleSortChange} - /> - ); - })} - - - {data.length > 0 ? ( - page.map((row) => { - prepareRow(row); - const { key, className, role, style } = row.getRowProps(); - - return ( - - key={key} - cells={row.cells} - className={className} - role={role} - style={style} - /> - ); - }) - ) : ( - - - - )} - -
- no tasks -
-
-
+ ); - - function handleSortChange(id: string, desc: boolean) { - setSortBy({ id, desc }); - } } diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx index ebe41d2e3..2df4ec0aa 100644 --- a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx @@ -3,6 +3,7 @@ import { CellProps, Column } from 'react-table'; import { Task } from '@/react/nomad/types'; import { Link } from '@@/Link'; +import { Icon } from '@@/Icon'; export const actions: Column = { Header: 'Task Actions', @@ -25,7 +26,7 @@ export function ActionsCell({ row }: CellProps) { }; return ( -
+
{/* events */} ) { title="Events" className="space-right" > -