Add orderable and deletable flafs to chips
[arvados-workbench2.git] / src / views / run-process-panel / inputs / file-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     FileArrayCommandInputParameter,
9     File,
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 } 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
30 export interface FileArrayInputProps {
31     input: FileArrayCommandInputParameter;
32 }
33 export const FileArrayInput = ({ input }: FileArrayInputProps) =>
34     <Field
35         name={input.id}
36         commandInput={input}
37         component={FileArrayInputComponent}
38         parse={parseFiles}
39         format={formatFiles}
40         validate={validationSelector(input)} />;
41
42 const parseFiles = (files: CollectionFile[]) =>
43     files.length > 0
44         ? files.map(parse)
45         : undefined;
46
47 const parse = (file: CollectionFile): File => ({
48     class: CWLType.FILE,
49     basename: file.name,
50     location: `keep:${file.id}`,
51     path: file.path,
52 });
53
54 const formatFiles = (files: File[] = []) => files.map(format);
55
56 const format = (file: File): CollectionFile => ({
57     id: file.location
58         ? file.location.replace('keep:', '')
59         : '',
60     name: file.basename || '',
61     path: file.path || '',
62     size: 0,
63     type: CollectionFileType.FILE,
64     url: '',
65 });
66
67 const validationSelector = createSelector(
68     isRequiredInput,
69     isRequired => isRequired
70         ? [required]
71         : undefined
72 );
73
74 const required = (value?: File[]) =>
75     value && value.length > 0
76         ? undefined
77         : ERROR_MESSAGE;
78 interface FileArrayInputComponentState {
79     open: boolean;
80     files: CollectionFile[];
81 }
82
83 interface FileArrayInputComponentProps {
84     treePickerState: TreePicker;
85 }
86
87 const treePickerSelector = (state: RootState) => state.treePicker;
88
89 const mapStateToProps = createStructuredSelector({
90     treePickerState: treePickerSelector,
91 });
92
93 const FileArrayInputComponent = connect(mapStateToProps)(
94     class FileArrayInputComponent extends React.Component<FileArrayInputComponentProps & GenericInputProps & DispatchProp, FileArrayInputComponentState> {
95         state: FileArrayInputComponentState = {
96             open: false,
97             files: [],
98         };
99
100         fileRefreshTimeout = -1;
101
102         componentDidMount() {
103             this.props.dispatch<any>(
104                 initProjectsTreePicker(this.props.commandInput.id));
105         }
106
107         render() {
108             return <>
109                 <this.input />
110                 <this.dialog />
111             </>;
112         }
113
114         openDialog = () => {
115             this.setFilesFromProps(this.props.input.value);
116             this.setState({ open: true });
117         }
118
119
120         closeDialog = () => {
121             this.setState({ open: false });
122         }
123
124         submit = () => {
125             this.closeDialog();
126             this.props.input.onChange(this.state.files);
127         }
128
129         setFiles = (files: CollectionFile[]) => {
130
131             const deletedFiles = this.state.files
132                 .reduce((deletedFiles, file) =>
133                     files.some(({ id }) => id === file.id)
134                         ? deletedFiles
135                         : [...deletedFiles, file]
136                     , []);
137
138             this.setState({ files });
139
140             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
141             deletedFiles.forEach(({ id }) => {
142                 ids.forEach(pickerId => {
143                     this.props.dispatch(
144                         treePickerActions.DESELECT_TREE_PICKER_NODE({
145                             pickerId, id,
146                         })
147                     );
148                 });
149             });
150
151         }
152
153         setFilesFromProps = (files: CollectionFile[]) => {
154
155             const addedFiles = files
156                 .reduce((addedFiles, file) =>
157                     this.state.files.some(({ id }) => id === file.id)
158                         ? addedFiles
159                         : [...addedFiles, file]
160                     , []);
161
162             this.setState({ files });
163
164             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
165             addedFiles.forEach(({ id }) => {
166                 ids.forEach(pickerId => {
167                     this.props.dispatch(
168                         treePickerActions.SELECT_TREE_PICKER_NODE({
169                             pickerId, id,
170                         })
171                     );
172                 });
173             });
174
175         }
176
177         refreshFiles = () => {
178             clearTimeout(this.fileRefreshTimeout);
179             this.fileRefreshTimeout = setTimeout(this.setSelectedFiles);
180         }
181
182         setSelectedFiles = () => {
183             const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
184             const initialFiles: CollectionFile[] = [];
185             const files = nodes
186                 .reduce((files, { value }) =>
187                     'type' in value && value.type === CollectionFileType.FILE
188                         ? files.concat(value)
189                         : files, initialFiles);
190
191             this.setFiles(files);
192         }
193         input = () =>
194             <GenericInput
195                 component={this.chipsInput}
196                 {...this.props} />
197
198         chipsInput = () =>
199             <ChipsInput
200                 value={this.props.input.value}
201                 onChange={noop}
202                 createNewValue={identity}
203                 getLabel={(file: CollectionFile) => file.name}
204                 inputComponent={this.textInput} />
205
206         textInput = (props: InputProps) =>
207             <Input
208                 {...props}
209                 error={this.props.meta.touched && !!this.props.meta.error}
210                 readOnly
211                 onClick={this.openDialog}
212                 onKeyPress={this.openDialog}
213                 onBlur={this.props.input.onBlur} />
214
215         dialog = () =>
216             <Dialog
217                 open={this.state.open}
218                 onClose={this.closeDialog}
219                 fullWidth
220                 maxWidth='md' >
221                 <DialogTitle>Choose files</DialogTitle>
222                 <DialogContent>
223                     <this.dialogContent />
224                 </DialogContent>
225                 <DialogActions>
226                     <Button onClick={this.closeDialog}>Cancel</Button>
227                     <Button
228                         variant='contained'
229                         color='primary'
230                         onClick={this.submit}>Ok</Button>
231                 </DialogActions>
232             </Dialog>
233
234         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
235             root: {
236                 display: 'flex',
237                 flexDirection: 'column',
238                 height: `${spacing.unit * 8}vh`,
239             },
240             tree: {
241                 flex: 3,
242                 overflow: 'auto',
243             },
244             divider: {
245                 margin: `${spacing.unit}px 0`,
246             },
247             chips: {
248                 flex: 1,
249                 overflow: 'auto',
250                 padding: `${spacing.unit}px 0`,
251                 overflowX: 'hidden',
252             },
253         })
254
255         dialogContent = withStyles(this.dialogContentStyles)(
256             ({ classes }: WithStyles<DialogContentCssRules>) =>
257                 <div className={classes.root}>
258                     <div className={classes.tree}>
259                         <ProjectsTreePicker
260                             pickerId={this.props.commandInput.id}
261                             includeCollections
262                             includeFiles
263                             showSelection
264                             toggleItemSelection={this.refreshFiles} />
265                     </div>
266                     <Divider />
267                     <div className={classes.chips}>
268                         <Typography variant='subheading'>Selected files ({this.state.files.length}):</Typography>
269                         <Chips
270                             orderable
271                             deletable
272                             values={this.state.files}
273                             onChange={this.setFiles}
274                             getLabel={(file: CollectionFile) => file.name} />
275                     </div>
276                 </div>
277         );
278
279     });
280
281 type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
282
283
284