This commit is contained in:
Tro95 2025-11-19 21:14:19 -06:00 committed by GitHub
commit 6c1f9b79fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1096 additions and 1464 deletions

View file

@ -9,13 +9,13 @@ export type SelectContextAction =
| { type: 'unselectAll' }
| {
type: 'toggleSelected';
id: number;
isSelected: boolean;
id: number | string;
isSelected: boolean | null;
shiftKey: boolean;
}
| {
type: 'removeItem';
id: number;
id: number | string;
}
| {
type: 'updateItems';

View file

@ -129,7 +129,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
}, [setIsDeleteArtistModalOpen]);
const onSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
({ id, value, shiftKey = false }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,

View file

@ -10,7 +10,6 @@ import ArtistIndexTableHeader from 'Artist/Index/Table/ArtistIndexTableHeader';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import SortDirection from 'Helpers/Props/SortDirection';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
@ -176,10 +175,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) {
return (
<div ref={measureRef}>
<Scroller
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<Scroller className={styles.tableScroller} scrollDirection={'horizontal'}>
<ArtistIndexTableHeader
showBanners={showBanners}
columns={columns}

View file

@ -1,96 +1,93 @@
import classNames from 'classnames';
import React, {
ComponentClass,
FunctionComponent,
ComponentPropsWithoutRef,
ElementType,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export type LinkProps<C extends ElementType = 'button'> =
ComponentPropsWithoutRef<C> & {
component?: C;
to?: string;
target?: string;
isDisabled?: LinkProps<C>['disabled'];
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
};
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
export default function Link<C extends ElementType = 'button'>({
className,
component,
to,
target,
type,
isDisabled,
noRouter,
onPress,
...otherProps
}: LinkProps<C>) {
const Component = component || 'button';
const onClick = useCallback(
(event: SyntheticEvent) => {
if (!isDisabled && onPress) {
onPress(event);
if (isDisabled) {
return;
}
onPress?.(event);
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Lidarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
const linkClass = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
if (to) {
const toLink = /\w+?:\/\//.test(to);
elementProps.onClick = onClick;
if (toLink || noRouter) {
return (
<a
href={to}
target={target || (toLink ? '_blank' : '_self')}
rel={toLink ? 'noreferrer' : undefined}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return React.createElement(el, elementProps);
return (
<RouterLink
to={`${window.Lidarr.urlBase}/${to.replace(/^\//, '')}`}
target={target}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return (
<Component
type={
component === 'button' || component === 'input'
? type || 'button'
: type
}
target={target}
className={linkClass}
disabled={isDisabled}
onClick={onClick}
{...otherProps}
/>
);
}
export default Link;

View file

@ -1,6 +1,5 @@
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
ref={ref}
{...otherProps}
className={className}
scrollDirection={ScrollDirection.Vertical}
scrollDirection={'vertical'}
onScroll={onScrollWrapper}
>
<div className={innerClassName}>{children}</div>

View file

@ -1,14 +1,15 @@
import classNames from 'classnames';
import { throttle } from 'lodash';
import React, {
ComponentProps,
ForwardedRef,
forwardRef,
MutableRefObject,
ReactNode,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
import styles from './Scroller.css';
export interface OnScroll {
@ -24,6 +25,7 @@ interface ScrollerProps {
scrollTop?: number;
initialScrollTop?: number;
children?: ReactNode;
style?: ComponentProps<'div'>['style'];
onScroll?: (payload: OnScroll) => void;
}
@ -33,7 +35,7 @@ const Scroller = forwardRef(
className,
autoFocus = false,
autoScroll = true,
scrollDirection = ScrollDirection.Vertical,
scrollDirection = 'vertical',
children,
scrollTop,
initialScrollTop,
@ -41,13 +43,14 @@ const Scroller = forwardRef(
...otherProps
} = props;
const internalRef = useRef();
const currentRef = (ref as MutableRefObject<HTMLDivElement>) ?? internalRef;
const internalRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => internalRef.current!, []);
useEffect(
() => {
if (initialScrollTop != null) {
currentRef.current.scrollTop = initialScrollTop;
internalRef.current!.scrollTop = initialScrollTop;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -56,16 +59,16 @@ const Scroller = forwardRef(
useEffect(() => {
if (scrollTop != null) {
currentRef.current.scrollTop = scrollTop;
internalRef.current!.scrollTop = scrollTop;
}
if (autoFocus && scrollDirection !== ScrollDirection.None) {
currentRef.current.focus({ preventScroll: true });
if (autoFocus && scrollDirection !== 'none') {
internalRef.current!.focus({ preventScroll: true });
}
}, [autoFocus, currentRef, scrollDirection, scrollTop]);
}, [autoFocus, scrollDirection, scrollTop]);
useEffect(() => {
const div = currentRef.current;
const div = internalRef.current!;
const handleScroll = throttle(() => {
const scrollLeft = div.scrollLeft;
@ -74,17 +77,17 @@ const Scroller = forwardRef(
onScroll?.({ scrollLeft, scrollTop });
}, 10);
div.addEventListener('scroll', handleScroll);
div?.addEventListener('scroll', handleScroll);
return () => {
div.removeEventListener('scroll', handleScroll);
div?.removeEventListener('scroll', handleScroll);
};
}, [currentRef, onScroll]);
}, [onScroll]);
return (
<div
{...otherProps}
ref={currentRef}
ref={internalRef}
className={classNames(
className,
styles.scroller,

View file

@ -1,66 +0,0 @@
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import TableRowCell from './TableRowCell';
import styles from './RelativeDateCell.css';
class RelativeDateCell extends PureComponent {
//
// Render
render() {
const {
className,
date,
includeSeconds,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
component: Component,
dispatch,
...otherProps
} = this.props;
if (!date) {
return (
<Component
className={className}
{...otherProps}
/>
);
}
return (
<Component
className={className}
title={formatDateTime(date, longDateFormat, timeFormat, { includeSeconds, includeRelativeDay: !showRelativeDates })}
{...otherProps}
>
{getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })}
</Component>
);
}
}
RelativeDateCell.propTypes = {
className: PropTypes.string.isRequired,
date: PropTypes.string,
includeSeconds: PropTypes.bool.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
component: PropTypes.elementType,
dispatch: PropTypes.func
};
RelativeDateCell.defaultProps = {
className: styles.cell,
includeSeconds: false,
component: TableRowCell
};
export default RelativeDateCell;

View file

@ -0,0 +1,50 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import TableRowCell from './TableRowCell';
import styles from './RelativeDateCell.css';
interface RelativeDateCellProps {
className?: string;
date?: string;
includeSeconds?: boolean;
component?: React.ElementType;
}
function RelativeDateCell(props: RelativeDateCellProps) {
const {
className = styles.cell,
date,
includeSeconds = false,
component: Component = TableRowCell,
...otherProps
} = props;
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
if (!date) {
return <Component className={className} {...otherProps} />;
}
return (
<Component
className={className}
title={formatDateTime(date, longDateFormat, timeFormat, {
includeSeconds,
includeRelativeDay: !showRelativeDates,
})}
{...otherProps}
>
{getRelativeDate(date, shortDateFormat, showRelativeDates, {
timeFormat,
includeSeconds,
timeForToday: true,
})}
</Component>
);
}
export default RelativeDateCell;

View file

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './TableRowCell.css';
class TableRowCell extends Component {
//
// Render
render() {
const {
className,
children,
...otherProps
} = this.props;
return (
<td
className={className}
{...otherProps}
>
{children}
</td>
);
}
}
TableRowCell.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
};
TableRowCell.defaultProps = {
className: styles.cell
};
export default TableRowCell;

View file

@ -0,0 +1,11 @@
import React, { ComponentPropsWithoutRef } from 'react';
import styles from './TableRowCell.css';
export type TableRowCellProps = ComponentPropsWithoutRef<'td'>;
export default function TableRowCell({
className = styles.cell,
...tdProps
}: TableRowCellProps) {
return <td className={className} {...tdProps} />;
}

View file

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
import TableRowCell from './TableRowCell';
import styles from './TableRowCellButton.css';
function TableRowCellButton({ className, ...otherProps }) {
return (
<Link
className={className}
component={TableRowCell}
{...otherProps}
/>
);
}
TableRowCellButton.propTypes = {
className: PropTypes.string.isRequired
};
TableRowCellButton.defaultProps = {
className: styles.cell
};
export default TableRowCellButton;

View file

@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import TableRowCell from './TableRowCell';
import styles from './TableRowCellButton.css';
interface TableRowCellButtonProps extends LinkProps {
className?: string;
children: ReactNode;
}
function TableRowCellButton(props: TableRowCellButtonProps) {
const { className = styles.cell, ...otherProps } = props;
return (
<Link className={className} component={TableRowCell} {...otherProps} />
);
}
export default TableRowCellButton;

View file

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import TableRowCell from './TableRowCell';
import styles from './TableSelectCell.css';
class TableSelectCell extends Component {
//
// Lifecycle
componentDidMount() {
const {
id,
isSelected,
onSelectedChange
} = this.props;
onSelectedChange({ id, value: isSelected });
}
componentWillUnmount() {
const {
id,
onSelectedChange
} = this.props;
onSelectedChange({ id, value: null });
}
//
// Listeners
onChange = ({ value, shiftKey }, a, b, c, d) => {
const {
id,
onSelectedChange
} = this.props;
onSelectedChange({ id, value, shiftKey });
};
//
// Render
render() {
const {
className,
id,
isSelected,
...otherProps
} = this.props;
return (
<TableRowCell className={className}>
<CheckInput
className={styles.input}
name={id.toString()}
value={isSelected}
{...otherProps}
onChange={this.onChange}
/>
</TableRowCell>
);
}
}
TableSelectCell.propTypes = {
className: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
isSelected: PropTypes.bool.isRequired,
onSelectedChange: PropTypes.func.isRequired
};
TableSelectCell.defaultProps = {
className: styles.selectCell,
isSelected: false
};
export default TableSelectCell;

View file

@ -0,0 +1,59 @@
import React, { useCallback, useEffect, useRef } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import TableRowCell, { TableRowCellProps } from './TableRowCell';
import styles from './TableSelectCell.css';
interface TableSelectCellProps extends Omit<TableRowCellProps, 'id'> {
className?: string;
id: number | string;
isSelected?: boolean;
onSelectedChange: (options: SelectStateInputProps) => void;
}
function TableSelectCell({
className = styles.selectCell,
id,
isSelected = false,
onSelectedChange,
...otherProps
}: TableSelectCellProps) {
const initialIsSelected = useRef(isSelected);
const handleSelectedChange = useRef(onSelectedChange);
handleSelectedChange.current = onSelectedChange;
const handleChange = useCallback(
({ value, shiftKey }: CheckInputChanged) => {
onSelectedChange({ id, value, shiftKey });
},
[id, onSelectedChange]
);
useEffect(() => {
handleSelectedChange.current({
id,
value: initialIsSelected.current,
shiftKey: false,
});
return () => {
handleSelectedChange.current({ id, value: null, shiftKey: false });
};
}, [id]);
return (
<TableRowCell className={className}>
<CheckInput
className={styles.input}
name={id.toString()}
value={isSelected}
{...otherProps}
onChange={handleChange}
/>
</TableRowCell>
);
}
export default TableSelectCell;

View file

@ -1,29 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './VirtualTableRowCell.css';
function VirtualTableRowCell(props) {
const {
className,
children
} = props;
return (
<div
className={className}
>
{children}
</div>
);
}
VirtualTableRowCell.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
};
VirtualTableRowCell.defaultProps = {
className: styles.cell
};
export default VirtualTableRowCell;

View file

@ -0,0 +1,16 @@
import React from 'react';
import styles from './VirtualTableRowCell.css';
export interface VirtualTableRowCellProps {
className?: string;
children?: string | React.ReactNode;
}
function VirtualTableRowCell({
className = styles.cell,
children,
}: VirtualTableRowCellProps) {
return <div className={className}>{children}</div>;
}
export default VirtualTableRowCell;

View file

@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import VirtualTableRowCell from './VirtualTableRowCell';
import styles from './VirtualTableSelectCell.css';
export function virtualTableSelectCellRenderer(cellProps) {
const {
cellKey,
rowData,
columnData,
...otherProps
} = cellProps;
return (
// eslint-disable-next-line no-use-before-define
<VirtualTableSelectCell
key={cellKey}
id={rowData.name}
isSelected={rowData.isSelected}
{...columnData}
{...otherProps}
/>
);
}
class VirtualTableSelectCell extends Component {
//
// Listeners
onChange = ({ value, shiftKey }) => {
const {
id,
onSelectedChange
} = this.props;
onSelectedChange({ id, value, shiftKey });
};
//
// Render
render() {
const {
className,
inputClassName,
id,
isSelected,
isDisabled,
...otherProps
} = this.props;
return (
<VirtualTableRowCell
className={className}
{...otherProps}
>
<CheckInput
className={inputClassName}
name={id.toString()}
value={isSelected}
isDisabled={isDisabled}
onChange={this.onChange}
/>
</VirtualTableRowCell>
);
}
}
VirtualTableSelectCell.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired,
onSelectedChange: PropTypes.func.isRequired
};
VirtualTableSelectCell.defaultProps = {
className: styles.cell,
inputClassName: styles.input,
isSelected: false
};
export default VirtualTableSelectCell;

View file

@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import VirtualTableRowCell, {
VirtualTableRowCellProps,
} from './VirtualTableRowCell';
import styles from './VirtualTableSelectCell.css';
interface VirtualTableSelectCellProps extends VirtualTableRowCellProps {
inputClassName?: string;
id: number | string;
isSelected?: boolean;
isDisabled: boolean;
onSelectedChange: (options: SelectStateInputProps) => void;
}
function VirtualTableSelectCell({
inputClassName = styles.input,
id,
isSelected = false,
isDisabled,
onSelectedChange,
...otherProps
}: VirtualTableSelectCellProps) {
const handleChange = useCallback(
({ value, shiftKey }: CheckInputChanged) => {
onSelectedChange({ id, value, shiftKey });
},
[id, onSelectedChange]
);
return (
<VirtualTableRowCell className={styles.cell} {...otherProps}>
<CheckInput
className={inputClassName}
name={id.toString()}
value={isSelected}
isDisabled={isDisabled}
onChange={handleChange}
/>
</VirtualTableRowCell>
);
}
export default VirtualTableSelectCell;

View file

@ -1,12 +1,16 @@
import React from 'react';
import { SortDirection } from 'Helpers/Props/sortDirections';
type PropertyFunction<T> = () => T;
// TODO: Convert to generic so `name` can be a type
interface Column {
name: string;
label: string | PropertyFunction<string> | React.ReactNode;
className?: string;
columnLabel?: string;
isSortable?: boolean;
fixedSortDirection?: SortDirection;
isVisible: boolean;
isModifiable?: boolean;
}

View file

@ -1,143 +0,0 @@
import classNames from 'classnames';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import IconButton from 'Components/Link/IconButton';
import Scroller from 'Components/Scroller/Scroller';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import { icons, scrollDirections } from 'Helpers/Props';
import TableHeader from './TableHeader';
import TableHeaderCell from './TableHeaderCell';
import TableSelectAllHeaderCell from './TableSelectAllHeaderCell';
import styles from './Table.css';
const tableHeaderCellProps = [
'sortKey',
'sortDirection'
];
function getTableHeaderCellProps(props) {
return _.reduce(tableHeaderCellProps, (result, key) => {
if (props.hasOwnProperty(key)) {
result[key] = props[key];
}
return result;
}, {});
}
function Table(props) {
const {
className,
horizontalScroll,
selectAll,
columns,
optionsComponent,
pageSize,
canModifyColumns,
children,
onSortPress,
onTableOptionChange,
...otherProps
} = props;
return (
<Scroller
className={classNames(
styles.tableContainer,
horizontalScroll && styles.horizontalScroll
)}
scrollDirection={
horizontalScroll ?
scrollDirections.HORIZONTAL :
scrollDirections.NONE
}
autoFocus={false}
>
<table className={className}>
<TableHeader>
{
selectAll ?
<TableSelectAllHeaderCell {...otherProps} /> :
null
}
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (
(name === 'actions' || name === 'details') &&
onTableOptionChange
) {
return (
<TableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={optionsComponent}
pageSize={pageSize}
canModifyColumns={canModifyColumns}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</TableHeaderCell>
);
}
return (
<TableHeaderCell
key={column.name}
onSortPress={onSortPress}
{...getTableHeaderCellProps(otherProps)}
{...column}
>
{typeof column.label === 'function' ? column.label() : column.label}
</TableHeaderCell>
);
})
}
</TableHeader>
{children}
</table>
</Scroller>
);
}
Table.propTypes = {
...TableHeaderCell.props,
className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
optionsComponent: PropTypes.elementType,
pageSize: PropTypes.number,
canModifyColumns: PropTypes.bool,
children: PropTypes.node,
onSortPress: PropTypes.func,
onTableOptionChange: PropTypes.func
};
Table.defaultProps = {
className: styles.table,
horizontalScroll: true,
selectAll: false
};
export default Table;

View file

@ -0,0 +1,124 @@
import classNames from 'classnames';
import React from 'react';
import IconButton from 'Components/Link/IconButton';
import Scroller from 'Components/Scroller/Scroller';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import { icons, scrollDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { CheckInputChanged } from 'typings/inputs';
import { TableOptionsChangePayload } from 'typings/Table';
import Column from './Column';
import TableHeader from './TableHeader';
import TableHeaderCell from './TableHeaderCell';
import TableSelectAllHeaderCell from './TableSelectAllHeaderCell';
import styles from './Table.css';
interface TableProps {
className?: string;
horizontalScroll?: boolean;
selectAll?: boolean;
allSelected?: boolean;
allUnselected?: boolean;
columns: Column[];
optionsComponent?: React.ElementType;
pageSize?: number;
canModifyColumns?: boolean;
sortKey?: string;
sortDirection?: SortDirection;
children?: React.ReactNode;
onSortPress?: (name: string, sortDirection?: SortDirection) => void;
onTableOptionChange?: (payload: TableOptionsChangePayload) => void;
onSelectAllChange?: (change: CheckInputChanged) => void;
}
function Table({
className = styles.table,
horizontalScroll = true,
selectAll = false,
allSelected = false,
allUnselected = false,
columns,
optionsComponent,
pageSize,
canModifyColumns,
sortKey,
sortDirection,
children,
onSortPress,
onTableOptionChange,
onSelectAllChange,
}: TableProps) {
return (
<Scroller
className={classNames(
styles.tableContainer,
horizontalScroll && styles.horizontalScroll
)}
scrollDirection={
horizontalScroll ? scrollDirections.HORIZONTAL : scrollDirections.NONE
}
autoFocus={false}
>
<table className={className}>
<TableHeader>
{selectAll && onSelectAllChange ? (
<TableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
) : null}
{columns.map((column) => {
const { name, isVisible, isSortable, ...otherColumnProps } = column;
if (!isVisible) {
return null;
}
if (
(name === 'actions' || name === 'details') &&
onTableOptionChange
) {
return (
<TableHeaderCell
key={name}
name={name}
isSortable={isSortable}
{...otherColumnProps}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={optionsComponent}
pageSize={pageSize}
canModifyColumns={canModifyColumns}
onTableOptionChange={onTableOptionChange}
>
<IconButton name={icons.ADVANCED_SETTINGS} />
</TableOptionsModalWrapper>
</TableHeaderCell>
);
}
return (
<TableHeaderCell
key={column.name}
{...column}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
{typeof column.label === 'function'
? column.label()
: column.label}
</TableHeaderCell>
);
})}
</TableHeader>
{children}
</table>
</Scroller>
);
}
export default Table;

View file

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
class TableBody extends Component {
//
// Render
render() {
const {
children
} = this.props;
return (
<tbody>{children}</tbody>
);
}
}
TableBody.propTypes = {
children: PropTypes.node
};
export default TableBody;

View file

@ -0,0 +1,11 @@
import React from 'react';
interface TableBodyProps {
children?: React.ReactNode;
}
function TableBody({ children }: TableBodyProps) {
return <tbody>{children}</tbody>;
}
export default TableBody;

View file

@ -1,28 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
class TableHeader extends Component {
//
// Render
render() {
const {
children
} = this.props;
return (
<thead>
<tr>
{children}
</tr>
</thead>
);
}
}
TableHeader.propTypes = {
children: PropTypes.node
};
export default TableHeader;

View file

@ -0,0 +1,15 @@
import React from 'react';
interface TableHeaderProps {
children?: React.ReactNode;
}
function TableHeader({ children }: TableHeaderProps) {
return (
<thead>
<tr>{children}</tr>
</thead>
);
}
export default TableHeader;

View file

@ -1,99 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sortDirections } from 'Helpers/Props';
import styles from './TableHeaderCell.css';
class TableHeaderCell extends Component {
//
// Listeners
onPress = () => {
const {
name,
fixedSortDirection
} = this.props;
if (fixedSortDirection) {
this.props.onSortPress(name, fixedSortDirection);
} else {
this.props.onSortPress(name);
}
};
//
// Render
render() {
const {
className,
name,
label,
columnLabel,
isSortable,
isVisible,
isModifiable,
sortKey,
sortDirection,
fixedSortDirection,
children,
onSortPress,
...otherProps
} = this.props;
const isSorting = isSortable && sortKey === name;
const sortIcon = sortDirection === sortDirections.ASCENDING ?
icons.SORT_ASCENDING :
icons.SORT_DESCENDING;
return (
isSortable ?
<Link
{...otherProps}
component="th"
className={className}
label={typeof label === 'function' ? label() : label}
title={typeof columnLabel === 'function' ? columnLabel() : columnLabel}
onPress={this.onPress}
>
{children}
{
isSorting &&
<Icon
name={sortIcon}
className={styles.sortIcon}
/>
}
</Link> :
<th className={className}>
{children}
</th>
);
}
}
TableHeaderCell.propTypes = {
className: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func, PropTypes.node]),
columnLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
isSortable: PropTypes.bool,
isVisible: PropTypes.bool,
isModifiable: PropTypes.bool,
sortKey: PropTypes.string,
fixedSortDirection: PropTypes.string,
sortDirection: PropTypes.string,
children: PropTypes.node,
onSortPress: PropTypes.func
};
TableHeaderCell.defaultProps = {
className: styles.headerCell,
isSortable: false
};
export default TableHeaderCell;

View file

@ -0,0 +1,66 @@
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sortDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import styles from './TableHeaderCell.css';
interface TableHeaderCellProps {
className?: string;
name: string;
label?: string | (() => string) | React.ReactNode;
columnLabel?: string | (() => string);
isSortable?: boolean;
isVisible?: boolean;
isModifiable?: boolean;
sortKey?: string;
fixedSortDirection?: SortDirection;
sortDirection?: string;
children?: React.ReactNode;
onSortPress?: (name: string, sortDirection?: SortDirection) => void;
}
function TableHeaderCell({
className = styles.headerCell,
name,
columnLabel,
isSortable = false,
sortKey,
sortDirection,
fixedSortDirection,
children,
onSortPress,
...otherProps
}: TableHeaderCellProps) {
const isSorting = isSortable && sortKey === name;
const sortIcon =
sortDirection === sortDirections.ASCENDING
? icons.SORT_ASCENDING
: icons.SORT_DESCENDING;
const handlePress = useCallback(() => {
if (fixedSortDirection) {
onSortPress?.(name, fixedSortDirection);
} else {
onSortPress?.(name);
}
}, [name, fixedSortDirection, onSortPress]);
return isSortable ? (
<Link
{...otherProps}
component="th"
className={className}
title={typeof columnLabel === 'function' ? columnLabel() : columnLabel}
onPress={handlePress}
>
{children}
{isSorting && <Icon name={sortIcon} className={styles.sortIcon} />}
</Link>
) : (
<th className={className}>{children}</th>
);
}
export default TableHeaderCell;

View file

@ -1,180 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons } from 'Helpers/Props';
import styles from './TablePager.css';
class TablePager extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isShowingPageSelect: false
};
}
//
// Listeners
onOpenPageSelectClick = () => {
this.setState({ isShowingPageSelect: true });
};
onPageSelect = ({ value: page }) => {
this.setState({ isShowingPageSelect: false });
this.props.onPageSelect(parseInt(page));
};
onPageSelectBlur = () => {
this.setState({ isShowingPageSelect: false });
};
//
// Render
render() {
const {
page,
totalPages,
totalRecords,
isFetching,
onFirstPagePress,
onPreviousPagePress,
onNextPagePress,
onLastPagePress
} = this.props;
const isShowingPageSelect = this.state.isShowingPageSelect;
const pages = Array.from(new Array(totalPages), (x, i) => {
const pageNumber = i + 1;
return {
key: pageNumber,
value: pageNumber
};
});
if (!page) {
return null;
}
const isFirstPage = page === 1;
const isLastPage = page === totalPages;
return (
<div className={styles.pager}>
<div className={styles.loadingContainer}>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
</div>
<div className={styles.controlsContainer}>
<div className={styles.controls}>
<Link
className={classNames(
styles.pageLink,
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
onPress={onFirstPagePress}
>
<Icon name={icons.PAGE_FIRST} />
</Link>
<Link
className={classNames(
styles.pageLink,
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
onPress={onPreviousPagePress}
>
<Icon name={icons.PAGE_PREVIOUS} />
</Link>
<div className={styles.pageNumber}>
{
!isShowingPageSelect &&
<Link
isDisabled={totalPages === 1}
onPress={this.onOpenPageSelectClick}
>
{page} / {totalPages}
</Link>
}
{
isShowingPageSelect &&
<SelectInput
className={styles.pageSelect}
name="pageSelect"
value={page}
values={pages}
autoFocus={true}
onChange={this.onPageSelect}
onBlur={this.onPageSelectBlur}
/>
}
</div>
<Link
className={classNames(
styles.pageLink,
isLastPage && styles.disabledPageButton
)}
isDisabled={isLastPage}
onPress={onNextPagePress}
>
<Icon name={icons.PAGE_NEXT} />
</Link>
<Link
className={classNames(
styles.pageLink,
isLastPage && styles.disabledPageButton
)}
isDisabled={isLastPage}
onPress={onLastPagePress}
>
<Icon name={icons.PAGE_LAST} />
</Link>
</div>
</div>
<div className={styles.recordsContainer}>
<div className={styles.records}>
Total records: {totalRecords}
</div>
</div>
</div>
);
}
}
TablePager.propTypes = {
page: PropTypes.number,
totalPages: PropTypes.number,
totalRecords: PropTypes.number,
isFetching: PropTypes.bool,
onFirstPagePress: PropTypes.func.isRequired,
onPreviousPagePress: PropTypes.func.isRequired,
onNextPagePress: PropTypes.func.isRequired,
onLastPagePress: PropTypes.func.isRequired,
onPageSelect: PropTypes.func.isRequired
};
export default TablePager;

View file

@ -0,0 +1,159 @@
import classNames from 'classnames';
import React, { useCallback, useMemo, useState } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './TablePager.css';
interface TablePagerProps {
page?: number;
totalPages?: number;
totalRecords?: number;
isFetching?: boolean;
onFirstPagePress: () => void;
onPreviousPagePress: () => void;
onNextPagePress: () => void;
onLastPagePress: () => void;
onPageSelect: (page: number) => void;
}
function TablePager({
page,
totalPages,
totalRecords = 0,
isFetching,
onFirstPagePress,
onPreviousPagePress,
onNextPagePress,
onLastPagePress,
onPageSelect,
}: TablePagerProps) {
const [isShowingPageSelect, setIsShowingPageSelect] = useState(false);
const isFirstPage = page === 1;
const isLastPage = page === totalPages;
const pages = useMemo(() => {
return Array.from(new Array(totalPages), (_x, i) => {
const pageNumber = i + 1;
return {
key: pageNumber,
value: String(pageNumber),
};
});
}, [totalPages]);
const handleOpenPageSelectClick = useCallback(() => {
setIsShowingPageSelect(true);
}, []);
const handlePageSelect = useCallback(
({ value }: InputChanged<number>) => {
setIsShowingPageSelect(false);
onPageSelect(value);
},
[onPageSelect]
);
const handlePageSelectBlur = useCallback(() => {
setIsShowingPageSelect(false);
}, []);
if (!page) {
return null;
}
return (
<div className={styles.pager}>
<div className={styles.loadingContainer}>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
</div>
<div className={styles.controlsContainer}>
<div className={styles.controls}>
<Link
className={classNames(
styles.pageLink,
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
onPress={onFirstPagePress}
>
<Icon name={icons.PAGE_FIRST} />
</Link>
<Link
className={classNames(
styles.pageLink,
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
onPress={onPreviousPagePress}
>
<Icon name={icons.PAGE_PREVIOUS} />
</Link>
<div className={styles.pageNumber}>
{isShowingPageSelect ? null : (
<Link
isDisabled={totalPages === 1}
onPress={handleOpenPageSelectClick}
>
{page} / {totalPages}
</Link>
)}
{isShowingPageSelect ? (
<SelectInput
className={styles.pageSelect}
name="pageSelect"
value={page}
values={pages}
autoFocus={true}
onChange={handlePageSelect}
onBlur={handlePageSelectBlur}
/>
) : null}
</div>
<Link
className={classNames(
styles.pageLink,
isLastPage && styles.disabledPageButton
)}
isDisabled={isLastPage}
onPress={onNextPagePress}
>
<Icon name={icons.PAGE_NEXT} />
</Link>
<Link
className={classNames(
styles.pageLink,
isLastPage && styles.disabledPageButton
)}
isDisabled={isLastPage}
onPress={onLastPagePress}
>
<Icon name={icons.PAGE_LAST} />
</Link>
</div>
</div>
<div className={styles.recordsContainer}>
<div className={styles.records}>
{translate('TotalRecords', { totalRecords })}
</div>
</div>
</div>
);
}
export default TablePager;

View file

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './TableRow.css';
function TableRow(props) {
const {
className,
children,
overlayContent,
...otherProps
} = props;
return (
<tr
className={className}
{...otherProps}
>
{children}
</tr>
);
}
TableRow.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node,
overlayContent: PropTypes.bool
};
TableRow.defaultProps = {
className: styles.row
};
export default TableRow;

View file

@ -0,0 +1,22 @@
import React from 'react';
import styles from './TableRow.css';
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
className?: string;
children?: React.ReactNode;
overlayContent?: boolean;
}
function TableRow({
className = styles.row,
children,
...otherProps
}: TableRowProps) {
return (
<tr className={className} {...otherProps}>
{children}
</tr>
);
}
export default TableRow;

View file

@ -1,16 +0,0 @@
import React from 'react';
import Link from 'Components/Link/Link';
import TableRow from './TableRow';
import styles from './TableRowButton.css';
function TableRowButton(props) {
return (
<Link
className={styles.row}
component={TableRow}
{...props}
/>
);
}
export default TableRowButton;

View file

@ -0,0 +1,10 @@
import React from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import TableRow from './TableRow';
import styles from './TableRowButton.css';
function TableRowButton(props: LinkProps) {
return <Link className={styles.row} component={TableRow} {...props} />;
}
export default TableRowButton;

View file

@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import CheckInput from 'Components/Form/CheckInput';
import VirtualTableHeaderCell from './TableHeaderCell';
import styles from './TableSelectAllHeaderCell.css';
function getValue(allSelected, allUnselected) {
if (allSelected) {
return true;
} else if (allUnselected) {
return false;
}
return null;
}
function TableSelectAllHeaderCell(props) {
const {
allSelected,
allUnselected,
onSelectAllChange
} = props;
const value = getValue(allSelected, allUnselected);
return (
<VirtualTableHeaderCell
className={styles.selectAllHeaderCell}
name="selectAll"
>
<CheckInput
className={styles.input}
name="selectAll"
value={value}
onChange={onSelectAllChange}
/>
</VirtualTableHeaderCell>
);
}
TableSelectAllHeaderCell.propTypes = {
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default TableSelectAllHeaderCell;

View file

@ -0,0 +1,43 @@
import React, { useMemo } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import { CheckInputChanged } from 'typings/inputs';
import VirtualTableHeaderCell from './TableHeaderCell';
import styles from './TableSelectAllHeaderCell.css';
interface TableSelectAllHeaderCellProps {
allSelected: boolean;
allUnselected: boolean;
onSelectAllChange: (change: CheckInputChanged) => void;
}
function TableSelectAllHeaderCell({
allSelected,
allUnselected,
onSelectAllChange,
}: TableSelectAllHeaderCellProps) {
const value = useMemo(() => {
if (allSelected) {
return true;
} else if (allUnselected) {
return false;
}
return null;
}, [allSelected, allUnselected]);
return (
<VirtualTableHeaderCell
className={styles.selectAllHeaderCell}
name="selectAll"
>
<CheckInput
className={styles.input}
name="selectAll"
value={value}
onChange={onSelectAllChange}
/>
</VirtualTableHeaderCell>
);
}
export default TableSelectAllHeaderCell;

View file

@ -1,202 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import Measure from 'Components/Measure';
import Scroller from 'Components/Scroller/Scroller';
import { scrollDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import styles from './VirtualTable.css';
const ROW_HEIGHT = 38;
function overscanIndicesGetter(options) {
const {
cellCount,
overscanCellsCount,
startIndex,
stopIndex
} = options;
// The default getter takes the scroll direction into account,
// but that can cause issues. Ignore the scroll direction and
// always over return more items.
const overscanStartIndex = startIndex - overscanCellsCount;
const overscanStopIndex = stopIndex + overscanCellsCount;
return {
overscanStartIndex: Math.max(0, overscanStartIndex),
overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex)
};
}
class VirtualTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
scrollRestored: false
};
this._grid = null;
}
componentDidUpdate(prevProps, prevState) {
const {
items,
scrollIndex,
scrollTop
} = this.props;
const {
width,
scrollRestored
} = this.state;
if (this._grid && (prevState.width !== width || hasDifferentItemsOrOrder(prevProps.items, items))) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== undefined && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
this._grid.scrollToCell({
rowIndex: scrollIndex,
columnIndex: 0
});
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
};
//
// Listeners
onMeasure = ({ width }) => {
this.setState({
width
});
};
//
// Render
render() {
const {
isSmallScreen,
className,
items,
scroller,
header,
headerHeight,
rowHeight,
rowRenderer,
...otherProps
} = this.props;
const {
width
} = this.state;
const gridStyle = {
boxSizing: undefined,
direction: undefined,
height: undefined,
position: undefined,
willChange: undefined,
overflow: undefined,
width: undefined
};
const containerStyle = {
position: undefined
};
return (
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return null;
}
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<Scroller
className={className}
scrollDirection={scrollDirections.HORIZONTAL}
>
{header}
<div ref={registerChild}>
<Grid
{...otherProps}
ref={this.setGridRef}
autoContainerWidth={true}
autoHeight={true}
autoWidth={true}
width={width}
height={height}
headerHeight={height - headerHeight}
rowHeight={rowHeight}
rowCount={items.length}
columnCount={1}
columnWidth={width}
scrollTop={scrollTop}
onScroll={onChildScroll}
overscanRowCount={2}
cellRenderer={rowRenderer}
overscanIndicesGetter={overscanIndicesGetter}
scrollToAlignment={'start'}
isScrollingOptout={true}
className={styles.tableBodyContainer}
style={gridStyle}
containerStyle={containerStyle}
/>
</div>
</Scroller>
</Measure>
);
}
}
</WindowScroller>
);
}
}
VirtualTable.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
className: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
scrollIndex: PropTypes.number,
scrollTop: PropTypes.number,
scroller: PropTypes.instanceOf(Element).isRequired,
header: PropTypes.node.isRequired,
headerHeight: PropTypes.number.isRequired,
rowRenderer: PropTypes.func.isRequired,
rowHeight: PropTypes.number.isRequired
};
VirtualTable.defaultProps = {
className: styles.tableContainer,
headerHeight: 38,
rowHeight: ROW_HEIGHT
};
export default VirtualTable;

View file

@ -0,0 +1,167 @@
import React, { ReactNode, useEffect, useRef } from 'react';
import { Grid, GridCellProps, WindowScroller } from 'react-virtualized';
import ModelBase from 'App/ModelBase';
import Scroller from 'Components/Scroller/Scroller';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { scrollDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import styles from './VirtualTable.css';
const ROW_HEIGHT = 38;
function overscanIndicesGetter(options: {
cellCount: number;
overscanCellsCount: number;
startIndex: number;
stopIndex: number;
}) {
const { cellCount, overscanCellsCount, startIndex, stopIndex } = options;
// The default getter takes the scroll direction into account,
// but that can cause issues. Ignore the scroll direction and
// always over return more items.
const overscanStartIndex = startIndex - overscanCellsCount;
const overscanStopIndex = stopIndex + overscanCellsCount;
return {
overscanStartIndex: Math.max(0, overscanStartIndex),
overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex),
};
}
interface VirtualTableProps<T extends ModelBase> {
isSmallScreen: boolean;
className?: string;
items: T[];
scrollIndex?: number;
scrollTop?: number;
scroller: Element;
header: React.ReactNode;
headerHeight?: number;
rowRenderer: (rowProps: GridCellProps) => ReactNode;
rowHeight?: number;
}
function VirtualTable<T extends ModelBase>({
isSmallScreen,
className = styles.tableContainer,
items,
scroller,
scrollIndex,
scrollTop,
header,
headerHeight = 38,
rowHeight = ROW_HEIGHT,
rowRenderer,
...otherProps
}: VirtualTableProps<T>) {
const [measureRef, bounds] = useMeasure();
const gridRef = useRef<Grid>(null);
const scrollRestored = useRef(false);
const previousScrollIndex = usePrevious(scrollIndex);
const previousItems = usePrevious(items);
const width = bounds.width;
const gridStyle = {
boxSizing: undefined,
direction: undefined,
height: undefined,
position: undefined,
willChange: undefined,
overflow: undefined,
width: undefined,
};
const containerStyle = {
position: undefined,
};
useEffect(() => {
if (gridRef.current && width > 0) {
gridRef.current.recomputeGridSize();
}
}, [width]);
useEffect(() => {
if (
gridRef.current &&
previousItems &&
hasDifferentItemsOrOrder(previousItems, items)
) {
gridRef.current.recomputeGridSize();
}
}, [items, previousItems]);
useEffect(() => {
if (gridRef.current && scrollTop && !scrollRestored.current) {
gridRef.current.scrollToPosition({ scrollLeft: 0, scrollTop });
scrollRestored.current = true;
}
}, [scrollTop]);
useEffect(() => {
if (
gridRef.current &&
scrollIndex != null &&
scrollIndex !== previousScrollIndex
) {
gridRef.current.scrollToCell({
rowIndex: scrollIndex,
columnIndex: 0,
});
}
}, [scrollIndex, previousScrollIndex]);
return (
<WindowScroller scrollElement={isSmallScreen ? undefined : scroller}>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return null;
}
return (
<div ref={measureRef}>
<Scroller
className={className}
scrollDirection={scrollDirections.HORIZONTAL}
>
{header}
{/* @ts-expect-error - ref type is incompatible */}
<div ref={registerChild}>
<Grid
{...otherProps}
ref={gridRef}
autoContainerWidth={true}
autoHeight={true}
autoWidth={true}
width={width}
height={height}
headerHeight={height - headerHeight}
rowHeight={rowHeight}
rowCount={items.length}
columnCount={1}
columnWidth={width}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={rowRenderer}
overscanIndicesGetter={overscanIndicesGetter}
scrollToAlignment="start"
isScrollingOptout={true}
className={styles.tableBodyContainer}
style={gridStyle}
containerStyle={containerStyle}
onScroll={onChildScroll}
/>
</div>
</Scroller>
</div>
);
}}
</WindowScroller>
);
}
export default VirtualTable;

