20251: Fix flaky collection file browser by using race-free state update callback
[arvados-workbench2.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 { 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 { CollectionResource } from 'models/collection';
22 import { ResourceKind } from 'models/resource';
23 import { ERROR_MESSAGE } from 'validators/require';
24
25 export interface DirectoryInputProps {
26     input: DirectoryCommandInputParameter;
27     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
28 }
29
30 type DialogContentCssRules = 'root' | 'pickerWrapper';
31
32 export const DirectoryInput = ({ input, options }: DirectoryInputProps) =>
33     <Field
34         name={input.id}
35         commandInput={input}
36         component={DirectoryInputComponent as any}
37         format={format}
38         parse={parse}
39         {...{
40             options
41         }}
42         validate={getValidation(input)} />;
43
44 const format = (value?: Directory) => value ? value.basename : '';
45
46 const parse = (directory: CollectionResource): Directory => ({
47     class: CWLType.DIRECTORY,
48     location: `keep:${directory.portableDataHash}`,
49     basename: directory.name,
50 });
51
52 const getValidation = memoize(
53     (input: DirectoryCommandInputParameter) => ([
54         isRequiredInput(input)
55             ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
56             : () => undefined,
57     ])
58 );
59
60 interface DirectoryInputComponentState {
61     open: boolean;
62     directory?: CollectionResource;
63 }
64
65 const DirectoryInputComponent = connect()(
66     class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
67         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
68     }, DirectoryInputComponentState> {
69         state: DirectoryInputComponentState = {
70             open: false,
71         };
72
73         componentDidMount() {
74             this.props.dispatch<any>(
75                 initProjectsTreePicker(this.props.commandInput.id));
76         }
77
78         render() {
79             return <>
80                 {this.renderInput()}
81                 <this.dialog />
82             </>;
83         }
84
85         openDialog = () => {
86             this.setState({ open: true });
87         }
88
89         closeDialog = () => {
90             this.setState({ open: false });
91         }
92
93         submit = () => {
94             this.closeDialog();
95             this.props.input.onChange(this.state.directory);
96         }
97
98         setDirectory = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
99             if ('kind' in data && data.kind === ResourceKind.COLLECTION) {
100                 this.setState({ directory: data });
101             } else {
102                 this.setState({ directory: undefined });
103             }
104         }
105
106         renderInput() {
107             return <GenericInput
108                 component={props =>
109                     <Input
110                         readOnly
111                         fullWidth
112                         value={props.input.value}
113                         error={props.meta.touched && !!props.meta.error}
114                         disabled={props.commandInput.disabled}
115                         onClick={!this.props.commandInput.disabled ? this.openDialog : undefined}
116                         onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined} />}
117                 {...this.props} />;
118         }
119
120         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
121             root: {
122                 display: 'flex',
123                 flexDirection: 'column',
124             },
125             pickerWrapper: {
126                 flexBasis: `${spacing.unit * 8}vh`,
127                 flexShrink: 1,
128                 minHeight: 0,
129             },
130         });
131
132         dialog = withStyles(this.dialogContentStyles)(
133             ({ classes }: WithStyles<DialogContentCssRules>) =>
134                 <Dialog
135                     open={this.state.open}
136                     onClose={this.closeDialog}
137                     fullWidth
138                     data-cy="choose-a-directory-dialog"
139                     maxWidth='md'>
140                     <DialogTitle>Choose a directory</DialogTitle>
141                     <DialogContent className={classes.root}>
142                         <div className={classes.pickerWrapper}>
143                             <ProjectsTreePicker
144                                 pickerId={this.props.commandInput.id}
145                                 includeCollections
146                                 options={this.props.options}
147                                 toggleItemActive={this.setDirectory} />
148                         </div>
149                     </DialogContent>
150                     <DialogActions>
151                         <Button onClick={this.closeDialog}>Cancel</Button>
152                         <Button
153                             disabled={!this.state.directory}
154                             variant='contained'
155                             color='primary'
156                             onClick={this.submit}>Ok</Button>
157                     </DialogActions>
158                 </Dialog>
159         );
160
161     });