[ARVADOS-WORKBENCH2] created: 1.2.0-646-gf2f7301
Git user
git at public.curoverse.com
Sun Oct 14 12:44:53 EDT 2018
at f2f7301dab111f2561332c8cc9e7c06df958ea66 (commit)
commit f2f7301dab111f2561332c8cc9e7c06df958ea66
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date: Sun Oct 14 18:44:45 2018 +0200
Create file array input
Feature #14232
Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
diff --git a/src/models/tree.ts b/src/models/tree.ts
index cce27b1..f0b53b4 100644
--- a/src/models/tree.ts
+++ b/src/models/tree.ts
@@ -139,7 +139,30 @@ export const toggleNodeSelection = (id: string) => <T>(tree: Tree<T>) => {
};
-export const initTreeNode = <T>(data: Pick<TreeNode<T>, 'id' | 'value'> & {parent?: string}): TreeNode<T> => ({
+export const selectNode = (id: string) => <T>(tree: Tree<T>) => {
+ const node = getNode(id)(tree);
+ return node && node.selected
+ ? tree
+ : toggleNodeSelection(id)(tree);
+};
+
+export const selectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
+ const ids = typeof id === 'string' ? [id] : id;
+ return ids.reduce((tree, id) => selectNode(id)(tree), tree);
+};
+export const deselectNode = (id: string) => <T>(tree: Tree<T>) => {
+ const node = getNode(id)(tree);
+ return node && node.selected
+ ? toggleNodeSelection(id)(tree)
+ : tree;
+};
+
+export const deselectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
+ const ids = typeof id === 'string' ? [id] : id;
+ return ids.reduce((tree, id) => deselectNode(id)(tree), tree);
+};
+
+export const initTreeNode = <T>(data: Pick<TreeNode<T>, 'id' | 'value'> & { parent?: string }): TreeNode<T> => ({
children: [],
active: false,
selected: false,
diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts
index 9ca6184..22a31c1 100644
--- a/src/store/tree-picker/tree-picker-actions.ts
+++ b/src/store/tree-picker/tree-picker-actions.ts
@@ -8,11 +8,11 @@ import { Dispatch } from 'redux';
import { RootState } from '~/store/store';
import { ServiceRepository } from '~/services/services';
import { FilterBuilder } from '~/services/api/filter-builder';
-import { pipe } from 'lodash/fp';
+import { pipe, map, values, mapValues } from 'lodash/fp';
import { ResourceKind } from '~/models/resource';
import { GroupContentsResource } from '../../services/groups-service/groups-service';
import { CollectionDirectory, CollectionFile } from '../../models/collection-file';
-import { getTreePicker } from './tree-picker';
+import { getTreePicker, TreePicker } from './tree-picker';
import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
export const treePickerActions = unionize({
@@ -22,6 +22,8 @@ export const treePickerActions = unionize({
ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
+ SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
+ DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
RESET_TREE_PICKER: ofType<{ pickerId: string }>()
});
@@ -33,6 +35,27 @@ export const getProjectsTreePickerIds = (pickerId: string) => ({
shared: `${pickerId}_shared`,
favorites: `${pickerId}_favorites`,
});
+
+export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) => {
+ return pipe(
+ () => values(getProjectsTreePickerIds(pickerId)),
+
+ ids => ids
+ .map(id => getTreePicker<Value>(id)(state)),
+
+ trees => trees
+ .map(getNodeDescendants(''))
+ .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
+
+ allNodes => allNodes
+ .reduce((map, node) => node.selected
+ ? map.set(node.id, node)
+ : map, new Map<string, TreeNode<Value>>())
+ .values(),
+
+ uniqueNodes => Array.from(uniqueNodes),
+ )();
+};
export const initProjectsTreePicker = (pickerId: string) =>
async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
const { home, shared, favorites } = getProjectsTreePickerIds(pickerId);
diff --git a/src/store/tree-picker/tree-picker-reducer.ts b/src/store/tree-picker/tree-picker-reducer.ts
index 2df567e..846e445 100644
--- a/src/store/tree-picker/tree-picker-reducer.ts
+++ b/src/store/tree-picker/tree-picker-reducer.ts
@@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0
-import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode } from '~/models/tree';
+import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode, deselectNode, selectNode, selectNodes, deselectNodes } from '~/models/tree';
import { TreePicker } from "./tree-picker";
import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
import { compose } from "redux";
@@ -22,6 +22,10 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
updateOrCreatePicker(state, pickerId, deactivateNode),
TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId }) =>
updateOrCreatePicker(state, pickerId, toggleNodeSelection(id)),
+ SELECT_TREE_PICKER_NODE: ({ id, pickerId }) =>
+ updateOrCreatePicker(state, pickerId, selectNodes(id)),
+ DESELECT_TREE_PICKER_NODE: ({ id, pickerId }) =>
+ updateOrCreatePicker(state, pickerId, deselectNodes(id)),
RESET_TREE_PICKER: ({ pickerId }) =>
updateOrCreatePicker(state, pickerId, createTree),
EXPAND_TREE_PICKER_NODES: ({ pickerId, ids }) =>
diff --git a/src/views/run-process-panel/inputs/file-array-input.tsx b/src/views/run-process-panel/inputs/file-array-input.tsx
new file mode 100644
index 0000000..39884a6
--- /dev/null
+++ b/src/views/run-process-panel/inputs/file-array-input.tsx
@@ -0,0 +1,282 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+ isRequiredInput,
+ FileArrayCommandInputParameter,
+ File,
+ CWLType
+} from '~/models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from '~/validators/require';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, Grid, 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 } from '~/store/tree-picker/tree-picker-actions';
+import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
+import { CollectionFile, CollectionFileType } from '~/models/collection-file';
+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';
+
+export interface FileArrayInputProps {
+ input: FileArrayCommandInputParameter;
+}
+export const FileArrayInput = ({ input }: FileArrayInputProps) =>
+ <Field
+ name={input.id}
+ commandInput={input}
+ component={FileArrayInputComponent}
+ parse={parseFiles}
+ format={formatFiles}
+ validate={validationSelector(input)} />;
+
+const parseFiles = (files: CollectionFile[]) =>
+ files.length > 0
+ ? files.map(parse)
+ : undefined;
+
+const parse = (file: CollectionFile): File => ({
+ class: CWLType.FILE,
+ basename: file.name,
+ location: `keep:${file.id}`,
+ path: file.path,
+});
+
+const formatFiles = (files: File[] = []) => files.map(format);
+
+const format = (file: File): CollectionFile => ({
+ id: file.location
+ ? file.location.replace('keep:', '')
+ : '',
+ name: file.basename || '',
+ path: file.path || '',
+ size: 0,
+ type: CollectionFileType.FILE,
+ url: '',
+});
+
+const validationSelector = createSelector(
+ isRequiredInput,
+ isRequired => isRequired
+ ? [required]
+ : undefined
+);
+
+const required = (value?: File[]) =>
+ value && value.length > 0
+ ? undefined
+ : ERROR_MESSAGE;
+interface FileArrayInputComponentState {
+ open: boolean;
+ files: CollectionFile[];
+}
+
+interface FileArrayInputComponentProps {
+ treePickerState: TreePicker;
+}
+
+const treePickerSelector = (state: RootState) => state.treePicker;
+
+const mapStateToProps = createStructuredSelector({
+ treePickerState: treePickerSelector,
+});
+
+const FileArrayInputComponent = connect(mapStateToProps)(
+ class FileArrayInputComponent extends React.Component<FileArrayInputComponentProps & GenericInputProps & DispatchProp, FileArrayInputComponentState> {
+ state: FileArrayInputComponentState = {
+ open: false,
+ files: [],
+ };
+
+ fileRefreshTimeout = -1;
+
+ componentDidMount() {
+ this.props.dispatch<any>(
+ initProjectsTreePicker(this.props.commandInput.id));
+ }
+
+ render() {
+ return <>
+ <this.input />
+ <this.dialog />
+ </>;
+ }
+
+ openDialog = () => {
+ this.setFilesFromProps(this.props.input.value);
+ this.setState({ open: true });
+ }
+
+
+ closeDialog = () => {
+ this.setState({ open: false });
+ }
+
+ submit = () => {
+ this.closeDialog();
+ this.props.input.onChange(this.state.files);
+ }
+
+ setFiles = (files: CollectionFile[]) => {
+
+ const deletedFiles = this.state.files
+ .reduce((deletedFiles, file) =>
+ files.some(({ id }) => id === file.id)
+ ? deletedFiles
+ : [...deletedFiles, file]
+ , []);
+
+ this.setState({ files });
+
+ const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+ deletedFiles.forEach(({ id }) => {
+ ids.forEach(pickerId => {
+ this.props.dispatch(
+ treePickerActions.DESELECT_TREE_PICKER_NODE({
+ pickerId, id,
+ })
+ );
+ });
+ });
+
+ }
+
+ setFilesFromProps = (files: CollectionFile[]) => {
+
+ const addedFiles = files
+ .reduce((addedFiles, file) =>
+ this.state.files.some(({ id }) => id === file.id)
+ ? addedFiles
+ : [...addedFiles, file]
+ , []);
+
+ this.setState({ files });
+
+ const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+ addedFiles.forEach(({ id }) => {
+ ids.forEach(pickerId => {
+ this.props.dispatch(
+ treePickerActions.SELECT_TREE_PICKER_NODE({
+ pickerId, id,
+ })
+ );
+ });
+ });
+
+ }
+
+ refreshFiles = () => {
+ clearTimeout(this.fileRefreshTimeout);
+ this.fileRefreshTimeout = setTimeout(this.setSelectedFiles);
+ }
+
+ setSelectedFiles = () => {
+ const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+ const initialFiles: CollectionFile[] = [];
+ const files = nodes
+ .reduce((files, { value }) =>
+ 'type' in value && value.type === CollectionFileType.FILE
+ ? files.concat(value)
+ : files, initialFiles);
+
+ this.setFiles(files);
+ }
+ input = () =>
+ <GenericInput
+ component={this.chipsInput}
+ {...this.props} />
+
+ chipsInput = () =>
+ <ChipsInput
+ value={this.props.input.value}
+ onChange={noop}
+ createNewValue={identity}
+ getLabel={(file: CollectionFile) => file.name}
+ inputComponent={this.textInput} />
+
+ textInput = (props: InputProps) =>
+ <Input
+ {...props}
+ error={this.props.meta.touched && !!this.props.meta.error}
+ readOnly
+ onClick={this.openDialog}
+ onKeyPress={this.openDialog}
+ onBlur={this.props.input.onBlur} />
+
+ dialog = () =>
+ <Dialog
+ open={this.state.open}
+ onClose={this.closeDialog}
+ fullWidth
+ maxWidth='md' >
+ <DialogTitle>Choose files</DialogTitle>
+ <DialogContent>
+ <this.dialogContent />
+ </DialogContent>
+ <DialogActions>
+ <Button onClick={this.closeDialog}>Cancel</Button>
+ <Button
+ variant='contained'
+ color='primary'
+ onClick={this.submit}>Ok</Button>
+ </DialogActions>
+ </Dialog>
+
+ dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: `${spacing.unit * 8}vh`,
+ },
+ tree: {
+ flex: 3,
+ overflow: 'auto',
+ },
+ divider: {
+ margin: `${spacing.unit}px 0`,
+ },
+ chips: {
+ flex: 1,
+ overflow: 'auto',
+ padding: `${spacing.unit}px 0`,
+ overflowX: 'hidden',
+ },
+ })
+
+ dialogContent = withStyles(this.dialogContentStyles)(
+ ({ classes }: WithStyles<DialogContentCssRules>) =>
+ <div className={classes.root}>
+ <div className={classes.tree}>
+ <ProjectsTreePicker
+ pickerId={this.props.commandInput.id}
+ includeCollections
+ includeFiles
+ showSelection
+ toggleItemSelection={this.refreshFiles} />
+ </div>
+ <Divider />
+ <div className={classes.chips}>
+ <Typography variant='subheading'>Selected files ({this.state.files.length}):</Typography>
+ <Chips
+ values={this.state.files}
+ onChange={this.setFiles}
+ getLabel={(file: CollectionFile) => file.name} />
+ </div>
+ </div>
+ );
+
+ });
+
+type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
+
+
+
diff --git a/src/views/run-process-panel/inputs/file-input.tsx b/src/views/run-process-panel/inputs/file-input.tsx
index 838aa51..f5d3d93 100644
--- a/src/views/run-process-panel/inputs/file-input.tsx
+++ b/src/views/run-process-panel/inputs/file-input.tsx
@@ -19,7 +19,6 @@ import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions'
import { TreeItem } from '~/components/tree/tree';
import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
import { CollectionFile, CollectionFileType } from '~/models/collection-file';
-import { getFileFullPath } from '~/services/collection-service/collection-service-files-response';
export interface FileInputProps {
input: FileCommandInputParameter;
diff --git a/src/views/run-process-panel/run-process-inputs-form.tsx b/src/views/run-process-panel/run-process-inputs-form.tsx
index 912be0d..f8c6c1b 100644
--- a/src/views/run-process-panel/run-process-inputs-form.tsx
+++ b/src/views/run-process-panel/run-process-inputs-form.tsx
@@ -7,7 +7,7 @@ import { reduxForm, InjectedFormProps } from 'redux-form';
import { CommandInputParameter, CWLType, IntCommandInputParameter, BooleanCommandInputParameter, FileCommandInputParameter, DirectoryCommandInputParameter } from '~/models/workflow';
import { IntInput } from '~/views/run-process-panel/inputs/int-input';
import { StringInput } from '~/views/run-process-panel/inputs/string-input';
-import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, File, Directory, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter } from '../../models/workflow';
+import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, File, Directory, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter, FileArrayCommandInputParameter } from '../../models/workflow';
import { FloatInput } from '~/views/run-process-panel/inputs/float-input';
import { BooleanInput } from './inputs/boolean-input';
import { FileInput } from './inputs/file-input';
@@ -18,6 +18,7 @@ import { EnumInput } from './inputs/enum-input';
import { DirectoryInput } from './inputs/directory-input';
import { StringArrayInput } from './inputs/string-array-input';
import { createStructuredSelector, createSelector } from 'reselect';
+import { FileArrayInput } from './inputs/file-array-input';
export const RUN_PROCESS_INPUTS_FORM = 'runProcessInputsForm';
@@ -98,6 +99,9 @@ const getInputComponent = (input: CommandInputParameter) => {
case isArrayOfType(input, CWLType.STRING):
return <StringArrayInput input={input as StringArrayCommandInputParameter} />;
+ case isArrayOfType(input, CWLType.FILE):
+ return <FileArrayInput input={input as FileArrayCommandInputParameter} />;
+
default:
return null;
}
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list