View file

@ -1,17 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './VirtualTableHeader.css';
function VirtualTableHeader({ children }) {
return (
<div className={styles.header}>
{children}
</div>
);
}
VirtualTableHeader.propTypes = {
children: PropTypes.node
};
export default VirtualTableHeader;

View file

@ -0,0 +1,12 @@
import React from 'react';
import styles from './VirtualTableHeader.css';
interface VirtualTableHeaderProps {
children?: React.ReactNode;
}
function VirtualTableHeader({ children }: VirtualTableHeaderProps) {
return <div className={styles.header}>{children}</div>;
}
export default VirtualTableHeader;

View file

@ -1,109 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sortDirections } from 'Helpers/Props';
import styles from './VirtualTableHeaderCell.css';
export function headerRenderer(headerProps) {
const {
columnData = {},
dataKey,
label
} = headerProps;
return (
// eslint-disable-next-line no-use-before-define
<VirtualTableHeaderCell
name={dataKey}
{...columnData}
>
{label}
</VirtualTableHeaderCell>
);
}
class VirtualTableHeaderCell extends Component {
//
// Listeners
onPress = () => {
const {
name,
fixedSortDirection
} = this.props;
if (fixedSortDirection) {
this.props.onSortPress(name, fixedSortDirection);
} else {
this.props.onSortPress(name);
}
};
//
// Render
render() {
const {
className,
name,
isSortable,
sortKey,
sortDirection,
fixedSortDirection,
children,
onSortPress,
...otherProps
} = this.props;
const isSorting = isSortable && sortKey === name;
const sortIcon = sortDirection === sortDirections.ASCENDING ?
icons.SORT_ASCENDING :
icons.SORT_DESCENDING;
return (
isSortable ?
<Link
component="div"
className={className}
onPress={this.onPress}
{...otherProps}
>
{children}
{
isSorting &&
<Icon
name={sortIcon}
className={styles.sortIcon}
/>
}
</Link> :
<div className={className}>
{children}
</div>
);
}
}
VirtualTableHeaderCell.propTypes = {
className: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
isSortable: PropTypes.bool,
sortKey: PropTypes.string,
fixedSortDirection: PropTypes.string,
sortDirection: PropTypes.string,
children: PropTypes.node,
onSortPress: PropTypes.func
};
VirtualTableHeaderCell.defaultProps = {
className: styles.headerCell,
isSortable: false
};
export default VirtualTableHeaderCell;

