15610: Uses a virtualized list to show the collection's file tree. (WIP)
[arvados-workbench2.git] / src / components / tree / virtual-tree.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import * as React from 'react';
6 import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
7 import { ReactElement } from "react";
8 import { FixedSizeList, ListChildComponentProps } from "react-window";
9 import AutoSizer from "react-virtualized-auto-sizer";
10 // import {FixedSizeTree as Tree} from 'react-vtree';
11
12 import { ArvadosTheme } from '~/common/custom-theme';
13 import { TreeItem } from './tree';
14 // import { FileTreeData } from '../file-tree/file-tree-data';
15
16 type CssRules = 'list'
17     | 'listItem'
18     | 'active'
19     | 'loader'
20     | 'toggableIconContainer'
21     | 'iconClose'
22     | 'renderContainer'
23     | 'iconOpen'
24     | 'toggableIcon'
25     | 'checkbox'
26     | 'virtualizedList';
27
28 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
29     list: {
30         padding: '3px 0px',
31     },
32     virtualizedList: {
33         height: '200px',
34     },
35     listItem: {
36         padding: '3px 0px',
37     },
38     loader: {
39         position: 'absolute',
40         transform: 'translate(0px)',
41         top: '3px'
42     },
43     toggableIconContainer: {
44         color: theme.palette.grey["700"],
45         height: '14px',
46         width: '14px',
47     },
48     toggableIcon: {
49         fontSize: '14px'
50     },
51     renderContainer: {
52         flex: 1
53     },
54     active: {
55         color: theme.palette.primary.main,
56     },
57     iconClose: {
58         transition: 'all 0.1s ease',
59     },
60     iconOpen: {
61         transition: 'all 0.1s ease',
62         transform: 'rotate(90deg)',
63     },
64     checkbox: {
65         width: theme.spacing.unit * 3,
66         height: theme.spacing.unit * 3,
67         margin: `0 ${theme.spacing.unit}px`,
68         padding: 0,
69         color: theme.palette.grey["500"],
70     }
71 });
72
73 export interface TreeProps<T> {
74     disableRipple?: boolean;
75     currentItemUuid?: string;
76     items?: Array<TreeItem<T>>;
77     level?: number;
78     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
79     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
80     showSelection?: boolean | ((item: TreeItem<T>) => boolean);
81     levelIndentation?: number;
82     itemRightPadding?: number;
83     toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
84     toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
85     toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
86
87     /**
88      * When set to true use radio buttons instead of checkboxes for item selection.
89      * This does not guarantee radio group behavior (i.e item mutual exclusivity).
90      * Any item selection logic must be done in the toggleItemActive callback prop.
91      */
92     useRadioButtons?: boolean;
93 }
94
95 // export const RowA = <T, _>(items: TreeItem<T>[], render:any) => (index: number) => {
96 //     return <div>
97 //         {render(items[index])}
98 //     </div>;
99 // };
100
101 // For some reason, on TSX files it isn't accepted just one generic param, so
102 // I'm using <T, _> as a workaround.
103 export const Row = <T, _>(items: TreeItem<T>[], render: any) => (props: React.PropsWithChildren<ListChildComponentProps>) => {
104     const { index, style } = props;
105     const level = items[index].level || 0;
106     const levelIndentation = 20;
107     return <div style={style}>
108         <div style={{ paddingLeft: (level + 1) * levelIndentation,}}>
109             {typeof render === 'function'
110                 ? items[index] && render(items[index]) || ''
111                 : 'whoops'}
112         </div>
113     </div>;
114     // <div style={style} key={`item/${level}/${idx}`}>
115     //     <ListItem button className={listItem}
116     //         style={{
117     //             paddingLeft: (level + 1) * levelIndentation,
118     //             paddingRight: itemRightPadding,
119     //         }}
120     //         disableRipple={disableRipple}
121     //         onClick={event => toggleItemActive(event, it)}
122     //         selected={showSelection(it) && it.id === currentItemUuid}
123     //         onContextMenu={this.handleRowContextMenu(it)}>
124     //         {it.status === TreeItemStatus.PENDING ?
125     //             <CircularProgress size={10} className={loader} /> : null}
126     //         <i onClick={this.handleToggleItemOpen(it)}
127     //             className={toggableIconContainer}>
128     //             <ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
129     //                 {this.getProperArrowAnimation(it.status, it.items!)}
130     //             </ListItemIcon>
131     //         </i>
132     //         {showSelection(it) && !useRadioButtons &&
133     //             <Checkbox
134     //                 checked={it.selected}
135     //                 className={classes.checkbox}
136     //                 color="primary"
137     //                 onClick={this.handleCheckboxChange(it)} />}
138     //         {showSelection(it) && useRadioButtons &&
139     //             <Radio
140     //                 checked={it.selected}
141     //                 className={classes.checkbox}
142     //                 color="primary" />}
143     //         <div className={renderContainer}>
144     //             {render(it, level)}
145     //         </div>
146     //     </ListItem>
147     //     {it.items && it.items.length > 0 &&
148     //         <Collapse in={it.open} timeout="auto" unmountOnExit>
149     //             <Tree
150     //                 showSelection={this.props.showSelection}
151     //                 items={it.items}
152     //                 render={render}
153     //                 disableRipple={disableRipple}
154     //                 toggleItemOpen={toggleItemOpen}
155     //                 toggleItemActive={toggleItemActive}
156     //                 level={level + 1}
157     //                 onContextMenu={onContextMenu}
158     //                 toggleItemSelection={this.props.toggleItemSelection} />
159     //         </Collapse>}
160     // </div>
161 };
162
163 export const VirtualList = <T, _>(height: number, width: number, items: TreeItem<T>[], render: any) =>
164     <FixedSizeList
165         height={height}
166         itemCount={items.length}
167         itemSize={30}
168         width={width}
169     >
170         {Row(items, render)}
171     </FixedSizeList>;
172
173 export const VirtualTree = withStyles(styles)(
174     class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
175         render(): ReactElement<any> {
176             const { items, render } = this.props;
177
178             return <div className={this.props.classes.virtualizedList}><AutoSizer>
179                 {({ height, width }) => {
180                     return VirtualList(height, width, items || [], render);
181                 }}
182             </AutoSizer></div>;
183         }
184     }
185 );
186
187 // const treeWalkerWithTree = (tree: Array<TreeItem<FileTreeData>>) => function* treeWalker(refresh: any) {
188 //     const stack = [];
189
190 //     // Remember all the necessary data of the first node in the stack.
191 //     stack.push({
192 //       nestingLevel: 0,
193 //       node: tree,
194 //     });
195
196 //     // Walk through the tree until we have no nodes available.
197 //     while (stack.length !== 0) {
198 //         const {
199 //             node: {items = [], id, name},
200 //             nestingLevel,
201 //         } = stack.pop()!;
202
203 //         // Here we are sending the information about the node to the Tree component
204 //         // and receive an information about the openness state from it. The
205 //         // `refresh` parameter tells us if the full update of the tree is requested;
206 //         // basing on it we decide to return the full node data or only the node
207 //         // id to update the nodes order.
208 //         const isOpened = yield refresh
209 //             ? {
210 //                 id,
211 //                 isLeaf: items.length === 0,
212 //                 isOpenByDefault: true,
213 //                 name,
214 //                 nestingLevel,
215 //             }
216 //             : id;
217
218 //         // Basing on the node openness state we are deciding if we need to render
219 //         // the child nodes (if they exist).
220 //         if (children.length !== 0 && isOpened) {
221 //             // Since it is a stack structure, we need to put nodes we want to render
222 //             // first to the end of the stack.
223 //             for (let i = children.length - 1; i >= 0; i--) {
224 //                 stack.push({
225 //                     nestingLevel: nestingLevel + 1,
226 //                     node: children[i],
227 //                 });
228 //             }
229 //         }
230 //     }
231 // };
232
233 // // Node component receives all the data we created in the `treeWalker` +
234 // // internal openness state (`isOpen`), function to change internal openness
235 // // state (`toggle`) and `style` parameter that should be added to the root div.
236 // const Node = ({data: {isLeaf, name}, isOpen, style, toggle}) => (
237 //     <div style={style}>
238 //         {!isLeaf && (
239 //         <button type="button" onClick={toggle}>
240 //             {isOpen ? '-' : '+'}
241 //         </button>
242 //         )}
243 //         <div>{name}</div>
244 //     </div>
245 // );
246
247 // export const Example = () => (
248 //     <Tree treeWalker={treeWalker} itemSize={30} height={150} width={300}>
249 //         {Node}
250 //     </Tree>
251 // );