1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React from 'react';
8 DirectoryArrayCommandInputParameter,
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';
33 const LOCATION_REGEX = new RegExp("^(?:keep:)?(" + PORTABLE_DATA_HASH_PATTERN + ")(/.*)?$");
34 export interface DirectoryArrayInputProps {
35 input: DirectoryArrayCommandInputParameter;
36 options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
39 export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
43 component={DirectoryArrayInputComponent as any}
44 parse={parseDirectories}
45 format={formatDirectories}
46 validate={validationSelector(input)} />;
48 interface FormattedDirectory {
50 portableDataHash: string;
54 const parseDirectories = (directories: FileOperationLocation[] | string) =>
55 typeof directories === 'string'
57 : directories.map(parse);
59 const parse = (directory: FileOperationLocation): Directory => ({
60 class: CWLType.DIRECTORY,
61 basename: directory.name,
62 location: `keep:${directory.pdh}${directory.subpath}`,
65 const formatDirectories = (directories: Directory[] = []): FormattedDirectory[] =>
66 directories ? directories.map(format).filter((dir): dir is FormattedDirectory => Boolean(dir)) : [];
68 const format = ({ location = '', basename = '' }: Directory): FormattedDirectory | undefined => {
69 const match = LOCATION_REGEX.exec(location);
73 portableDataHash: match[1],
81 const validationSelector = createSelector(
83 isRequired => isRequired
88 const required = (value?: Directory[]) =>
89 value && value.length > 0
92 interface DirectoryArrayInputComponentState {
94 directories: FileOperationLocation[];
97 interface DirectoryArrayInputDataProps {
98 treePickerState: TreePicker;
101 const treePickerSelector = (state: RootState) => state.treePicker;
103 const mapStateToProps = createStructuredSelector({
104 treePickerState: treePickerSelector,
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>;
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
120 deselectTreePickerNode: (pickerId: string, id: string | string[]) =>
121 dispatch<any>(treePickerActions.DESELECT_TREE_PICKER_NODE({
122 pickerId, id, cascade: false
124 getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
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 = {
136 directoryRefreshTimeout = -1;
138 componentDidMount() {
139 this.props.initProjectsTreePicker(this.props.commandInput.id);
150 this.setState({ open: true });
153 closeDialog = () => {
154 this.setState({ open: false });
159 this.props.input.onChange(this.state.directories);
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
169 this.setDirectories(locations);
172 refreshDirectories = () => {
173 clearTimeout(this.directoryRefreshTimeout);
174 this.directoryRefreshTimeout = window.setTimeout(this.setDirectoriesFromTree);
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);
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)
194 : [...deletedDirectories, directory]
195 , [] as FileOperationLocation[]);
197 this.setState({ directories: locations });
199 const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
200 ids.forEach(pickerId => {
201 this.props.deselectTreePickerNode(
203 deletedDirectories.map(fileOperationLocationToPickerId)
210 component={this.chipsInput}
215 values={this.props.input.value}
217 disabled={this.props.commandInput.disabled}
218 createNewValue={identity}
219 getLabel={(data: FormattedDirectory) => data.name}
220 inputComponent={this.textInput} />
222 textInput = (props: InputProps) =>
225 error={this.props.meta.touched && !!this.props.meta.error}
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} />
232 dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
235 flexDirection: 'column',
239 flexDirection: 'column',
240 flexBasis: `${spacing.unit * 8}vh`,
249 margin: `${spacing.unit}px 0`,
254 padding: `${spacing.unit}px 0`,
259 dialog = withStyles(this.dialogContentStyles)(
260 ({ classes }: WithStyles<DialogContentCssRules>) =>
262 open={this.state.open}
263 onClose={this.closeDialog}
266 <DialogTitle>Choose directories</DialogTitle>
267 <DialogContent className={classes.root}>
268 <div className={classes.pickerWrapper}>
269 <div className={classes.tree}>
271 pickerId={this.props.commandInput.id}
272 currentUuids={this.state.directories.map(dir => fileOperationLocationToPickerId(dir))}
276 cascadeSelection={false}
277 options={this.props.options}
278 toggleItemSelection={this.refreshDirectories} />
281 <div className={classes.chips}>
282 <Typography variant='subtitle1'>Selected collections ({this.state.directories.length}):</Typography>
286 values={this.state.directories}
287 onChange={this.setDirectories}
288 getLabel={(directory: CollectionResource) => directory.name} />
294 <Button onClick={this.closeDialog}>Cancel</Button>
299 onClick={this.submit}>Ok</Button>
306 type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';