View file

@ -0,0 +1,60 @@
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sortDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import styles from './VirtualTableHeaderCell.css';
interface VirtualTableHeaderCellProps {
className?: string;
name: string;
isSortable?: boolean;
sortKey?: string;
fixedSortDirection?: SortDirection;
sortDirection?: string;
children?: React.ReactNode;
onSortPress?: (name: string, sortDirection?: SortDirection) => void;
}
function VirtualTableHeaderCell({
className = styles.headerCell,
name,
isSortable = false,
sortKey,
sortDirection,
fixedSortDirection,
children,
onSortPress,
...otherProps
}: VirtualTableHeaderCellProps) {
const isSorting = isSortable && sortKey === name;
const sortIcon =
sortDirection === sortDirections.ASCENDING
? icons.SORT_ASCENDING
: icons.SORT_DESCENDING;
const handlePress = useCallback(() => {
if (fixedSortDirection) {
onSortPress?.(name, fixedSortDirection);
} else {
onSortPress?.(name);
}
}, [name, fixedSortDirection, onSortPress]);
return isSortable ? (
<Link
component="div"
className={className}
onPress={handlePress}
{...otherProps}
>
{children}
{isSorting ? <Icon name={sortIcon} className={styles.sortIcon} /> : null}
</Link>
) : (
<div className={className}>{children}</div>
);
}
export default VirtualTableHeaderCell;

