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:
parent
65821aaccc
commit
07e7fbd270
80 changed files with 3614 additions and 1084 deletions
|
@ -26,6 +26,6 @@ function sizeClass(size: Size | undefined) {
|
|||
case 'large':
|
||||
return 'btn-group-lg';
|
||||
default:
|
||||
return 'btn-group-sm';
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
93
app/portainer/components/datatables/components/Filter.tsx
Normal file
93
app/portainer/components/datatables/components/Filter.tsx
Normal 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]);
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
}
|
59
app/portainer/components/datatables/components/SearchBar.tsx
Normal file
59
app/portainer/components/datatables/components/SearchBar.tsx
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
29
app/portainer/components/datatables/components/Table.tsx
Normal file
29
app/portainer/components/datatables/components/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.sort-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: inline-block;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
35
app/portainer/components/datatables/components/TableRow.tsx
Normal file
35
app/portainer/components/datatables/components/TableRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.alert-visible {
|
||||
opacity: 1;
|
||||
transition: all 250ms linear;
|
||||
}
|
||||
|
||||
.alert-hidden {
|
||||
opacity: 0;
|
||||
transition: all 250ms ease-out 2s;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
9
app/portainer/components/datatables/components/index.tsx
Normal file
9
app/portainer/components/datatables/components/index.tsx
Normal 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';
|
|
@ -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]);
|
||||
}
|
478
app/portainer/components/datatables/components/useRowSelect.ts
Normal file
478
app/portainer/components/datatables/components/useRowSelect.ts
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
>;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
57
app/portainer/components/form-components/Checkbox.tsx
Normal file
57
app/portainer/components/form-components/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
29
app/portainer/components/pagination-controls/PageButton.tsx
Normal file
29
app/portainer/components/pagination-controls/PageButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
>
|
||||
«
|
||||
</PageButton>
|
||||
) : null}
|
||||
{directionLinks ? (
|
||||
<PageButton
|
||||
onPageChange={onPageChange}
|
||||
page={currentPage - 1}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
‹
|
||||
</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}
|
||||
>
|
||||
›
|
||||
</PageButton>
|
||||
) : null}
|
||||
{boundaryLinks ? (
|
||||
<PageButton
|
||||
disabled={currentPage === last}
|
||||
onPageChange={onPageChange}
|
||||
page={last}
|
||||
>
|
||||
»
|
||||
</PageButton>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
3
app/portainer/components/pagination-controls/index.ts
Normal file
3
app/portainer/components/pagination-controls/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import './pagination-controls.css';
|
||||
|
||||
export { PaginationControls } from './PaginationControls';
|
|
@ -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);
|
||||
}
|
12
app/portainer/environments/types.ts
Normal file
12
app/portainer/environments/types.ts
Normal 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;
|
||||
}
|
27
app/portainer/environments/useEnvironment.tsx
Normal file
27
app/portainer/environments/useEnvironment.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default class PortainerError {
|
||||
constructor(msg, err) {
|
||||
this.msg = msg;
|
||||
this.err = err;
|
||||
}
|
||||
}
|
8
app/portainer/error.ts
Normal file
8
app/portainer/error.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default class PortainerError extends Error {
|
||||
err?: Error;
|
||||
|
||||
constructor(msg: string, err?: Error) {
|
||||
super(msg);
|
||||
this.err = err;
|
||||
}
|
||||
}
|
15
app/portainer/hooks/useDebounce.ts
Normal file
15
app/portainer/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue