Create directory-array-input
[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, Grid, 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 { CollectionFile, CollectionFileType } from '~/models/collection-file';
21 import { createSelector, createStructuredSelector } from 'reselect';
22 import { ChipsInput } from '~/components/chips-input/chips-input';
23 import { identity, values, noop } from 'lodash';
24 import { InputProps } from '@material-ui/core/Input';
25 import { TreePicker } from '~/store/tree-picker/tree-picker';
26 import { RootState } from '~/store/store';
27 import { Chips } from '~/components/chips/chips';
28 import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles';
29 import { CollectionResource } from '~/models/collection';
30 import { ResourceKind } from '~/models/resource';
31
32 export interface DirectoryArrayInputProps {
33     input: DirectoryArrayCommandInputParameter;
34 }
35
36 export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
37     <Field
38         name={input.id}
39         commandInput={input}
40         component={DirectoryArrayInputComponent}
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.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, DirectoryArrayInputComponentState> {
98         state: DirectoryArrayInputComponentState = {
99             open: false,
100             directories: [],
101             prevDirectories: [],
102         };
103
104         directoryRefreshTimeout = -1;
105
106         componentDidMount() {
107             this.props.dispatch<any>(
108                 initProjectsTreePicker(this.props.commandInput.id));
109         }
110
111         render() {
112             return <>
113                 <this.input />
114                 <this.dialog />
115             </>;
116         }
117
118         openDialog = () => {
119             this.setDirectoriesFromProps(this.props.input.value);
120             this.setState({ open: true });
121         }
122
123
124         closeDialog = () => {
125             this.setState({ open: false });
126         }
127
128         submit = () => {
129             this.closeDialog();
130             this.props.input.onChange(this.state.directories);
131         }
132
133         setDirectories = (directories: CollectionResource[]) => {
134
135             const deletedDirectories = this.state.directories
136                 .reduce((deletedDirectories, directory) =>
137                     directories.some(({ uuid }) => uuid === directory.uuid)
138                         ? deletedDirectories
139                         : [...deletedDirectories, directory]
140                     , []);
141
142             this.setState({ directories });
143
144             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
145             ids.forEach(pickerId => {
146                 this.props.dispatch(
147                     treePickerActions.DESELECT_TREE_PICKER_NODE({
148                         pickerId, id: deletedDirectories.map(({ uuid }) => uuid),
149                     })
150                 );
151             });
152
153         }
154
155         setDirectoriesFromProps = (formattedDirectories: FormattedDirectory[]) => {
156             const nodes = getAllNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
157             const initialDirectories: CollectionResource[] = [];
158             const directories = nodes
159                 .reduce((directories, { value }) =>
160                     'kind' in value &&
161                         value.kind === ResourceKind.COLLECTION &&
162                         formattedDirectories.find(({ portableDataHash }) => value.portableDataHash === portableDataHash)
163                         ? directories.concat(value)
164                         : directories, initialDirectories);
165
166             const addedDirectories = directories
167                 .reduce((addedDirectories, directory) =>
168                     this.state.directories.find(({ uuid }) =>
169                         uuid === directory.uuid)
170                         ? addedDirectories
171                         : [...addedDirectories, directory]
172                     , []);
173
174             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
175             ids.forEach(pickerId => {
176                 this.props.dispatch(
177                     treePickerActions.SELECT_TREE_PICKER_NODE({
178                         pickerId, id: addedDirectories.map(({ uuid }) => uuid),
179                     })
180                 );
181             });
182
183             const orderedDirectories = formattedDirectories.reduce((dirs, formattedDir) => {
184                 const dir = directories.find(({ portableDataHash }) => portableDataHash === formattedDir.portableDataHash);
185                 return dir
186                     ? [...dirs, dir]
187                     : dirs;
188             }, []);
189
190             this.setDirectories(orderedDirectories);
191
192         }
193
194         refreshDirectories = () => {
195             clearTimeout(this.directoryRefreshTimeout);
196             this.directoryRefreshTimeout = setTimeout(this.setSelectedFiles);
197         }
198
199         setSelectedFiles = () => {
200             const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
201             const initialDirectories: CollectionResource[] = [];
202             const directories = nodes
203                 .reduce((directories, { value }) =>
204                     'kind' in value && value.kind === ResourceKind.COLLECTION
205                         ? directories.concat(value)
206                         : directories, initialDirectories);
207             this.setDirectories(directories);
208         }
209         input = () =>
210             <GenericInput
211                 component={this.chipsInput}
212                 {...this.props} />
213
214         chipsInput = () =>
215             <ChipsInput
216                 value={this.props.input.value}
217                 onChange={noop}
218                 createNewValue={identity}
219                 getLabel={(data: FormattedDirectory) => data.name}
220                 inputComponent={this.textInput} />
221
222         textInput = (props: InputProps) =>
223             <Input
224                 {...props}
225                 error={this.props.meta.touched && !!this.props.meta.error}
226                 readOnly
227                 onClick={this.openDialog}
228                 onKeyPress={this.openDialog}
229                 onBlur={this.props.input.onBlur} />
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='subheading'>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