1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-09 07:45:22 +02:00

refactor(containers): replace containers datatable with react component [EE-1815] (#6059)

This commit is contained in:
Chaim Lev-Ari 2022-01-04 14:16:09 +02:00 committed by GitHub
parent 65821aaccc
commit 07e7fbd270
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 3614 additions and 1084 deletions

View file

@ -26,6 +26,6 @@ function sizeClass(size: Size | undefined) {
case 'large':
return 'btn-group-lg';
default:
return 'btn-group-sm';
return '';
}
}

View file

@ -0,0 +1,65 @@
import clsx from 'clsx';
import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { ColumnInstance } from 'react-table';
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
import type { DockerContainer } from '@/docker/containers/types';
import { useTableContext } from './TableContainer';
interface Props {
columns: ColumnInstance<DockerContainer>[];
onChange: (value: string[]) => void;
value: string[];
}
export function ColumnVisibilityMenu({ columns, onChange, value }: Props) {
useTableContext();
return (
<Menu className="setting">
{({ isExpanded }) => (
<>
<MenuButton
className={clsx('table-setting-menu-btn', {
'setting-active': isExpanded,
})}
>
<i className="fa fa-columns" aria-hidden="true" /> Columns
</MenuButton>
<MenuList>
<div className="tableMenu">
<div className="menuHeader">Show / Hide Columns</div>
<div className="menuContent">
{columns.map((column) => (
<div key={column.id}>
<Checkbox
checked={column.isVisible}
label={column.Header as string}
id={`visibility_${column.id}`}
onChange={(e) =>
handleChangeColumnVisibility(
column.id,
e.target.checked
)
}
/>
</div>
))}
</div>
</div>
</MenuList>
</>
)}
</Menu>
);
function handleChangeColumnVisibility(colId: string, visible: boolean) {
if (visible) {
onChange(value.filter((id) => id !== colId));
return;
}
onChange([...value, colId]);
}
}

View file

@ -0,0 +1,93 @@
import clsx from 'clsx';
import { useMemo } from 'react';
import { Menu, MenuButton, MenuPopover } from '@reach/menu-button';
import { ColumnInstance } from 'react-table';
export function DefaultFilter({
column: { filterValue, setFilter, preFilteredRows, id },
}: {
column: ColumnInstance;
}) {
const options = useMemo(() => {
const options = new Set<string>();
preFilteredRows.forEach((row) => {
options.add(row.values[id]);
});
return Array.from(options);
}, [id, preFilteredRows]);
return (
<MultipleSelectionFilter
options={options}
filterKey={id}
value={filterValue}
onChange={setFilter}
/>
);
}
interface MultipleSelectionFilterProps {
options: string[];
value: string[];
filterKey: string;
onChange: (value: string[]) => void;
}
function MultipleSelectionFilter({
options,
value = [],
filterKey,
onChange,
}: MultipleSelectionFilterProps) {
const enabled = value.length > 0;
return (
<div>
<Menu>
<MenuButton
className={clsx('table-filter', { 'filter-active': enabled })}
>
Filter
<i
className={clsx(
'fa',
'space-left',
enabled ? 'fa-check' : 'fa-filter'
)}
aria-hidden="true"
/>
</MenuButton>
<MenuPopover className="dropdown-menu">
<div className="tableMenu">
<div className="menuHeader">Filter by state</div>
<div className="menuContent">
{options.map((option, index) => (
<div className="md-checkbox" key={index}>
<input
id={`filter_${filterKey}_${index}`}
type="checkbox"
checked={value.includes(option)}
onChange={() => handleChange(option)}
/>
<label htmlFor={`filter_${filterKey}_${index}`}>
{option}
</label>
</div>
))}
</div>
</div>
</MenuPopover>
</Menu>
</div>
);
function handleChange(option: string) {
if (value.includes(option)) {
onChange(value.filter((o) => o !== option));
return;
}
onChange([...value, option]);
}
}

View file

