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