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