--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect } from 'react';
+import {
+ WithStyles,
+ withStyles,
+ ButtonBase,
+ StyleRulesCallback,
+ Theme,
+ Popover,
+ Button,
+ Card,
+ CardActions,
+ Typography,
+ CardContent,
+ Tooltip,
+ IconButton,
+} from '@material-ui/core';
+import classnames from 'classnames';
+import { DefaultTransformOrigin } from 'components/popover/helpers';
+import { createTree } from 'models/tree';
+import { DataTableFilters, DataTableFiltersTree } from './data-table-multiselect-tree';
+import { getNodeDescendants } from 'models/tree';
+import debounce from 'lodash/debounce';
+import { green, grey } from '@material-ui/core/colors';
+
+export type CssRules = 'root' | 'icon' | 'iconButton' | 'active' | 'checkbox';
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+ root: {
+ // border: '1px dashed green',
+ margin: 0,
+ borderRadius: '7px',
+ '&:hover': {
+ backgroundColor: grey[200],
+ },
+ '&:focus': {
+ color: theme.palette.text.primary,
+ },
+ },
+ active: {
+ color: theme.palette.text.primary,
+ '& $iconButton': {
+ opacity: 1,
+ },
+ },
+ icon: {
+ // border: '1px solid red',
+ cursor: 'pointer',
+ fontSize: 20,
+ userSelect: 'none',
+ '&:hover': {
+ color: theme.palette.text.primary,
+ },
+ },
+ iconButton: {
+ color: theme.palette.text.primary,
+ opacity: 0.6,
+ padding: 1,
+ paddingBottom: 5,
+ },
+ checkbox: {
+ width: 24,
+ height: 24,
+ },
+});
+
+enum SelectionMode {
+ ALL = 'all',
+ NONE = 'none',
+}
+
+export interface DataTableFilterProps {
+ name: string;
+ filters: DataTableFilters;
+ onChange?: (filters: DataTableFilters) => void;
+
+ /**
+ * When set to true, only one filter can be selected at a time.
+ */
+ mutuallyExclusive?: boolean;
+
+ /**
+ * By default `all` filters selection means that label should be grayed out.
+ * Use `none` when label is supposed to be grayed out when no filter is selected.
+ */
+ defaultSelection?: SelectionMode;
+}
+
+interface DataTableFilterState {
+ anchorEl?: HTMLElement;
+ filters: DataTableFilters;
+ prevFilters: DataTableFilters;
+}
+
+export const DataTableMultiselectPopover = withStyles(styles)(
+ class extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
+ state: DataTableFilterState = {
+ anchorEl: undefined,
+ filters: createTree(),
+ prevFilters: createTree(),
+ };
+ icon = React.createRef<HTMLElement>();
+
+ render() {
+ const { name, classes, defaultSelection = SelectionMode.ALL, children } = this.props;
+ const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected));
+ return (
+ <>
+ <Tooltip disableFocusListener title='Multiselect Actions'>
+ <ButtonBase className={classnames([classes.root, { [classes.active]: isActive }])} component='span' onClick={this.open} disableRipple>
+ {children}
+ <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
+ <i className={classnames(['fas fa-sort-down', classes.icon])} data-fa-transform='shrink-3' ref={this.icon} />
+ </IconButton>
+ </ButtonBase>
+ </Tooltip>
+ <Popover
+ anchorEl={this.state.anchorEl}
+ open={!!this.state.anchorEl}
+ anchorOrigin={DefaultTransformOrigin}
+ transformOrigin={DefaultTransformOrigin}
+ onClose={this.close}
+ >
+ <Card>
+ <CardContent>
+ <Typography variant='caption'>{'foo'}</Typography>
+ </CardContent>
+ <DataTableFiltersTree filters={this.state.filters} mutuallyExclusive={this.props.mutuallyExclusive} onChange={this.onChange} />
+ {this.props.mutuallyExclusive || (
+ <CardActions>
+ <Button color='primary' variant='outlined' size='small' onClick={this.close}>
+ Close
+ </Button>
+ </CardActions>
+ )}
+ </Card>
+ </Popover>
+ <this.MountHandler />
+ </>
+ );
+ }
+
+ static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
+ return props.filters !== state.prevFilters ? { ...state, filters: props.filters, prevFilters: props.filters } : state;
+ }
+
+ open = () => {
+ this.setState({ anchorEl: this.icon.current || undefined });
+ };
+
+ onChange = (filters) => {
+ this.setState({ filters });
+ if (this.props.mutuallyExclusive) {
+ // Mutually exclusive filters apply immediately
+ const { onChange } = this.props;
+ if (onChange) {
+ onChange(filters);
+ }
+ this.close();
+ } else {
+ // Non-mutually exclusive filters are debounced
+ this.submit();
+ }
+ };
+
+ submit = debounce(() => {
+ const { onChange } = this.props;
+ if (onChange) {
+ onChange(this.state.filters);
+ }
+ }, 1000);
+
+ MountHandler = () => {
+ useEffect(() => {
+ return () => {
+ this.submit.cancel();
+ };
+ }, []);
+ return null;
+ };
+
+ close = () => {
+ this.setState((prev) => ({
+ ...prev,
+ anchorEl: undefined,
+ }));
+ };
+ }
+);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Tree, toggleNodeSelection, getNode, initTreeNode, getNodeChildrenIds, selectNode, deselectNodes } from 'models/tree';
+import { Tree as TreeComponent, TreeItem, TreeItemStatus } from 'components/tree/tree';
+import { noop, map } from 'lodash/fp';
+import { toggleNodeCollapse } from 'models/tree';
+import { countNodes, countChildren } from 'models/tree';
+
+export interface DataTableFilterItem {
+ name: string;
+}
+
+export type DataTableFilters = Tree<DataTableFilterItem>;
+
+export interface DataTableFilterProps {
+ filters: DataTableFilters;
+ onChange?: (filters: DataTableFilters) => void;
+
+ /**
+ * When set to true, only one filter can be selected at a time.
+ */
+ mutuallyExclusive?: boolean;
+}
+
+export class DataTableFiltersTree extends React.Component<DataTableFilterProps> {
+ render() {
+ const { filters } = this.props;
+ const hasSubfilters = countNodes(filters) !== countChildren('')(filters);
+ return (
+ <TreeComponent
+ levelIndentation={hasSubfilters ? 20 : 0}
+ itemRightPadding={20}
+ items={filtersToTree(filters)}
+ render={this.props.mutuallyExclusive ? renderRadioItem : renderItem}
+ showSelection
+ useRadioButtons={this.props.mutuallyExclusive}
+ disableRipple
+ onContextMenu={noop}
+ toggleItemActive={this.props.mutuallyExclusive ? this.toggleRadioButtonFilter : this.toggleFilter}
+ toggleItemOpen={this.toggleOpen}
+ />
+ );
+ }
+
+ /**
+ * Handler for when a tree item is toggled via a radio button.
+ * Ensures mutual exclusivity among filter tree items.
+ */
+ toggleRadioButtonFilter = (_: any, item: TreeItem<DataTableFilterItem>) => {
+ const { onChange = noop } = this.props;
+
+ // If the filter is already selected, do nothing.
+ if (item.selected) {
+ return;
+ }
+
+ // Otherwise select this node and deselect the others
+ const filters = selectNode(item.id)(this.props.filters);
+ const toDeselect = Object.keys(this.props.filters).filter((id) => id !== item.id);
+ onChange(deselectNodes(toDeselect)(filters));
+ };
+
+ toggleFilter = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
+ const { onChange = noop } = this.props;
+ onChange(toggleNodeSelection(item.id)(this.props.filters));
+ };
+
+ toggleOpen = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
+ const { onChange = noop } = this.props;
+ onChange(toggleNodeCollapse(item.id)(this.props.filters));
+ };
+}
+
+const renderItem = (item: TreeItem<DataTableFilterItem>) => (
+ <span>
+ {item.data.name}
+ {item.initialState !== item.selected ? <>*</> : null}
+ </span>
+);
+
+const renderRadioItem = (item: TreeItem<DataTableFilterItem>) => <span>{item.data.name}</span>;
+
+const filterToTreeItem =
+ (filters: DataTableFilters) =>
+ (id: string): TreeItem<any> => {
+ const node = getNode(id)(filters) || initTreeNode({ id: '', value: 'InvalidNode' });
+ const items = getNodeChildrenIds(node.id)(filters).map(filterToTreeItem(filters));
+ const isIndeterminate = !node.selected && items.some((i) => i.selected || i.indeterminate);
+
+ return {
+ active: node.active,
+ data: node.value,
+ id: node.id,
+ items: items.length > 0 ? items : undefined,
+ open: node.expanded,
+ selected: node.selected,
+ initialState: node.initialState,
+ indeterminate: isIndeterminate,
+ status: TreeItemStatus.LOADED,
+ };
+ };
+
+const filtersToTree = (filters: DataTableFilters): TreeItem<DataTableFilterItem>[] => map(filterToTreeItem(filters), getNodeChildrenIds('')(filters));
import { DataColumn, SortDirection } from './data-column';
import { DataTableDefaultView } from '../data-table-default-view/data-table-default-view';
import { DataTableFilters } from '../data-table-filters/data-table-filters-tree';
+import { DataTableMultiselectPopover } from '../data-table-multiselect-popover/data-table-multiselect-popover';
import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover';
import { countNodes, getTreeDirty } from 'models/tree';
import { IconType, PendingIcon } from 'components/icon/icon';
currentRoute?: string;
}
-type CssRules = 'tableBody' | 'root' | 'content' | 'noItemsInfo' | 'checkBoxCell' | 'tableCell' | 'arrow' | 'arrowButton' | 'tableCellWorkflows' | 'loader';
+type CssRules =
+ | 'tableBody'
+ | 'root'
+ | 'content'
+ | 'noItemsInfo'
+ | 'checkBoxHead'
+ | 'checkBoxCell'
+ | 'checkBox'
+ | 'tableCell'
+ | 'arrow'
+ | 'arrowButton'
+ | 'tableCellWorkflows'
+ | 'loader';
const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
root: {
textAlign: 'center',
padding: theme.spacing.unit,
},
+ checkBoxHead: {
+ padding: '0',
+ display: 'flex',
+ },
checkBoxCell: {
padding: '0',
+ paddingLeft: '10px',
+ },
+ checkBox: {
cursor: 'pointer',
},
tableCell: {
}
componentDidUpdate(prevProps: Readonly<DataTableProps<T>>) {
- console.log(this.state);
+ // console.log(this.state.checkedList);
if (!arraysAreCongruent(prevProps.items, this.props.items)) {
this.initializeCheckedList(this.props.items);
}
}
- componentWillUnmount(): void {
- console.log('UNMOUNT');
- }
-
checkBoxColumn: DataColumn<any, any> = {
name: 'checkBoxColumn',
selected: true,
<input
type='checkbox'
name={uuid}
- color='primary'
+ className={this.props.classes.checkBox}
checked={this.state.checkedList[uuid] ?? false}
onChange={() => this.handleCheck(uuid)}
onDoubleClick={(ev) => ev.stopPropagation()}
};
handleCheck = (uuid: string): void => {
- const newCheckedList = { ...this.state.checkedList };
- newCheckedList[uuid] = !this.state.checkedList[uuid];
+ const { checkedList } = this.state;
+ const newCheckedList = { ...checkedList };
+ newCheckedList[uuid] = !checkedList[uuid];
this.setState({ checkedList: newCheckedList });
- console.log(newCheckedList);
+ // console.log(newCheckedList);
};
handleInvertSelect = (): void => {
- const newCheckedList = { ...this.state.checkedList };
+ const { checkedList } = this.state;
+ const newCheckedList = { ...checkedList };
for (const key in newCheckedList) {
- newCheckedList[key] = !this.state.checkedList[key];
+ newCheckedList[key] = !checkedList[key];
}
this.setState({ checkedList: newCheckedList });
};
const { onSortToggle, onFiltersChange, classes } = this.props;
return index === 0 ? (
<TableCell key={key || index} className={classes.checkBoxCell}>
- <Tooltip title={this.state.isSelected ? 'Deselect All' : 'Select All'}>
- <input type='checkbox' checked={this.state.isSelected} onChange={this.handleSelectorSelect}></input>
- </Tooltip>
+ <div className={classes.checkBoxHead}>
+ <Tooltip title={this.state.isSelected ? 'Deselect All' : 'Select All'}>
+ <input type='checkbox' className={classes.checkBox} checked={this.state.isSelected} onChange={this.handleSelectorSelect}></input>
+ </Tooltip>
+ <DataTableMultiselectPopover
+ name={`${name} filters`}
+ mutuallyExclusive={column.mutuallyExclusiveFilters}
+ onChange={(filters) => onFiltersChange && onFiltersChange(filters, column)}
+ filters={filters}
+ ></DataTableMultiselectPopover>
+ </div>
</TableCell>
) : (
<TableCell className={classes.tableCell} key={key || index}>