// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; import { isRequiredInput, DirectoryArrayCommandInputParameter, Directory, CWLType } from 'models/workflow'; import { Field } from 'redux-form'; import { ERROR_MESSAGE } from 'validators/require'; import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, WithStyles, Typography } from '@material-ui/core'; import { GenericInputProps, GenericInput } from './generic-input'; import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker'; import { connect, DispatchProp } from 'react-redux'; import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, FileOperationLocation, getFileOperationLocation, fileOperationLocationToPickerId } from 'store/tree-picker/tree-picker-actions'; import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware'; import { createSelector, createStructuredSelector } from 'reselect'; import { ChipsInput } from 'components/chips-input/chips-input'; import { identity, values, noop } from 'lodash'; import { InputProps } from '@material-ui/core/Input'; import { TreePicker } from 'store/tree-picker/tree-picker'; import { RootState } from 'store/store'; import { Chips } from 'components/chips/chips'; import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles'; import { CollectionResource } from 'models/collection'; import { PORTABLE_DATA_HASH_PATTERN, ResourceKind } from 'models/resource'; import { Dispatch } from 'redux'; import { CollectionDirectory, CollectionFileType } from 'models/collection-file'; const LOCATION_REGEX = new RegExp("^(?:keep:)?(" + PORTABLE_DATA_HASH_PATTERN + ")(/.*)?$"); export interface DirectoryArrayInputProps { input: DirectoryArrayCommandInputParameter; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; } export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) => <Field name={input.id} commandInput={input} component={DirectoryArrayInputComponent as any} parse={parseDirectories} format={formatDirectories} validate={validationSelector(input)} />; interface FormattedDirectory { name: string; portableDataHash: string; subpath: string; } const parseDirectories = (directories: FileOperationLocation[] | string) => typeof directories === 'string' ? undefined : directories.map(parse); const parse = (directory: FileOperationLocation): Directory => ({ class: CWLType.DIRECTORY, basename: directory.name, location: `keep:${directory.pdh}${directory.subpath}`, }); const formatDirectories = (directories: Directory[] = []): FormattedDirectory[] => directories ? directories.map(format).filter((dir): dir is FormattedDirectory => Boolean(dir)) : []; const format = ({ location = '', basename = '' }: Directory): FormattedDirectory | undefined => { const match = LOCATION_REGEX.exec(location); if (match) { return { portableDataHash: match[1], subpath: match[2], name: basename, }; } return undefined; }; const validationSelector = createSelector( isRequiredInput, isRequired => isRequired ? [required] : undefined ); const required = (value?: Directory[]) => value && value.length > 0 ? undefined : ERROR_MESSAGE; interface DirectoryArrayInputComponentState { open: boolean; directories: FileOperationLocation[]; } interface DirectoryArrayInputDataProps { treePickerState: TreePicker; } const treePickerSelector = (state: RootState) => state.treePicker; const mapStateToProps = createStructuredSelector({ treePickerState: treePickerSelector, }); interface DirectoryArrayInputActionProps { initProjectsTreePicker: (pickerId: string) => void; selectTreePickerNode: (pickerId: string, id: string | string[]) => void; deselectTreePickerNode: (pickerId: string, id: string | string[]) => void; getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>; } const mapDispatchToProps = (dispatch: Dispatch): DirectoryArrayInputActionProps => ({ initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)), selectTreePickerNode: (pickerId: string, id: string | string[]) => dispatch<any>(treePickerActions.SELECT_TREE_PICKER_NODE({ pickerId, id, cascade: false })), deselectTreePickerNode: (pickerId: string, id: string | string[]) => dispatch<any>(treePickerActions.DESELECT_TREE_PICKER_NODE({ pickerId, id, cascade: false })), getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)), }); const DirectoryArrayInputComponent = connect(mapStateToProps, mapDispatchToProps)( class DirectoryArrayInputComponent extends React.Component<GenericInputProps & DirectoryArrayInputDataProps & DirectoryArrayInputActionProps & DispatchProp & { options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; }, DirectoryArrayInputComponentState> { state: DirectoryArrayInputComponentState = { open: false, directories: [], }; directoryRefreshTimeout = -1; componentDidMount() { this.props.initProjectsTreePicker(this.props.commandInput.id); } render() { return <> <this.input /> <this.dialog /> </>; } openDialog = () => { this.setState({ open: true }); } closeDialog = () => { this.setState({ open: false }); } submit = () => { this.closeDialog(); this.props.input.onChange(this.state.directories); } setDirectoriesFromResources = async (directories: (CollectionResource | CollectionDirectory)[]) => { const locations = (await Promise.all( directories.map(directory => (this.props.getFileOperationLocation(directory))) )).filter((location): location is FileOperationLocation => ( location !== undefined )); this.setDirectories(locations); } refreshDirectories = () => { clearTimeout(this.directoryRefreshTimeout); this.directoryRefreshTimeout = window.setTimeout(this.setDirectoriesFromTree); } setDirectoriesFromTree = () => { const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState); const initialDirectories: (CollectionResource | CollectionDirectory)[] = []; const directories = nodes .reduce((directories, { value }) => (('kind' in value && value.kind === ResourceKind.COLLECTION) || ('type' in value && value.type === CollectionFileType.DIRECTORY)) ? directories.concat(value) : directories, initialDirectories); this.setDirectoriesFromResources(directories); } setDirectories = (locations: FileOperationLocation[]) => { const deletedDirectories = this.state.directories .reduce((deletedDirectories, directory) => locations.some(({ uuid, subpath }) => uuid === directory.uuid && subpath === directory.subpath) ? deletedDirectories : [...deletedDirectories, directory] , [] as FileOperationLocation[]); this.setState({ directories: locations }); const ids = values(getProjectsTreePickerIds(this.props.commandInput.id)); ids.forEach(pickerId => { this.props.deselectTreePickerNode( pickerId, deletedDirectories.map(fileOperationLocationToPickerId) ); }); }; input = () => <GenericInput component={this.chipsInput} {...this.props} /> chipsInput = () => <ChipsInput values={this.props.input.value} onChange={noop} disabled={this.props.commandInput.disabled} createNewValue={identity} getLabel={(data: FormattedDirectory) => data.name} inputComponent={this.textInput} /> textInput = (props: InputProps) => <Input {...props} error={this.props.meta.touched && !!this.props.meta.error} readOnly onClick={!this.props.commandInput.disabled ? this.openDialog : undefined} onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined} onBlur={this.props.input.onBlur} disabled={this.props.commandInput.disabled} /> dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({ root: { display: 'flex', flexDirection: 'column', }, pickerWrapper: { display: 'flex', flexDirection: 'column', flexBasis: `${spacing.unit * 8}vh`, flexShrink: 1, minHeight: 0, }, tree: { flex: 3, overflow: 'auto', }, divider: { margin: `${spacing.unit}px 0`, }, chips: { flex: 1, overflow: 'auto', padding: `${spacing.unit}px 0`, overflowX: 'hidden', }, }); dialog = withStyles(this.dialogContentStyles)( ({ classes }: WithStyles<DialogContentCssRules>) => <Dialog open={this.state.open} onClose={this.closeDialog} fullWidth maxWidth='md' > <DialogTitle>Choose directories</DialogTitle> <DialogContent className={classes.root}> <div className={classes.pickerWrapper}> <div className={classes.tree}> <ProjectsTreePicker pickerId={this.props.commandInput.id} currentUuids={this.state.directories.map(dir => fileOperationLocationToPickerId(dir))} includeCollections includeDirectories showSelection cascadeSelection={false} options={this.props.options} toggleItemSelection={this.refreshDirectories} /> </div> <Divider /> <div className={classes.chips}> <Typography variant='subtitle1'>Selected collections ({this.state.directories.length}):</Typography> <Chips orderable deletable values={this.state.directories} onChange={this.setDirectories} getLabel={(directory: CollectionResource) => directory.name} /> </div> </div> </DialogContent> <DialogActions> <Button onClick={this.closeDialog}>Cancel</Button> <Button data-cy='ok-button' variant='contained' color='primary' onClick={this.submit}>Ok</Button> </DialogActions> </Dialog> ); }); type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';