Add helper for creating collection array workflow
[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 { 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
123         closeDialog = () => {
124             this.setState({ open: false });
125         }
126
127         submit = () => {
128             this.closeDialog();
129             this.props.input.onChange(this.state.directories);
130         }
131
132         setDirectories = (directories: CollectionResource[]) => {
133
134             const deletedDirectories = this.state.directories
135                 .reduce((deletedDirectories, directory) =>
136                     directories.some(({ uuid }) => uuid === directory.uuid)
137                         ? deletedDirectories
138                         : [...deletedDirectories, directory]
139                     , []);
140
141             this.setState({ directories });
142
143             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
144             ids.forEach(pickerId => {
145                 this.props.dispatch(
146                     treePickerActions.DESELECT_TREE_PICKER_NODE({
147                         pickerId, id: deletedDirectories.map(({ uuid }) => uuid),
148                     })
149                 );
150             });
151
152         }
153
154         setDirectoriesFromProps = (formattedDirectories: FormattedDirectory[]) => {
155             const nodes = getAllNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
156             const initialDirectories: CollectionResource[] = [];
157             const directories = nodes
158                 .reduce((directories, { value }) =>
159                     'kind' in value &&
160                         value.kind === ResourceKind.COLLECTION &&
161                         formattedDirectories.find(({ portableDataHash }) => value.portableDataHash === portableDataHash)
162                         ? directories.concat(value)
163                         : directories, initialDirectories);
164
165             const addedDirectories = directories
166                 .reduce((addedDirectories, directory) =>
167                     this.state.directories.find(({ uuid }) =>
168                         uuid === directory.uuid)
169                         ? addedDirectories
170                         : [...addedDirectories, directory]
171                     , []);
172
173             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
174             ids.forEach(pickerId => {
175                 this.props.dispatch(
176                     treePickerActions.SELECT_TREE_PICKER_NODE({
177                         pickerId, id: addedDirectories.map(({ uuid }) => uuid),
178                     })
179                 );
180             });
181
182             const orderedDirectories = formattedDirectories.reduce((dirs, formattedDir) => {
183                 const dir = directories.find(({ portableDataHash }) => portableDataHash === formattedDir.portableDataHash);
184                 return dir
185                     ? [...dirs, dir]
186                     : dirs;
187             }, []);
188
189             this.setDirectories(orderedDirectories);
190
191         }
192
193         refreshDirectories = () => {
194             clearTimeout(this.directoryRefreshTimeout);
195             this.directoryRefreshTimeout = setTimeout(this.setSelectedFiles);
196         }
197
198         setSelectedFiles = () => {
199             const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
200             const initialDirectories: CollectionResource[] = [];
201             const directories = nodes
202                 .reduce((directories, { value }) =>
203                     'kind' in value && value.kind === ResourceKind.COLLECTION
204                         ? directories.concat(value)
205                         : directories, initialDirectories);
206             this.setDirectories(directories);
207         }
208         input = () =>
209             <GenericInput
210                 component={this.chipsInput}
211                 {...this.props} />
212
213         chipsInput = () =>
214             <ChipsInput
215                 value={this.props.input.value}
216                 onChange={noop}
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.openDialog}
227                 onKeyPress={this.openDialog}
228                 onBlur={this.props.input.onBlur} />
229
230         dialog = () =>
231             <Dialog
232                 open={this.state.open}
233                 onClose={this.closeDialog}
234                 fullWidth
235                 maxWidth='md' >
236                 <DialogTitle>Choose collections</DialogTitle>
237                 <DialogContent>
238                     <this.dialogContent />
239                 </DialogContent>
240                 <DialogActions>
241                     <Button onClick={this.closeDialog}>Cancel</Button>
242                     <Button
243                         variant='contained'
244                         color='primary'
245                         onClick={this.submit}>Ok</Button>
246                 </DialogActions>
247             </Dialog>
248
249         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
250             root: {
251                 display: 'flex',
252                 flexDirection: 'column',
253                 height: `${spacing.unit * 8}vh`,
254             },
255             tree: {
256                 flex: 3,
257                 overflow: 'auto',
258             },
259             divider: {
260                 margin: `${spacing.unit}px 0`,
261             },
262             chips: {
263                 flex: 1,
264                 overflow: 'auto',
265                 padding: `${spacing.unit}px 0`,
266                 overflowX: 'hidden',
267             },
268         })
269
270         dialogContent = withStyles(this.dialogContentStyles)(
271             ({ classes }: WithStyles<DialogContentCssRules>) =>
272                 <div className={classes.root}>
273                     <div className={classes.tree}>
274                         <ProjectsTreePicker
275                             pickerId={this.props.commandInput.id}
276                             includeCollections
277                             showSelection
278                             toggleItemSelection={this.refreshDirectories} />
279                     </div>
280                     <Divider />
281                     <div className={classes.chips}>
282                         <Typography variant='subheading'>Selected collections ({this.state.directories.length}):</Typography>
283                         <Chips
284                             orderable
285                             deletable
286                             values={this.state.directories}
287                             onChange={this.setDirectories}
288                             getLabel={(directory: CollectionResource) => directory.name} />
289                     </div>
290                 </div>
291         );
292
293     });
294
295 type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
296
297
298