19231: Add smaller page sizes (10 and 20 items) to load faster
[arvados-workbench2.git] / src / views / run-process-panel / inputs / directory-array-input.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import {
7     isRequiredInput,
8     DirectoryArrayCommandInputParameter,
9     Directory,
10     CWLType
11 } from 'models/workflow';
12 import { Field } from 'redux-form';
13 import { ERROR_MESSAGE } from 'validators/require';
14 import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, WithStyles, Typography } from '@material-ui/core';
15 import { GenericInputProps, GenericInput } from './generic-input';
16 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
17 import { connect, DispatchProp } from 'react-redux';
18 import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, getAllNodes } from 'store/tree-picker/tree-picker-actions';
19 import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
20 import { createSelector, createStructuredSelector } from 'reselect';
21 import { ChipsInput } from 'components/chips-input/chips-input';
22 import { identity, values, noop } from 'lodash';
23 import { InputProps } from '@material-ui/core/Input';
24 import { TreePicker } from 'store/tree-picker/tree-picker';
25 import { RootState } from 'store/store';
26 import { Chips } from 'components/chips/chips';
27 import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles';
28 import { CollectionResource } from 'models/collection';
29 import { ResourceKind } from 'models/resource';
30
31 export interface DirectoryArrayInputProps {
32     input: DirectoryArrayCommandInputParameter;
33     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
34 }
35
36 export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
37     <Field
38         name={input.id}
39         commandInput={input}
40         component={DirectoryArrayInputComponent as any}
41         parse={parseDirectories}
42         format={formatDirectories}
43         validate={validationSelector(input)} />;
44
45 interface FormattedDirectory {
46     name: string;
47     portableDataHash: string;
48 }
49
50 const parseDirectories = (directories: CollectionResource[] | string) =>
51     typeof directories === 'string'
52         ? undefined
53         : directories.map(parse);
54
55 const parse = (directory: CollectionResource): Directory => ({
56     class: CWLType.DIRECTORY,
57     basename: directory.name,
58     location: `keep:${directory.portableDataHash}`,
59 });
60
61 const formatDirectories = (directories: Directory[] = []) =>
62     directories ? directories.map(format) : [];
63
64 const format = ({ location = '', basename = '' }: Directory): FormattedDirectory => ({
65     portableDataHash: location.replace('keep:', ''),
66     name: basename,
67 });
68
69 const validationSelector = createSelector(
70     isRequiredInput,
71     isRequired => isRequired
72         ? [required]
73         : undefined
74 );
75
76 const required = (value?: Directory[]) =>
77     value && value.length > 0
78         ? undefined
79         : ERROR_MESSAGE;
80 interface DirectoryArrayInputComponentState {
81     open: boolean;
82     directories: CollectionResource[];
83     prevDirectories: CollectionResource[];
84 }
85
86 interface DirectoryArrayInputComponentProps {
87     treePickerState: TreePicker;
88 }
89
90 const treePickerSelector = (state: RootState) => state.treePicker;
91
92 const mapStateToProps = createStructuredSelector({
93     treePickerState: treePickerSelector,
94 });
95
96 const DirectoryArrayInputComponent = connect(mapStateToProps)(
97     class DirectoryArrayInputComponent extends React.Component<DirectoryArrayInputComponentProps & GenericInputProps & DispatchProp & {
98         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
99     }, DirectoryArrayInputComponentState> {
100         state: DirectoryArrayInputComponentState = {
101             open: false,
102             directories: [],
103             prevDirectories: [],
104         };
105
106         directoryRefreshTimeout = -1;
107
108         componentDidMount() {
109             this.props.dispatch<any>(
110                 initProjectsTreePicker(this.props.commandInput.id));
111         }
112
113         render() {
114             return <>
115                 <this.input />
116                 <this.dialog />
117             </>;
118         }
119
120         openDialog = () => {
121             this.setDirectoriesFromProps(this.props.input.value);
122             this.setState({ open: true });
123         }
124
125         closeDialog = () => {
126             this.setState({ open: false });
127         }
128
129         submit = () => {
130             this.closeDialog();
131             this.props.input.onChange(this.state.directories);
132         }
133
134         setDirectories = (directories: CollectionResource[]) => {
135
136             const deletedDirectories = this.state.directories
137                 .reduce((deletedDirectories, directory) =>
138                     directories.some(({ uuid }) => uuid === directory.uuid)
139                         ? deletedDirectories
140                         : [...deletedDirectories, directory]
141                     , []);
142
143             this.setState({ directories });
144
145             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
146             ids.forEach(pickerId => {
147                 this.props.dispatch(
148                     treePickerActions.DESELECT_TREE_PICKER_NODE({
149                         pickerId, id: deletedDirectories.map(({ uuid }) => uuid),
150                     })
151                 );
152             });
153
154         }
155
156         setDirectoriesFromProps = (formattedDirectories: FormattedDirectory[]) => {
157             const nodes = getAllNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
158             const initialDirectories: CollectionResource[] = [];
159             const directories = nodes
160                 .reduce((directories, { value }) =>
161                     'kind' in value &&
162                         value.kind === ResourceKind.COLLECTION &&
163                         formattedDirectories.find(({ portableDataHash, name }) => value.portableDataHash === portableDataHash && value.name === name)
164                         ? directories.concat(value)
165                         : directories, initialDirectories);
166
167             const addedDirectories = directories
168                 .reduce((addedDirectories, directory) =>
169                     this.state.directories.find(({ uuid }) =>
170                         uuid === directory.uuid)
171                         ? addedDirectories
172                         : [...addedDirectories, directory]
173                     , []);
174
175             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
176             ids.forEach(pickerId => {
177                 this.props.dispatch(
178                     treePickerActions.SELECT_TREE_PICKER_NODE({
179                         pickerId, id: addedDirectories.map(({ uuid }) => uuid),
180                     })
181                 );
182             });
183
184             const orderedDirectories = formattedDirectories.reduce((dirs, formattedDir) => {
185                 const dir = directories.find(({ portableDataHash, name }) => portableDataHash === formattedDir.portableDataHash && name === formattedDir.name);
186                 return dir
187                     ? [...dirs, dir]
188                     : dirs;
189             }, []);
190
191             this.setDirectories(orderedDirectories);
192
193         }
194
195         refreshDirectories = () => {
196             clearTimeout(this.directoryRefreshTimeout);
197             this.directoryRefreshTimeout = window.setTimeout(this.setSelectedFiles);
198         }
199
200         setSelectedFiles = () => {
201             const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
202             const initialDirectories: CollectionResource[] = [];
203             const directories = nodes
204                 .reduce((directories, { value }) =>
205                     'kind' in value && value.kind === ResourceKind.COLLECTION
206                         ? directories.concat(value)
207                         : directories, initialDirectories);
208             this.setDirectories(directories);
209         }
210         input = () =>
211             <GenericInput
212                 component={this.chipsInput}
213                 {...this.props} />
214
215         chipsInput = () =>
216             <ChipsInput
217                 values={this.props.input.value}
218                 onChange={noop}
219                 disabled={this.props.commandInput.disabled}
220                 createNewValue={identity}
221                 getLabel={(data: FormattedDirectory) => data.name}
222                 inputComponent={this.textInput} />
223
224         textInput = (props: InputProps) =>
225             <Input
226                 {...props}
227                 error={this.props.meta.touched && !!this.props.meta.error}
228                 readOnly
229                 onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
230                 onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined}
231                 onBlur={this.props.input.onBlur}
232                 disabled={this.props.commandInput.disabled} />
233
234         dialog = () =>
235             <Dialog
236                 open={this.state.open}
237                 onClose={this.closeDialog}
238                 fullWidth
239                 maxWidth='md' >
240                 <DialogTitle>Choose collections</DialogTitle>
241                 <DialogContent>
242                     <this.dialogContent />
243                 </DialogContent>
244                 <DialogActions>
245                     <Button onClick={this.closeDialog}>Cancel</Button>
246                     <Button
247                         data-cy='ok-button'
248                         variant='contained'
249                         color='primary'
250                         onClick={this.submit}>Ok</Button>
251                 </DialogActions>
252             </Dialog>
253
254         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
255             root: {
256                 display: 'flex',
257                 flexDirection: 'column',
258                 height: `${spacing.unit * 8}vh`,
259             },
260             tree: {
261                 flex: 3,
262                 overflow: 'auto',
263             },
264             divider: {
265                 margin: `${spacing.unit}px 0`,
266             },
267             chips: {
268                 flex: 1,
269                 overflow: 'auto',
270                 padding: `${spacing.unit}px 0`,
271                 overflowX: 'hidden',
272             },
273         })
274
275         dialogContent = withStyles(this.dialogContentStyles)(
276             ({ classes }: WithStyles<DialogContentCssRules>) =>
277                 <div className={classes.root}>
278                     <div className={classes.tree}>
279                         <ProjectsTreePicker
280                             pickerId={this.props.commandInput.id}
281                             includeCollections
282                             showSelection
283                             options={this.props.options}
284                             toggleItemSelection={this.refreshDirectories} />
285                     </div>
286                     <Divider />
287                     <div className={classes.chips}>
288                         <Typography variant='subtitle1'>Selected collections ({this.state.directories.length}):</Typography>
289                         <Chips
290                             orderable
291                             deletable
292                             values={this.state.directories}
293                             onChange={this.setDirectories}
294                             getLabel={(directory: CollectionResource) => directory.name} />
295                     </div>
296                 </div>
297         );
298
299     });
300
301 type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';