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