// 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 { CustomStyleRulesCallback } from 'common/custom-theme'; import withStyles 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) => ; 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; } const mapDispatchToProps = (dispatch: Dispatch): DirectoryArrayInputActionProps => ({ initProjectsTreePicker: (pickerId: string) => dispatch(initProjectsTreePicker(pickerId)), selectTreePickerNode: (pickerId: string, id: string | string[]) => dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ pickerId, id, cascade: false })), deselectTreePickerNode: (pickerId: string, id: string | string[]) => dispatch(treePickerActions.DESELECT_TREE_PICKER_NODE({ pickerId, id, cascade: false })), getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch(getFileOperationLocation(item)), }); const DirectoryArrayInputComponent = connect(mapStateToProps, mapDispatchToProps)( class DirectoryArrayInputComponent extends React.Component { state: DirectoryArrayInputComponentState = { open: false, directories: [], }; directoryRefreshTimeout = -1; componentDidMount() { this.props.initProjectsTreePicker(this.props.commandInput.id); } render() { return <> ; } 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(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 = () => chipsInput = () => data.name} inputComponent={this.textInput} /> textInput = (props: InputProps) => dialogContentStyles: CustomStyleRulesCallback = ({ spacing }) => ({ root: { display: 'flex', flexDirection: 'column', }, pickerWrapper: { display: 'flex', flexDirection: 'column', flexBasis: `${spacing(8)}vh`, flexShrink: 1, minHeight: 0, }, tree: { flex: 3, overflow: 'auto', }, divider: { margin: `${spacing(1)}px 0`, }, chips: { flex: 1, overflow: 'auto', padding: `${spacing(1)}px 0`, overflowX: 'hidden', }, }); dialog = withStyles(this.dialogContentStyles)( ({ classes }: WithStyles) => Choose directories
fileOperationLocationToPickerId(dir))} includeCollections includeDirectories showSelection cascadeSelection={false} options={this.props.options} toggleItemSelection={this.refreshDirectories} />
Selected collections ({this.state.directories.length}): directory.name} />
); }); type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';