15768: multiselect popover pops over Arvados-DCO-1.1-Signed-off-by: Lisa Knox <lisa...
authorLisa Knox <lisaknox83@gmail.com>
Thu, 27 Apr 2023 19:40:11 +0000 (15:40 -0400)
committerLisa Knox <lisaknox83@gmail.com>
Thu, 27 Apr 2023 19:40:11 +0000 (15:40 -0400)
src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx [new file with mode: 0644]
src/components/data-table-multiselect-popover/data-table-multiselect-tree.tsx [new file with mode: 0644]
src/components/data-table/data-table.tsx

diff --git a/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx b/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx
new file mode 100644 (file)
index 0000000..74b083a
--- /dev/null
@@ -0,0 +1,192 @@
+// 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,
+            }));
+        };
+    }
+);
diff --git a/src/components/data-table-multiselect-popover/data-table-multiselect-tree.tsx b/src/components/data-table-multiselect-popover/data-table-multiselect-tree.tsx
new file mode 100644 (file)
index 0000000..326d039
--- /dev/null
@@ -0,0 +1,106 @@
+// 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));
index 265b098641518aa9e26f9a6a5708e27805760b7f..9627681fd9c3a848c71d410ece2c44c36b5f053f 100644 (file)
@@ -21,6 +21,7 @@ import classnames from 'classnames';
 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';
@@ -52,7 +53,19 @@ export interface DataTableDataProps<I> {
     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: {
@@ -74,8 +87,15 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
         textAlign: 'center',
         padding: theme.spacing.unit,
     },
+    checkBoxHead: {
+        padding: '0',
+        display: 'flex',
+    },
     checkBoxCell: {
         padding: '0',
+        paddingLeft: '10px',
+    },
+    checkBox: {
         cursor: 'pointer',
     },
     tableCell: {
@@ -121,16 +141,12 @@ export const DataTable = withStyles(styles)(
         }
 
         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,
@@ -140,7 +156,7 @@ export const DataTable = withStyles(styles)(
                 <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()}
@@ -167,16 +183,18 @@ export const DataTable = withStyles(styles)(
         };
 
         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 });
         };
@@ -216,9 +234,17 @@ export const DataTable = withStyles(styles)(
             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}>