Merge branch '21128-toolbar-context-menu'
[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 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, 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, FileOperationLocation, getFileOperationLocation, fileOperationLocationToPickerId } from 'store/tree-picker/tree-picker-actions';
19 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
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 { PORTABLE_DATA_HASH_PATTERN, ResourceKind } from 'models/resource';
30 import { Dispatch } from 'redux';
31 import { CollectionDirectory, CollectionFileType } from 'models/collection-file';
32
33 const LOCATION_REGEX = new RegExp("^(?:keep:)?(" + PORTABLE_DATA_HASH_PATTERN + ")(/.*)?$");
34 export interface DirectoryArrayInputProps {
35     input: DirectoryArrayCommandInputParameter;
36     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
37 }
38
39 export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
40     <Field
41         name={input.id}
42         commandInput={input}
43         component={DirectoryArrayInputComponent as any}
44         parse={parseDirectories}
45         format={formatDirectories}
46         validate={validationSelector(input)} />;
47
48 interface FormattedDirectory {
49     name: string;
50     portableDataHash: string;
51     subpath: string;
52 }
53
54 const parseDirectories = (directories: FileOperationLocation[] | string) =>
55     typeof directories === 'string'
56         ? undefined
57         : directories.map(parse);
58
59 const parse = (directory: FileOperationLocation): Directory => ({
60     class: CWLType.DIRECTORY,
61     basename: directory.name,
62     location: `keep:${directory.pdh}${directory.subpath}`,
63 });
64
65 const formatDirectories = (directories: Directory[] = []): FormattedDirectory[] =>
66     directories ? directories.map(format).filter((dir): dir is FormattedDirectory => Boolean(dir)) : [];
67
68 const format = ({ location = '', basename = '' }: Directory): FormattedDirectory | undefined => {
69     const match = LOCATION_REGEX.exec(location);
70
71     if (match) {
72         return {
73             portableDataHash: match[1],
74             subpath: match[2],
75             name: basename,
76         };
77     }
78     return undefined;
79 };
80
81 const validationSelector = createSelector(
82     isRequiredInput,
83     isRequired => isRequired
84         ? [required]
85         : undefined
86 );
87
88 const required = (value?: Directory[]) =>
89     value && value.length > 0
90         ? undefined
91         : ERROR_MESSAGE;
92 interface DirectoryArrayInputComponentState {
93     open: boolean;
94     directories: FileOperationLocation[];
95 }
96
97 interface DirectoryArrayInputDataProps {
98     treePickerState: TreePicker;
99 }
100
101 const treePickerSelector = (state: RootState) => state.treePicker;
102
103 const mapStateToProps = createStructuredSelector({
104     treePickerState: treePickerSelector,
105 });
106
107 interface DirectoryArrayInputActionProps {
108     initProjectsTreePicker: (pickerId: string) => void;
109     selectTreePickerNode: (pickerId: string, id: string | string[]) => void;
110     deselectTreePickerNode: (pickerId: string, id: string | string[]) => void;
111     getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
112 }
113
114 const mapDispatchToProps = (dispatch: Dispatch): DirectoryArrayInputActionProps => ({
115     initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
116     selectTreePickerNode: (pickerId: string, id: string | string[]) =>
117         dispatch<any>(treePickerActions.SELECT_TREE_PICKER_NODE({
118             pickerId, id, cascade: false
119         })),
120     deselectTreePickerNode: (pickerId: string, id: string | string[]) =>
121         dispatch<any>(treePickerActions.DESELECT_TREE_PICKER_NODE({
122             pickerId, id, cascade: false
123         })),
124     getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
125 });
126
127 const DirectoryArrayInputComponent = connect(mapStateToProps, mapDispatchToProps)(
128     class DirectoryArrayInputComponent extends React.Component<GenericInputProps & DirectoryArrayInputDataProps & DirectoryArrayInputActionProps & DispatchProp & {
129         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
130     }, DirectoryArrayInputComponentState> {
131         state: DirectoryArrayInputComponentState = {
132             open: false,
133             directories: [],
134         };
135
136         directoryRefreshTimeout = -1;
137
138         componentDidMount() {
139             this.props.initProjectsTreePicker(this.props.commandInput.id);
140         }
141
142         render() {
143             return <>
144                 <this.input />
145                 <this.dialog />
146             </>;
147         }
148
149         openDialog = () => {
150             this.setState({ open: true });
151         }
152
153         closeDialog = () => {
154             this.setState({ open: false });
155         }
156
157         submit = () => {
158             this.closeDialog();
159             this.props.input.onChange(this.state.directories);
160         }
161
162         setDirectoriesFromResources = async (directories: (CollectionResource | CollectionDirectory)[]) => {
163             const locations = (await Promise.all(
164                 directories.map(directory => (this.props.getFileOperationLocation(directory)))
165             )).filter((location): location is FileOperationLocation => (
166                 location !== undefined
167             ));
168
169             this.setDirectories(locations);
170         }
171
172         refreshDirectories = () => {
173             clearTimeout(this.directoryRefreshTimeout);
174             this.directoryRefreshTimeout = window.setTimeout(this.setDirectoriesFromTree);
175         }
176
177         setDirectoriesFromTree = () => {
178             const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
179             const initialDirectories: (CollectionResource | CollectionDirectory)[] = [];
180             const directories = nodes
181                 .reduce((directories, { value }) =>
182                     (('kind' in value && value.kind === ResourceKind.COLLECTION) ||
183                     ('type' in value && value.type === CollectionFileType.DIRECTORY))
184                         ? directories.concat(value)
185                         : directories, initialDirectories);
186             this.setDirectoriesFromResources(directories);
187         }
188
189         setDirectories = (locations: FileOperationLocation[]) => {
190             const deletedDirectories = this.state.directories
191                 .reduce((deletedDirectories, directory) =>
192                     locations.some(({ uuid, subpath }) => uuid === directory.uuid && subpath === directory.subpath)
193                         ? deletedDirectories
194                         : [...deletedDirectories, directory]
195                     , [] as FileOperationLocation[]);
196
197             this.setState({ directories: locations });
198
199             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
200             ids.forEach(pickerId => {
201                 this.props.deselectTreePickerNode(
202                     pickerId,
203                     deletedDirectories.map(fileOperationLocationToPickerId)
204                 );
205             });
206         };
207
208         input = () =>
209             <GenericInput
210                 component={this.chipsInput}
211                 {...this.props} />
212
213         chipsInput = () =>
214             <ChipsInput
215                 values={this.props.input.value}
216                 onChange={noop}
217                 disabled={this.props.commandInput.disabled}
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.props.commandInput.disabled ? this.openDialog : undefined}
228                 onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined}
229                 onBlur={this.props.input.onBlur}
230                 disabled={this.props.commandInput.disabled} />
231
232         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
233             root: {
234                 display: 'flex',
235                 flexDirection: 'column',
236             },
237             pickerWrapper: {
238                 display: 'flex',
239                 flexDirection: 'column',
240                 flexBasis: `${spacing.unit * 8}vh`,
241                 flexShrink: 1,
242                 minHeight: 0,
243             },
244             tree: {
245                 flex: 3,
246                 overflow: 'auto',
247             },
248             divider: {
249                 margin: `${spacing.unit}px 0`,
250             },
251             chips: {
252                 flex: 1,
253                 overflow: 'auto',
254                 padding: `${spacing.unit}px 0`,
255                 overflowX: 'hidden',
256             },
257         });
258
259         dialog = withStyles(this.dialogContentStyles)(
260             ({ classes }: WithStyles<DialogContentCssRules>) =>
261                 <Dialog
262                     open={this.state.open}
263                     onClose={this.closeDialog}
264                     fullWidth
265                     maxWidth='md' >
266                     <DialogTitle>Choose directories</DialogTitle>
267                     <DialogContent className={classes.root}>
268                         <div className={classes.pickerWrapper}>
269                             <div className={classes.tree}>
270                                 <ProjectsTreePicker
271                                     pickerId={this.props.commandInput.id}
272                                     currentUuids={this.state.directories.map(dir => fileOperationLocationToPickerId(dir))}
273                                     includeCollections
274                                     includeDirectories
275                                     showSelection
276                                     cascadeSelection={false}
277                                     options={this.props.options}
278                                     toggleItemSelection={this.refreshDirectories} />
279                             </div>
280                             <Divider />
281                             <div className={classes.chips}>
282                                 <Typography variant='subtitle1'>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                     </DialogContent>
293                     <DialogActions>
294                         <Button onClick={this.closeDialog}>Cancel</Button>
295                         <Button
296                             data-cy='ok-button'
297                             variant='contained'
298                             color='primary'
299                             onClick={this.submit}>Ok</Button>
300                     </DialogActions>
301                 </Dialog>
302         );
303
304     });
305
306 type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';