View file

@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './VirtualTableRow.css';
function VirtualTableRow(props) {
const {
className,
children,
style,
...otherProps
} = props;
return (
<div
className={className}
style={style}
{...otherProps}
>
{children}
</div>
);
}
VirtualTableRow.propTypes = {
className: PropTypes.string.isRequired,
style: PropTypes.object.isRequired,
children: PropTypes.node
};
VirtualTableRow.defaultProps = {
className: styles.row
};
export default VirtualTableRow;

View file

@ -0,0 +1,23 @@
import React from 'react';
import styles from './VirtualTableRow.css';
interface VirtualTableRowProps extends React.HTMLAttributes<HTMLDivElement> {
className: string;
style: object;
children?: React.ReactNode;
}
function VirtualTableRow({
className = styles.row,
children,
style,
...otherProps
}: VirtualTableRowProps) {
return (
<div className={className} style={style} {...otherProps}>
{children}
</div>
);
}
export default VirtualTableRow;

View file

@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import CheckInput from 'Components/Form/CheckInput';
import VirtualTableHeaderCell from './VirtualTableHeaderCell';
import styles from './VirtualTableSelectAllHeaderCell.css';
function getValue(allSelected, allUnselected) {
if (allSelected) {
return true;
} else if (allUnselected) {
return false;
}
return null;
}
function VirtualTableSelectAllHeaderCell(props) {
const {
allSelected,
allUnselected,
onSelectAllChange
} = props;
const value = getValue(allSelected, allUnselected);
return (
<VirtualTableHeaderCell
className={styles.selectAllHeaderCell}
name="selectAll"
>
<CheckInput
className={styles.input}
name="selectAll"
value={value}
onChange={onSelectAllChange}
/>
</VirtualTableHeaderCell>
);
}
VirtualTableSelectAllHeaderCell.propTypes = {
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default VirtualTableSelectAllHeaderCell;

View file

@ -0,0 +1,43 @@
import React, { useMemo } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import { CheckInputChanged } from 'typings/inputs';
import VirtualTableHeaderCell from './VirtualTableHeaderCell';
import styles from './VirtualTableSelectAllHeaderCell.css';
interface VirtualTableSelectAllHeaderCellProps {
allSelected: boolean;
allUnselected: boolean;
onSelectAllChange: (change: CheckInputChanged) => void;
}
function VirtualTableSelectAllHeaderCell({
allSelected,
allUnselected,
onSelectAllChange,
}: VirtualTableSelectAllHeaderCellProps) {
const value = useMemo(() => {
if (allSelected) {
return true;
} else if (allUnselected) {
return false;
}
return null;
}, [allSelected, allUnselected]);
return (
<VirtualTableHeaderCell
className={styles.selectAllHeaderCell}
name="selectAll"
>
<CheckInput
className={styles.input}
name="selectAll"
value={value}
onChange={onSelectAllChange}
/>
</VirtualTableHeaderCell>
);
}
export default VirtualTableSelectAllHeaderCell;

View file

@ -5,11 +5,11 @@ import areAllSelected from 'Utilities/Table/areAllSelected';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
export type SelectedState = Record<number, boolean>;
export type SelectedState = Record<number | string, boolean>;
export interface SelectState {
selectedState: SelectedState;
lastToggled: number | null;
lastToggled: number | string | null;
allSelected: boolean;
allUnselected: boolean;
}
@ -20,14 +20,14 @@ export type SelectAction =
| { type: 'unselectAll'; items: ModelBase[] }
| {
type: 'toggleSelected';
id: number;
isSelected: boolean;
id: number | string;
isSelected: boolean | null;
shiftKey: boolean;
items: ModelBase[];
}
| {
type: 'removeItem';
id: number;
id: number | string;
}
| {
type: 'updateItems';

View file

@ -1,8 +0,0 @@
enum ScrollDirection {
Horizontal = 'horizontal',
Vertical = 'vertical',
None = 'none',
Both = 'both',
}
export default ScrollDirection;

View file

@ -4,3 +4,5 @@ export const HORIZONTAL = 'horizontal';
export const VERTICAL = 'vertical';
export const all = [NONE, HORIZONTAL, VERTICAL, BOTH];
export type ScrollDirection = 'none' | 'both' | 'horizontal' | 'vertical';

View file

@ -2,3 +2,5 @@ export const ASCENDING = 'ascending';
export const DESCENDING = 'descending';
export const all = [ASCENDING, DESCENDING];
export type SortDirection = 'ascending' | 'descending';

View file

@ -21,7 +21,7 @@ import {
setManageCustomFormatsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import { CheckInputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
@ -133,7 +133,7 @@ function ManageCustomFormatsModalContent(
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]

View file

@ -21,7 +21,7 @@ import {
setManageDownloadClientsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import { CheckInputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
@ -185,7 +185,7 @@ function ManageDownloadClientsModalContent(
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]

View file

@ -19,7 +19,7 @@ import {
bulkEditImportLists,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import { CheckInputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
@ -168,7 +168,7 @@ function ManageImportListsModalContent(
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]

View file

@ -21,7 +21,7 @@ import {
setManageIndexersSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import { CheckInputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
@ -183,7 +183,7 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]

View file

@ -3,13 +3,14 @@ import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { fetchCommands } from 'Store/Actions/commandActions';
import translate from 'Utilities/String/translate';
import QueuedTaskRow from './QueuedTaskRow';
const columns = [
const columns: Column[] = [
{
name: 'trigger',
label: '',
@ -42,6 +43,7 @@ const columns = [
},
{
name: 'actions',
label: '',
isVisible: true,
},
];

View file

@ -0,0 +1,6 @@
import Column from 'Components/Table/Column';
export interface TableOptionsChangePayload {
pageSize?: number;
columns?: Column[];
}

View file

@ -5,7 +5,6 @@ export type InputChanged<T = unknown> = {
export type InputOnChange<T> = (change: InputChanged<T>) => void;
export type CheckInputChanged = {
name: string;
value: boolean;
};
export interface CheckInputChanged extends InputChanged<boolean> {
shiftKey: boolean;
}

View file

@ -1,5 +1,5 @@
export interface SelectStateInputProps {
id: number;
value: boolean;
id: number | string;
value: boolean | null;
shiftKey: boolean;
}

View file

@ -100,6 +100,7 @@
"@types/react-lazyload": "3.2.3",
"@types/react-router-dom": "5.3.3",
"@types/react-text-truncate": "0.19.0",
"@types/react-virtualized": "9.22.0",
"@types/react-window": "1.8.8",
"@types/redux-actions": "2.6.5",
"@types/webpack-livereload-plugin": "2.3.6",
@ -148,6 +149,9 @@
"webpack-livereload-plugin": "3.0.2",
"worker-loader": "3.0.8"
},
"resolutions": {
"string-width": "4.2.3"
},
"volta": {
"node": "16.17.0",
"yarn": "1.22.19"

View file

@ -1528,6 +1528,14 @@
dependencies:
"@types/react" "*"
"@types/react-virtualized@9.22.0":
version "9.22.0"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.22.0.tgz#2ff9b3692fa04a429df24ffc7d181d9f33b3831d"
integrity sha512-JL/YCCFZ123za//cj10Apk54F0UGFMrjOE0QHTuXt1KBMFrzLOGv9/x6Uc/pZ0Gaf4o6w61Fostvlw0DwuPXig==
dependencies:
"@types/prop-types" "*"
"@types/react" "*"
"@types/react-window@1.8.8":
version "1.8.8"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3"
@ -2974,11 +2982,6 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
electron-to-chromium@^1.5.227:
version "1.5.228"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz#38b849bc8714bd21fb64f5ad56bf8cfd8638e1e9"
@ -2999,11 +3002,6 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
emoji-regex@^9.2.2:
version "9.2.2"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
emojis-list@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
@ -6511,7 +6509,7 @@ string-template@~0.2.1:
resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
"string-width-cjs@npm:string-width@^4.2.0":
"string-width-cjs@npm:string-width@^4.2.0", string-width@4.2.3, string-width@^4.1.0, string-width@^4.2.3, string-width@^5.0.1, string-width@^5.1.2:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -6520,24 +6518,6 @@ string-template@~0.2.1:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
dependencies:
eastasianwidth "^0.2.0"
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"
string.prototype.matchall@^4.0.11:
version "4.0.11"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a"
@ -6611,14 +6591,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==