@ -0,0 +1,49 @@
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
import { useTableSettings } from './useTableSettings';
export interface Action {
id: string;
label: string;
}
interface Props {
actions: Action[];
}
export interface QuickActionsSettingsType {
hiddenQuickActions: string[];
}
export function QuickActionsSettings({ actions }: Props) {
const { settings, setTableSettings } = useTableSettings<
QuickActionsSettingsType
>();
return (
<>
{actions.map(({ id, label }) => (
<Checkbox
key={id}
label={label}
id={`quick-actions-${id}`}
checked={!settings.hiddenQuickActions.includes(id)}
onChange={(e) => toggleAction(id, e.target.checked)}
/>
))}
</>
);
function toggleAction(key: string, value: boolean) {
setTableSettings(({ hiddenQuickActions = [], ...settings }) => ({
...settings,
hiddenQuickActions: value
? hiddenQuickActions.filter((id) => id !== key)
: [...hiddenQuickActions, key],
}));
}
}
export function buildAction(id: string, label: string): Action {
return { id, label };
}

View file

@ -0,0 +1,59 @@
import { useContext, createContext, PropsWithChildren } from 'react';
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
interface Props {
autoFocus: boolean;
value: string;
onChange(value: string): void;
}
export function SearchBar({ autoFocus, value, onChange }: Props) {
return (
<div className="searchBar">
<i className="fa fa-search searchIcon" aria-hidden="true" />
<input
autoFocus={autoFocus}
type="text"
className="searchInput"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search..."
/>
</div>
);
}
const SearchBarContext = createContext<
[string, (value: string) => void] | null
>(null);
interface SearchBarProviderProps {
defaultValue?: string;
}
export function SearchBarProvider({
children,
defaultValue = '',
}: PropsWithChildren<SearchBarProviderProps>) {
const [value, setValue] = useLocalStorage(
'datatable_text_filter_containers',
defaultValue,
sessionStorage
);
return (
<SearchBarContext.Provider value={[value, setValue]}>
{children}
</SearchBarContext.Provider>
);
}
export function useSearchBarContext() {
const context = useContext(SearchBarContext);
if (context === null) {
throw new Error('should be used under SearchBarProvider');
}
return context;
}

View file

@ -0,0 +1,9 @@
interface SelectedRowsCountProps {
value: number;
}
export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
return value !== 0 ? (
<div className="infoBar">{value} item(s) selected</div>
) : null;
}

View file

@ -0,0 +1,29 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { TableProps } from 'react-table';
import { useTableContext } from './TableContainer';
export function Table({
children,
className,
role,
style,
}: PropsWithChildren<TableProps>) {
useTableContext();
return (
<div className="table-responsive">
<table
className={clsx(
'table table-hover table-filters nowrap-cells',
className
)}
role={role}
style={style}
>
{children}
</table>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useTableContext } from './TableContainer';
export function TableActions({ children }: PropsWithChildren<unknown>) {
useTableContext();
return <div className="actionBar">{children}</div>;
}

View file

@ -0,0 +1,25 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { Widget, WidgetBody } from '@/portainer/components/widget';
const Context = createContext<null | boolean>(null);
export function useTableContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be nested inside a TableContainer component');
}
}
export function TableContainer({ children }: PropsWithChildren<unknown>) {
return (
<Context.Provider value>
<div className="datatable">
<Widget>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</div>
</Context.Provider>
);
}

View file

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useTableContext } from './TableContainer';
export function TableFooter({ children }: PropsWithChildren<unknown>) {
useTableContext();
return <footer className="footer">{children}</footer>;
}

View file

@ -0,0 +1,5 @@
.sort-icon {
width: 1em;
height: 1em;
display: inline-block;
}

View file

