Merge branch '21026-sanitize-html'
[arvados.git] / src / views / run-process-panel / inputs / directory-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 { connect, DispatchProp } from 'react-redux';
7 import { memoize } from 'lodash/fp';
8 import { Field } from 'redux-form';
9 import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
10 import {
11     isRequiredInput,
12     DirectoryCommandInputParameter,
13     CWLType,
14     Directory
15 } from 'models/workflow';
16 import { GenericInputProps, GenericInput } from './generic-input';
17 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
18 import { FileOperationLocation, getFileOperationLocation, initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
19 import { TreeItem } from 'components/tree/tree';
20 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
21 import { ERROR_MESSAGE } from 'validators/require';
22 import { Dispatch } from 'redux';
23
24 export interface DirectoryInputProps {
25     input: DirectoryCommandInputParameter;
26     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
27 }
28
29 type DialogContentCssRules = 'root' | 'pickerWrapper';
30
31 export const DirectoryInput = ({ input, options }: DirectoryInputProps) =>
32     <Field
33         name={input.id}
34         commandInput={input}
35         component={DirectoryInputComponent as any}
36         format={format}
37         parse={parse}
38         {...{
39             options
40         }}
41         validate={getValidation(input)} />;
42
43 const format = (value?: Directory) => value ? value.basename : '';
44
45 const parse = (directory: FileOperationLocation): Directory => ({
46     class: CWLType.DIRECTORY,
47     location: `keep:${directory.pdh}${directory.subpath}`,
48     basename: directory.name,
49 });
50
51 const getValidation = memoize(
52     (input: DirectoryCommandInputParameter) => ([
53         isRequiredInput(input)
54             ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
55             : () => undefined,
56     ])
57 );
58
59 interface DirectoryInputComponentState {
60     open: boolean;
61     directory?: FileOperationLocation;
62 }
63
64 interface DirectoryInputActionProps {
65     initProjectsTreePicker: (pickerId: string) => void;
66     getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
67 }
68
69 const mapDispatchToProps = (dispatch: Dispatch): DirectoryInputActionProps => ({
70     initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
71     getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
72 });
73
74 const DirectoryInputComponent = connect(null, mapDispatchToProps)(
75     class FileInputComponent extends React.Component<GenericInputProps & DirectoryInputActionProps & DispatchProp & {
76         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
77     }, DirectoryInputComponentState> {
78         state: DirectoryInputComponentState = {
79             open: false,
80         };
81
82         componentDidMount() {
83             this.props.initProjectsTreePicker(this.props.commandInput.id);
84         }
85
86         render() {
87             return <>
88                 {this.renderInput()}
89                 <this.dialog />
90             </>;
91         }
92
93         openDialog = () => {
94             this.setState({ open: true });
95         }
96
97         closeDialog = () => {
98             this.setState({ open: false });
99         }
100
101         submit = () => {
102             this.closeDialog();
103             this.props.input.onChange(this.state.directory);
104         }
105
106         setDirectory = async (_: {}, { data: item }: TreeItem<ProjectsTreePickerItem>) => {
107             const location = await this.props.getFileOperationLocation(item);
108             this.setState({ directory: location });
109         }
110
111         renderInput() {
112             return <GenericInput
113                 component={props =>
114                     <Input
115                         readOnly
116                         fullWidth
117                         value={props.input.value}
118                         error={props.meta.touched && !!props.meta.error}
119                         disabled={props.commandInput.disabled}
120                         onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
121                         onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined} />}
122                 {...this.props} />;
123         }
124
125         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
126             root: {
127                 display: 'flex',
128                 flexDirection: 'column',
129             },
130             pickerWrapper: {
131                 flexBasis: `${spacing.unit * 8}vh`,
132                 flexShrink: 1,
133                 minHeight: 0,
134             },
135         });
136
137         dialog = withStyles(this.dialogContentStyles)(
138             ({ classes }: WithStyles<DialogContentCssRules>) =>
139                 <Dialog
140                     open={this.state.open}
141                     onClose={this.closeDialog}
142                     fullWidth
143                     data-cy="choose-a-directory-dialog"
144                     maxWidth='md'>
145                     <DialogTitle>Choose a directory</DialogTitle>
146                     <DialogContent className={classes.root}>
147                         <div className={classes.pickerWrapper}>
148                             <ProjectsTreePicker
149                                 pickerId={this.props.commandInput.id}
150                                 includeCollections
151                                 includeDirectories
152                                 cascadeSelection={false}
153                                 options={this.props.options}
154                                 toggleItemActive={this.setDirectory} />
155                         </div>
156                     </DialogContent>
157                     <DialogActions>
158                         <Button onClick={this.closeDialog}>Cancel</Button>
159                         <Button
160                             disabled={!this.state.directory}
161                             variant='contained'
162                             color='primary'
163                             onClick={this.submit}>Ok</Button>
164                     </DialogActions>
165                 </Dialog>
166         );
167
168     });