@ -0,0 +1,90 @@
import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { TableHeaderProps } from 'react-table';
import { Button } from '@/portainer/components/Button';
import { useTableContext } from './TableContainer';
import styles from './TableHeaderCell.module.css';
interface Props {
canFilter: boolean;
canSort: boolean;
headerProps: TableHeaderProps;
isSorted: boolean;
isSortedDesc?: boolean;
onSortClick: (desc: boolean) => void;
render: () => ReactNode;
renderFilter: () => ReactNode;
}
export function TableHeaderCell({
headerProps: { className, role, style },
canSort,
render,
onSortClick,
isSorted,
isSortedDesc,
canFilter,
renderFilter,
}: Props) {
useTableContext();
return (
<th role={role} style={style} className={className}>
<SortWrapper
canSort={canSort}
onClick={onSortClick}
isSorted={isSorted}
isSortedDesc={isSortedDesc}
>
{render()}
</SortWrapper>
{canFilter ? renderFilter() : null}
</th>
);
}
interface SortWrapperProps {
canSort: boolean;
isSorted: boolean;
isSortedDesc?: boolean;
onClick: (desc: boolean) => void;
}
function SortWrapper({
canSort,
children,
onClick,
isSorted,
isSortedDesc,
}: PropsWithChildren<SortWrapperProps>) {
if (!canSort) {
return <>{children}</>;
}
return (
<Button
color="link"
type="button"
onClick={() => onClick(!isSortedDesc)}
className="sortable"
>
<span className="sortable-label">{children}</span>
{isSorted ? (
<i
className={clsx(
'fa',
'space-left',
isSortedDesc ? 'fa-sort-alpha-up' : 'fa-sort-alpha-down',
styles.sortIcon
)}
aria-hidden="true"
/>
) : (
<div className={styles.sortIcon} />
)}
</Button>
);
}

View file

@ -0,0 +1,46 @@
import { HeaderGroup, TableHeaderProps } from 'react-table';
import { useTableContext } from './TableContainer';
import { TableHeaderCell } from './TableHeaderCell';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
headers: HeaderGroup<D>[];
onSortChange(colId: string, desc: boolean): void;
}
export function TableHeaderRow<
D extends Record<string, unknown> = Record<string, unknown>
>({
headers,
onSortChange,
className,
role,
style,
}: Props<D> & TableHeaderProps) {
useTableContext();
return (
<tr className={className} role={role} style={style}>
{headers.map((column) => (
<TableHeaderCell
headerProps={{
...column.getHeaderProps({
className: column.className,
}),
}}
key={column.id}
canSort={column.canSort}
onSortClick={(desc) => {
column.toggleSortBy(desc);
onSortChange(column.id, desc);
}}
isSorted={column.isSorted}
isSortedDesc={column.isSortedDesc}
render={() => column.render('Header')}
canFilter={!column.disableFilters}
renderFilter={() => column.render('Filter')}
/>
))}
</tr>
);
}

View file

@ -0,0 +1,35 @@
import { Cell, TableRowProps } from 'react-table';
import { useTableContext } from './TableContainer';
interface Props<D extends Record<string, unknown> = Record<string, unknown>>
extends TableRowProps {
cells: Cell<D>[];
}
export function TableRow<
D extends Record<string, unknown> = Record<string, unknown>
>({ cells, className, role, style }: Props<D>) {
useTableContext();
return (
<tr className={className} role={role} style={style}>
{cells.map((cell) => {
const cellProps = cell.getCellProps({
className: cell.className,
});
return (
<td
className={cellProps.className}
role={cellProps.role}
style={cellProps.style}
key={cellProps.key}
>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
}

View file

@ -0,0 +1,44 @@
import clsx from 'clsx';
import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { PropsWithChildren, ReactNode } from 'react';
import { useTableContext } from './TableContainer';
interface Props {
quickActions: ReactNode;
}
export function TableSettingsMenu({
quickActions,
children,
}: PropsWithChildren<Props>) {
useTableContext();
return (
<Menu className="setting">
{({ isExpanded }) => (
<>
<MenuButton
className={clsx('table-setting-menu-btn', {
'setting-active': isExpanded,
})}
>
<i className="fa fa-cog" aria-hidden="true" /> Settings
</MenuButton>
<MenuList>
<div className="tableMenu">
<div className="menuHeader">Table settings</div>
<div className="menuContent">{children}</div>
{quickActions && (
<div>
<div className="menuHeader">Quick actions</div>
<div className="menuContent">{quickActions}</div>
</div>
)}
</div>
</MenuList>
</>
)}
</Menu>
);
}

View file

@ -0,0 +1,9 @@
.alert-visible {
opacity: 1;
transition: all 250ms linear;
}
.alert-hidden {
opacity: 0;
transition: all 250ms ease-out 2s;
}

View file

@ -0,0 +1,65 @@
import clsx from 'clsx';
import { useState } from 'react';
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
import styles from './TableSettingsMenuAutoRefresh.module.css';
interface Props {
onChange(value: number): void;
value: number;
}
export function TableSettingsMenuAutoRefresh({ onChange, value }: Props) {
const [isCheckVisible, setIsCheckVisible] = useState(false);
const isEnabled = value > 0;
return (
<>
<Checkbox
id="settings-auto-refresh"
label="Auto refresh"
checked={isEnabled}
onChange={(e) => onChange(e.target.checked ? 10 : 0)}
/>
{isEnabled && (
<div>
<label htmlFor="settings_refresh_rate">Refresh rate</label>
<select
id="settings_refresh_rate"
className="small-select"
value={value}
onChange={(e) => handleChange(e.target.value)}
>
<option value={10}>10s</option>
<option value={30}>30s</option>
<option value={60}>1min</option>
<option value={120}>2min</option>
<option value={300}>5min</option>
</select>
<span
className={clsx(
isCheckVisible ? styles.alertVisible : styles.alertHidden,
styles.check
)}
onTransitionEnd={() => setIsCheckVisible(false)}
>
<i
id="refreshRateChange"
className="fa fa-check green-icon"
aria-hidden="true"
style={{ marginTop: '7px' }}
/>
</span>
</div>
)}
</>
);
function handleChange(value: string) {
onChange(Number(value));
setIsCheckVisible(true);
}
}

View file

@ -0,0 +1,27 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { useTableContext } from './TableContainer';
interface Props {
icon: string;
label: string;
}
export function TableTitle({
icon,
label,
children,
}: PropsWithChildren<Props>) {
useTableContext();
return (
<div className="toolBar">
<div className="toolBarTitle">
<i className={clsx('space-right', 'fa', icon)} aria-hidden="true" />
{label}
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useTableContext } from './TableContainer';
export function TableTitleActions({ children }: PropsWithChildren<unknown>) {
useTableContext();
return <div className="settings">{children}</div>;
}

View file

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

View file

@ -0,0 +1,9 @@
export { Table } from './Table';
export { TableActions } from './TableActions';
export { TableTitleActions } from './TableTitleActions';
export { TableHeaderCell } from './TableHeaderCell';
export { TableSettingsMenu } from './TableSettingsMenu';
export { TableTitle } from './TableTitle';
export { TableContainer } from './TableContainer';
export { TableHeaderRow } from './TableHeaderRow';
export { TableRow } from './TableRow';

View file

@ -0,0 +1,42 @@
import { useEffect, useCallback, useState } from 'react';
export function useRepeater(
refreshRate: number,
onRefresh: () => Promise<void>
) {
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const stopRepeater = useCallback(() => {
if (!intervalId) {
return;
}
clearInterval(intervalId);
setIntervalId(null);
}, [intervalId]);
const startRepeater = useCallback(
(refreshRate) => {
if (intervalId) {
return;
}
setIntervalId(
setInterval(async () => {
await onRefresh();
}, refreshRate * 1000)
);
},
[intervalId]
);
useEffect(() => {
if (!refreshRate) {
stopRepeater();
} else {
startRepeater(refreshRate);
}
return stopRepeater;
}, [refreshRate, startRepeater, stopRepeater, intervalId]);
}

View file

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

View file

@ -0,0 +1,77 @@
import { Context, createContext, ReactNode, useContext, useState } from 'react';
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
export 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);
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;
defaults?: T;
storageKey: string;
}
export function TableSettingsProvider<T>({
children,
defaults,
storageKey,
}: ProviderProps<T>) {
const Context = getContextType<T>();
const [storage, setStorage] = useLocalStorage<T>(
keyBuilder(storageKey),
defaults as T
);
const [settings, setTableSettings] = useState(storage);
return (
<Context.Provider value={{ settings, setTableSettings: handleChange }}>
{children}
</Context.Provider>
);
function handleChange(partialSettings: T): void;
function handleChange(mutation: (settings: T) => T): void;
function handleChange(mutation: T | ((settings: T) => T)): void {
setTableSettings((settings) => {
const newTableSettings =
mutation instanceof Function
? mutation(settings)
: { ...settings, ...mutation };
setStorage(newTableSettings);
return newTableSettings;
});
}
function keyBuilder(key: string) {
return `datatable_TableSettings_${key}`;
}
}
function getContextType<T>() {
return (TableSettingsContext as unknown) as Context<
TableSettingsContextInterface<T>
>;
}

View file

@ -126,6 +126,10 @@
color: #767676;
cursor: pointer;
font-size: 12px !important;
background: none;
border: none;
padding: 0;
margin: 0;
}
.widget .widget-body table thead th .filter-active {
@ -252,3 +256,25 @@
transform: scale(0);
width: 8px;
}
.table th.selection,
.table td.selection {
width: 30px;
}
.table th button.sortable {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-link-color);
}
.table th button.sortable:hover .sortable-label {
text-decoration: underline;
}
.datatable .table-setting-menu-btn {
border: none;
background: none;
}

View file

@ -0,0 +1,57 @@
import {
forwardRef,
useRef,
useEffect,
MutableRefObject,
ChangeEventHandler,
HTMLProps,
} from 'react';
interface Props extends HTMLProps<HTMLInputElement> {
checked?: boolean;
indeterminate?: boolean;
title?: string;
label?: string;
id: string;
className?: string;
role?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
}
export const Checkbox = forwardRef<HTMLInputElement, Props>(
(
{ indeterminate, title, label, id, checked, onChange, ...props }: Props,
ref
) => {
const defaultRef = useRef<HTMLInputElement>(null);
let resolvedRef = ref as MutableRefObject<HTMLInputElement | null>;
if (!ref) {
resolvedRef = defaultRef;
}
useEffect(() => {
if (resolvedRef === null || resolvedRef.current === null) {
return;
}
if (typeof indeterminate !== 'undefined') {
resolvedRef.current.indeterminate = indeterminate;
}
}, [resolvedRef, indeterminate]);
return (
<div className="md-checkbox" title={title || label}>
<input
id={id}
type="checkbox"
ref={resolvedRef}
onChange={onChange}
checked={checked}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
<label htmlFor={id}>{label}</label>
</div>
);
}
);

View file

@ -0,0 +1,26 @@
interface Props {
value: number;
onChange(value: number): void;
showAll: boolean;
}
export function ItemsPerPageSelector({ value, onChange, showAll }: Props) {
return (
<span className="limitSelector">
<span className="space-right">Items per page</span>
<select
className="form-control"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
data-cy="paginationSelect"
>
{showAll ? <option value={Number.MAX_SAFE_INTEGER}>All</option> : null}
{[10, 25, 50, 100].map((v) => (
<option value={v} key={v}>
{v}
</option>
))}
</select>
</span>
);
}

View file

@ -0,0 +1,29 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
interface Props {
active?: boolean;
children: ReactNode;
disabled?: boolean;
onPageChange(page: number): void;
page: number | '...';
}
export function PageButton({
children,
page,
disabled,
active,
onPageChange,
}: Props) {
return (
<li className={clsx({ disabled, active })}>
<button
type="button"
onClick={() => typeof page === 'number' && onPageChange(page)}
>
{children}
</button>
</li>
);
}

View file

@ -0,0 +1,87 @@
import { generatePagesArray } from './generatePagesArray';
import { PageButton } from './PageButton';
interface Props {
boundaryLinks?: boolean;
currentPage: number;
directionLinks?: boolean;
itemsPerPage: number;
onPageChange(page: number): void;
totalCount: number;
maxSize: number;
}
export function PageSelector({
currentPage,
totalCount,
itemsPerPage,
onPageChange,
maxSize = 5,
directionLinks = true,
boundaryLinks = false,
}: Props) {
const pages = generatePagesArray(
currentPage,
totalCount,
itemsPerPage,
maxSize
);
const last = pages[pages.length - 1];
if (pages.length <= 1) {
return null;
}
return (
<ul className="pagination">
{boundaryLinks ? (
<PageButton
onPageChange={onPageChange}
page={1}
disabled={currentPage === 1}
>
&laquo;
</PageButton>
) : null}
{directionLinks ? (
<PageButton
onPageChange={onPageChange}
page={currentPage - 1}
disabled={currentPage === 1}
>
&lsaquo;
</PageButton>
) : null}
{pages.map((pageNumber, index) => (
<PageButton
onPageChange={onPageChange}
page={pageNumber}
disabled={pageNumber === '...'}
active={currentPage === pageNumber}
key={index}
>
{pageNumber}
</PageButton>
))}
{directionLinks ? (
<PageButton
onPageChange={onPageChange}
page={currentPage + 1}
disabled={currentPage === last}
>
&rsaquo;
</PageButton>
) : null}
{boundaryLinks ? (
<PageButton
disabled={currentPage === last}
onPageChange={onPageChange}
page={last}
>
&raquo;
</PageButton>
) : null}
</ul>
);
}

View file

@ -0,0 +1,46 @@
import { ItemsPerPageSelector } from './ItemsPerPageSelector';
import { PageSelector } from './PageSelector';
interface Props {
onPageChange(page: number): void;
onPageLimitChange(value: number): void;
page: number;
pageLimit: number;
showAll: boolean;
totalCount: number;
}
export function PaginationControls({
pageLimit,
page,
onPageLimitChange,
showAll,
onPageChange,
totalCount,
}: Props) {
return (
<div className="paginationControls">
<form className="form-inline">
<ItemsPerPageSelector
value={pageLimit}
onChange={handlePageLimitChange}
showAll={showAll}
/>
{pageLimit !== 0 && (
<PageSelector
maxSize={5}
onPageChange={onPageChange}
currentPage={page}
itemsPerPage={pageLimit}
totalCount={totalCount}
/>
)}
</form>
</div>
);
function handlePageLimitChange(value: number) {
onPageLimitChange(value);
onPageChange(1);
}
}

View file

@ -0,0 +1,37 @@
/**
* Given the position in the sequence of pagination links, figure out what page number corresponds to that position.
*
* @param position
* @param currentPage
* @param paginationRange
* @param totalPages
*/
export function calculatePageNumber(
position: number,
currentPage: number,
paginationRange: number,
totalPages: number
) {
const halfWay = Math.ceil(paginationRange / 2);
if (position === paginationRange) {
return totalPages;
}
if (position === 1) {
return position;
}
if (paginationRange < totalPages) {
if (totalPages - halfWay < currentPage) {
return totalPages - paginationRange + position;
}
if (halfWay < currentPage) {
return currentPage - halfWay + position;
}
return position;
}
return position;
}

View file

@ -0,0 +1,55 @@
import { calculatePageNumber } from './calculatePageNumber';
export /**
* Generate an array of page numbers (or the '...' string) which is used in an ng-repeat to generate the
* links used in pagination
*
* @param currentPage
* @param rowsPerPage
* @param paginationRange
* @param collectionLength
* @returns {Array}
*/
function generatePagesArray(
currentPage: number,
collectionLength: number,
rowsPerPage: number,
paginationRange: number
): (number | '...')[] {
const pages: (number | '...')[] = [];
const totalPages = Math.ceil(collectionLength / rowsPerPage);
const halfWay = Math.ceil(paginationRange / 2);
let position;
if (currentPage <= halfWay) {
position = 'start';
} else if (totalPages - halfWay < currentPage) {
position = 'end';
} else {
position = 'middle';
}
const ellipsesNeeded = paginationRange < totalPages;
for (let i = 1; i <= totalPages && i <= paginationRange; i += 1) {
const pageNumber = calculatePageNumber(
i,
currentPage,
paginationRange,
totalPages
);
const openingEllipsesNeeded =
i === 2 && (position === 'middle' || position === 'end');
const closingEllipsesNeeded =
i === paginationRange - 1 &&
(position === 'middle' || position === 'start');
if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
pages.push('...');
} else {
pages.push(pageNumber);
}
}
return pages;
}

View file

@ -0,0 +1,3 @@
import './pagination-controls.css';
export { PaginationControls } from './PaginationControls';

View file

@ -0,0 +1,72 @@
.pagination-controls {
margin-left: 10px;
}
.paginationControls form.form-inline {
display: flex;
}
.pagination > li:first-child > button {
margin-left: 0;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.pagination > .disabled > span,
.pagination > .disabled > span:hover,
.pagination > .disabled > span:focus,
.pagination > .disabled > button,
.pagination > .disabled > button:hover,
.pagination > .disabled > button:focus,
.pagination > .disabled > a,
.pagination > .disabled > a:hover,
.pagination > .disabled > a:focus {
color: var(--text-pagination-color);
background-color: var(--bg-pagination-color);
border-color: var(--border-pagination-color);
}
.pagination > li > button {
position: relative;
float: left;
padding: 6px 12px;
margin-left: -1px !important;
line-height: 1.42857143;
text-decoration: none;
border: 1px solid #ddd;
}
.pagination > li > a,
.pagination > li > button,
.pagination > li > span {
background-color: var(--bg-pagination-span-color);
border-color: var(--border-pagination-span-color);
color: var(--text-pagination-span-color);
}
.pagination > li > a:hover,
.pagination > li > button:hover,
.pagination > li > span:hover,
.pagination > li > a:focus,
.pagination > li > button:focus,
.pagination > li > span:focus {
background-color: var(--bg-pagination-hover-color);
border-color: var(--border-pagination-hover-color);
color: var(--text-pagination-span-hover-color);
}
.pagination > .active > a,
.pagination > .active > span,
.pagination > .active > button,
.pagination > .active > a:hover,
.pagination > .active > span:hover,
.pagination > .active > button:hover,
.pagination > .active > a:focus,
.pagination > .active > span:focus,
.pagination > .active > button:focus {
z-index: 3;
color: #fff;
cursor: default;
background-color: var(--text-pagination-span-color);
border-color: var(--text-pagination-span-color);
}

View file

@ -0,0 +1,12 @@
export type EnvironmentId = number;
export enum EnvironmentStatus {
Up = 1,
Down = 2,
}
export interface Environment {
Id: EnvironmentId;
Status: EnvironmentStatus;
PublicURL: string;
}

View file

@ -0,0 +1,27 @@
import { createContext, ReactNode, useContext } from 'react';
import type { Environment } from './types';
const EnvironmentContext = createContext<Environment | null>(null);
export function useEnvironment() {
const context = useContext(EnvironmentContext);
if (context === null) {
throw new Error('must be nested under EnvironmentProvider');
}
return context;
}
interface Props {
children: ReactNode;
environment: Environment;
}
export function EnvironmentProvider({ children, environment }: Props) {
return (
<EnvironmentContext.Provider value={environment}>
{children}
</EnvironmentContext.Provider>
);
}

View file

@ -1,6 +0,0 @@
export default class PortainerError {
constructor(msg, err) {
this.msg = msg;
this.err = err;
}
}

8
app/portainer/error.ts Normal file
View file

@ -0,0 +1,8 @@
export default class PortainerError extends Error {
err?: Error;
constructor(msg: string, err?: Error) {
super(msg);
this.err = err;
}
}

View file

@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View file

@ -71,13 +71,10 @@ interface AuthorizedProps {
children: ReactNode;
}
export function Authorized({
authorizations,
children,
}: AuthorizedProps): ReactNode {
export function Authorized({ authorizations, children }: AuthorizedProps) {
const isAllowed = useAuthorizations(authorizations);
return isAllowed ? children : null;
return isAllowed ? <>{children}</> : null;
}
interface UserProviderProps {