merge master
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Mon, 15 Oct 2018 13:32:37 +0000 (15:32 +0200)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Mon, 15 Oct 2018 13:32:37 +0000 (15:32 +0200)
Feature #14277

Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>

24 files changed:
package.json
src/components/chips-input/chips-input.tsx [new file with mode: 0644]
src/components/chips/chips.tsx [new file with mode: 0644]
src/components/select-field/select-field.tsx [new file with mode: 0644]
src/components/text-field/text-field.tsx
src/index.tsx
src/models/search-bar.ts [new file with mode: 0644]
src/models/tree.ts
src/models/workflow.ts
src/store/search-bar/search-bar-actions.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-reducer.ts
src/views-components/form-fields/search-bar-form-fields.tsx
src/views-components/search-bar/search-bar-advanced-view.tsx
src/views-components/search-bar/search-bar-view.tsx
src/views-components/search-bar/search-bar.tsx
src/views/run-process-panel/inputs/directory-array-input.tsx [new file with mode: 0644]
src/views/run-process-panel/inputs/file-array-input.tsx [new file with mode: 0644]
src/views/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/inputs/string-array-input.tsx [new file with mode: 0644]
src/views/run-process-panel/run-process-inputs-form.tsx
src/views/run-process-panel/run-process-second-step.tsx
src/views/workbench/workbench.tsx
yarn.lock

index d28ec550fd9458211eed6cfd194c1c486133ca74..8ed84dd9b818b121be780d176760b7cc3fc40922 100644 (file)
@@ -8,9 +8,11 @@
     "@types/js-yaml": "3.11.2",
     "@types/lodash": "4.14.116",
     "@types/react-copy-to-clipboard": "4.2.6",
+    "@types/react-dnd": "3.0.2",
     "@types/react-dropzone": "4.2.2",
     "@types/react-highlight-words": "0.12.0",
     "@types/redux-form": "7.4.5",
+    "@types/reselect": "2.2.0",
     "@types/shell-quote": "1.6.0",
     "axios": "0.18.0",
     "classnames": "2.2.6",
@@ -18,6 +20,8 @@
     "lodash": "4.17.11",
     "react": "16.5.2",
     "react-copy-to-clipboard": "5.0.1",
+    "react-dnd": "5.0.0",
+    "react-dnd-html5-backend": "5.0.1",
     "react-dom": "16.5.2",
     "react-dropzone": "5.1.1",
     "react-highlight-words": "0.14.0",
diff --git a/src/components/chips-input/chips-input.tsx b/src/components/chips-input/chips-input.tsx
new file mode 100644 (file)
index 0000000..fc5fda0
--- /dev/null
@@ -0,0 +1,139 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Chips } from '~/components/chips/chips';
+import { Input as MuiInput, withStyles, WithStyles } from '@material-ui/core';
+import { StyleRulesCallback } from '@material-ui/core/styles';
+import { InputProps } from '@material-ui/core/Input';
+
+interface ChipsInputProps<Value> {
+    value: Value[];
+    getLabel?: (value: Value) => string;
+    onChange: (value: Value[]) => void;
+    createNewValue: (value: string) => Value;
+    inputComponent?: React.ComponentType<InputProps>;
+    inputProps?: InputProps;
+    deletable?: boolean;
+    orderable?: boolean;
+}
+
+type CssRules = 'chips' | 'input' | 'inputContainer';
+
+const styles: StyleRulesCallback = ({ spacing }) => ({
+    chips: {
+        minHeight: spacing.unit * 5,
+        zIndex: 1,
+        position: 'relative',
+    },
+    input: {
+        zIndex: 1,
+        marginBottom: 8,
+        position: 'relative',
+    },
+    inputContainer: {
+        marginTop: -34
+    },
+});
+
+export const ChipsInput = withStyles(styles)(
+    class ChipsInput<Value> extends React.Component<ChipsInputProps<Value> & WithStyles<CssRules>> {
+
+        state = {
+            text: '',
+        };
+
+        filler = React.createRef<HTMLDivElement>();
+        timeout = -1;
+
+        setText = (event: React.ChangeEvent<HTMLInputElement>) => {
+            this.setState({ text: event.target.value });
+        }
+
+        handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
+            if (key === 'Enter') {
+                this.createNewValue();
+            } else if (key === 'Backspace') {
+                this.deleteLastValue();
+            }
+        }
+
+        createNewValue = () => {
+            if (this.state.text) {
+                const newValue = this.props.createNewValue(this.state.text);
+                this.setState({ text: '' });
+                this.props.onChange([...this.props.value, newValue]);
+            }
+        }
+
+        deleteLastValue = () => {
+            if (this.state.text.length === 0 && this.props.value.length > 0) {
+                this.props.onChange(this.props.value.slice(0, -1));
+            }
+        }
+
+        updateCursorPosition = () => {
+            if (this.timeout) {
+                clearTimeout(this.timeout);
+            }
+            this.timeout = setTimeout(() => this.setState({ ...this.state }));
+        }
+
+        getInputStyles = (): React.CSSProperties => ({
+            width: this.filler.current
+                ? this.filler.current.offsetWidth
+                : '100%',
+            right: this.filler.current
+                ? `calc(${this.filler.current.offsetWidth}px - 100%)`
+                : 0,
+
+        })
+
+        componentDidMount() {
+            this.updateCursorPosition();
+        }
+
+        render() {
+            return <>
+                {this.renderChips()}
+                {this.renderInput()}
+            </>;
+        }
+
+        renderChips() {
+            const { classes, value, ...props } = this.props;
+            return <div className={classes.chips}>
+                <Chips
+                    {...props}
+                    values={value}
+                    filler={<div ref={this.filler} />}
+                />
+            </div>;
+        }
+
+        renderInput() {
+            const { inputProps: InputProps, inputComponent: Input = MuiInput, classes } = this.props;
+            return <Input
+                {...InputProps}
+                value={this.state.text}
+                onChange={this.setText}
+                onKeyDown={this.handleKeyPress}
+                inputProps={{
+                    ...(InputProps && InputProps.inputProps),
+                    className: classes.input,
+                    style: this.getInputStyles(),
+                }}
+                fullWidth
+                className={classes.inputContainer} />;
+        }
+
+        componentDidUpdate(prevProps: ChipsInputProps<Value>) {
+            if (prevProps.value !== this.props.value) {
+                this.updateCursorPosition();
+            }
+        }
+        componentWillUnmount() {
+            clearTimeout(this.timeout);
+        }
+    });
diff --git a/src/components/chips/chips.tsx b/src/components/chips/chips.tsx
new file mode 100644 (file)
index 0000000..36cf241
--- /dev/null
@@ -0,0 +1,124 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Chip, Grid, StyleRulesCallback, withStyles } from '@material-ui/core';
+import { DragSource, DragSourceSpec, DragSourceCollector, ConnectDragSource, DropTarget, DropTargetSpec, DropTargetCollector, ConnectDropTarget } from 'react-dnd';
+import { compose, noop } from 'lodash/fp';
+import { WithStyles } from '@material-ui/core/styles';
+interface ChipsProps<Value> {
+    values: Value[];
+    getLabel?: (value: Value) => string;
+    filler?: React.ReactNode;
+    deletable?: boolean;
+    orderable?: boolean;
+    onChange: (value: Value[]) => void;
+}
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = ({ spacing }) => ({
+    root: {
+        margin: `0px -${spacing.unit / 2}px`,
+    },
+});
+export const Chips = withStyles(styles)(
+    class Chips<Value> extends React.Component<ChipsProps<Value> & WithStyles<CssRules>> {
+        render() {
+            const { values, filler } = this.props;
+            return <Grid container spacing={8} className={this.props.classes.root}>
+                {values.map(this.renderChip)}
+                {filler && <Grid item xs>{filler}</Grid>}
+            </Grid>;
+        }
+
+        renderChip = (value: Value, index: number) =>
+            <Grid item key={index}>
+                <this.chip {...{ value }} />
+            </Grid>
+
+        type = 'chip';
+
+        dragSpec: DragSourceSpec<DraggableChipProps<Value>, { value: Value }> = {
+            beginDrag: ({ value }) => ({ value }),
+            endDrag: ({ value: dragValue }, monitor) => {
+                const result = monitor.getDropResult();
+                if (result) {
+                    const { value: dropValue } = monitor.getDropResult();
+                    const dragIndex = this.props.values.indexOf(dragValue);
+                    const dropIndex = this.props.values.indexOf(dropValue);
+                    const newValues = this.props.values.slice(0);
+                    if (dragIndex < dropIndex) {
+                        newValues.splice(dragIndex, 1);
+                        newValues.splice(dropIndex - 1 || 0, 0, dragValue);
+                    } else if (dragIndex > dropIndex) {
+                        newValues.splice(dragIndex, 1);
+                        newValues.splice(dropIndex, 0, dragValue);
+                    }
+                    this.props.onChange(newValues);
+                }
+            }
+        };
+
+        dragCollector: DragSourceCollector<{}> = connect => ({
+            connectDragSource: connect.dragSource(),
+        })
+
+        dropSpec: DropTargetSpec<DraggableChipProps<Value>> = {
+            drop: ({ value }) => ({ value }),
+        };
+
+        dropCollector: DropTargetCollector<{}> = (connect, monitor) => ({
+            connectDropTarget: connect.dropTarget(),
+            isOver: monitor.isOver(),
+        })
+        chip = compose(
+            DragSource(this.type, this.dragSpec, this.dragCollector),
+            DropTarget(this.type, this.dropSpec, this.dropCollector),
+        )(
+            ({ connectDragSource, connectDropTarget, isOver, value }: DraggableChipProps<Value> & CollectedProps) => {
+                const connect = compose(
+                    connectDragSource,
+                    connectDropTarget,
+                );
+
+                const chip =
+                    <span>
+                        <Chip
+                            color={isOver ? 'primary' : 'default'}
+                            onDelete={this.props.deletable
+                                ? this.deleteValue(value)
+                                : undefined}
+                            label={this.props.getLabel ?
+                                this.props.getLabel(value)
+                                : typeof value === 'object'
+                                    ? JSON.stringify(value)
+                                    : value} />
+                    </span>;
+
+                return this.props.orderable
+                    ? connect(chip)
+                    : chip;
+            }
+        );
+
+        deleteValue = (value: Value) => () => {
+            const { values } = this.props;
+            const index = values.indexOf(value);
+            const newValues = values.slice(0);
+            newValues.splice(index, 1);
+            this.props.onChange(newValues);
+        }
+    });
+
+interface CollectedProps {
+    connectDragSource: ConnectDragSource;
+    connectDropTarget: ConnectDropTarget;
+
+    isOver: boolean;
+}
+
+interface DraggableChipProps<Value> {
+    value: Value;
+}
\ No newline at end of file
diff --git a/src/components/select-field/select-field.tsx b/src/components/select-field/select-field.tsx
new file mode 100644 (file)
index 0000000..1c3dec3
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WrappedFieldProps } from 'redux-form';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { StyleRulesCallback, WithStyles, withStyles, FormControl, InputLabel, Select, MenuItem } from '@material-ui/core';
+
+type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    formControl: {
+        width: '100%'
+    },
+    selectWrapper: {
+        backgroundColor: theme.palette.common.white,
+        '&:before': {
+            borderBottomColor: 'rgba(0, 0, 0, 0.42)'
+        },
+        '&:focus': {
+            outline: 'none'
+        }
+    },
+    select: {
+        fontSize: '0.875rem',
+        '&:focus': {
+            backgroundColor: 'rgba(0, 0, 0, 0.0)'
+        }
+    },
+    option: {
+        fontSize: '0.875rem',
+        backgroundColor: theme.palette.common.white,
+        height: '30px'
+    }
+});
+
+export const NativeSelectField = withStyles(styles)
+    ((props: WrappedFieldProps & WithStyles<CssRules> & { items: any[] }) =>
+        <FormControl className={props.classes.formControl}>
+            <Select className={props.classes.selectWrapper}
+                native
+                value={props.input.value}
+                onChange={props.input.onChange}
+                disabled={props.meta.submitting}
+                name={props.input.name}
+                inputProps={{
+                    id: `id-${props.input.name}`,
+                    className: props.classes.select
+                }}>
+                {props.items.map(item => (
+                    <option key={item.key} value={item.key} className={props.classes.option}>
+                        {item.value}
+                    </option>
+                ))}
+            </Select>
+        </FormControl>
+    );
\ No newline at end of file
index 4d8c012f9edec158ada5549b9b3cfde0dde3b368..13bb1e4f136bcd9f8d6f91d24c85b56bde779ab3 100644 (file)
@@ -55,4 +55,22 @@ export const RichEditorTextField = withStyles(styles)(
                 placeholder={this.props.label} />;
         }
     }
-);
\ No newline at end of file
+);
+
+type DateTextFieldProps = WrappedFieldProps & WithStyles<CssRules>;
+
+export const DateTextField = withStyles(styles)
+    ((props: DateTextFieldProps) => 
+        <MaterialTextField
+            disabled={props.meta.submitting}
+            error={props.meta.touched && !!props.meta.error}
+            type="date"
+            fullWidth={true}
+            name={props.input.name}
+            InputLabelProps={{
+                shrink: true
+            }}
+            onChange={props.input.onChange}
+            value={props.input.value}
+        />    
+    );
\ No newline at end of file
index 1d072d9d5d9b531e7108b9509f5faa4057d0b228..62557f6150113632ece7ecf60faf7cabe8afd540 100644 (file)
@@ -42,9 +42,10 @@ import { setUuidPrefix } from '~/store/workflow-panel/workflow-panel-actions';
 import { trashedCollectionActionSet } from '~/views-components/context-menu/action-sets/trashed-collection-action-set';
 import { ContainerRequestState } from '~/models/container-request';
 import { MountKind } from '~/models/mount-types';
-import { initProjectsTreePicker } from './store/tree-picker/tree-picker-actions';
 import { setBuildInfo } from '~/store/app-info/app-info-actions';
 import { getBuildInfo } from '~/common/app-info';
+import { DragDropContextProvider } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -86,14 +87,16 @@ fetchConfig()
 
         const App = () =>
             <MuiThemeProvider theme={CustomTheme}>
-                <Provider store={store}>
-                    <ConnectedRouter history={history}>
-                        <div>
-                            <Route path={Routes.TOKEN} component={TokenComponent} />
-                            <Route path={Routes.ROOT} component={MainPanelComponent} />
-                        </div>
-                    </ConnectedRouter>
-                </Provider>
+                <DragDropContextProvider backend={HTML5Backend}>
+                    <Provider store={store}>
+                        <ConnectedRouter history={history}>
+                            <div>
+                                <Route path={Routes.TOKEN} component={TokenComponent} />
+                                <Route path={Routes.ROOT} component={MainPanelComponent} />
+                            </div>
+                        </ConnectedRouter>
+                    </Provider>
+                </DragDropContextProvider>
             </MuiThemeProvider>;
 
         ReactDOM.render(
@@ -115,6 +118,22 @@ const initListener = (history: History, store: RootStore, services: ServiceRepos
     };
 };
 
+const createDirectoriesArrayCollectorWorkflow = ({workflowService}: ServiceRepository) => {
+    workflowService.create({
+        name: 'Directories array collector',
+        description: 'Workflow for collecting directories array',
+        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"multiple_collections\":\n          $(inputs.multiple_collections)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n      type: array\n      items: Directory\n    id: '#input_collector.cwl/multiple_collections'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [echo]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n      type: array\n      items: Directory\n    label: Multiple Collections\n    doc: This should allow for selecting multiple collections.\n    id: '#main/multiple_collections'\n    default:\n    - class: Directory\n      location: keep:1e1682585d576f031b2d8b4944f989ee+57\n      basename: 1e1682585d576f031b2d8b4944f989ee+57\n    - class: Directory\n      location: keep:326f692370e9e121fcbd013796f7352a+57\n      basename: 326f692370e9e121fcbd013796f7352a+57\n  \n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/multiple_collections'\n      id: '#main/input_collector/multiple_collections'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
+    });
+};
+
+const createFilesArrayCollectorWorkflow = ({workflowService}: ServiceRepository) => {
+    workflowService.create({
+        name: 'Files array collector',
+        description: 'Workflow for collecting files array',
+        definition: "cwlVersion: v1.0\n$graph:\n- class: CommandLineTool\n\n  requirements:\n  - listing:\n    - entryname: input_collector.log\n      entry: |\n        \"multiple_files\":\n          $(inputs.multiple_files)\n\n    class: InitialWorkDirRequirement\n  inputs:\n  - type:\n      type: array\n      items: File\n    id: '#input_collector.cwl/multiple_files'\n  outputs:\n  - type: File\n    outputBinding:\n      glob: '*'\n    id: '#input_collector.cwl/output'\n\n  baseCommand: [cat]\n  id: '#input_collector.cwl'\n- class: Workflow\n  doc: This is the description of the workflow\n  inputs:\n  - type:\n      type: array\n      items: File\n    label: Multiple Files\n    doc: This should allow for selecting multiple files.\n    id: '#main/multiple_files'\n    default:\n      - class: File\n        location: keep:af831660d820bcbb98f473355e6e1b85+67/fileA\n        basename: fileA\n        nameroot: fileA\n        nameext: ''\n  outputs:\n  - type: File\n    outputSource: '#main/input_collector/output'\n\n    id: '#main/log_file'\n  steps:\n  - run: '#input_collector.cwl'\n    in:\n    - source: '#main/multiple_files'\n      id: '#main/input_collector/multiple_files'\n    out: ['#main/input_collector/output']\n    id: '#main/input_collector'\n  id: '#main'\n",
+    });
+};
+
 const createPrimitivesCollectorWorkflow = ({ workflowService }: ServiceRepository) => {
     workflowService.create({
         name: 'Primitive values collector',
diff --git a/src/models/search-bar.ts b/src/models/search-bar.ts
new file mode 100644 (file)
index 0000000..9fadc2a
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from '~/models/resource';
+
+export interface SearchBarAdvanceFormData {
+    type?: ResourceKind;
+    cluster?: ClusterObjectType;
+    project?: string;
+    inTrash: boolean;
+    dateFrom: string;
+    dateTo: string;
+    saveQuery: boolean;
+    searchQuery: string;
+}
+
+export enum ClusterObjectType {
+    INDIANAPOLIS = "indianapolis",
+    KAISERAUGST = "kaiseraugst",
+    PENZBERG = "penzberg"
+}
\ No newline at end of file
index cce27b125a1053c053b6df04227bd0e4e23a52df..f0b53b46f46fd03906cfd81cde9ce63f45a0028a 100644 (file)
@@ -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,
index d7d97c4c962401bfc7bd5ff655ebe88fdaf4d92d..59915137427d118167dd14d174574a3fcbdbf3f6 100644 (file)
@@ -141,6 +141,12 @@ export const isPrimitiveOfType = (input: GenericCommandInputParameter<any, any>,
         ? input.type.indexOf(type) > -1
         : input.type === type;
 
+export const isArrayOfType = (input: GenericCommandInputParameter<any, any>, type: CWLType) =>
+    typeof input.type === 'object' &&
+        input.type.type === 'array'
+        ? input.type.items === type
+        : false;
+
 export const stringifyInputType = ({ type }: CommandInputParameter) => {
     if (typeof type === 'string') {
         return type;
index 394920a5d7c776aaec41841c0d13161fb5701446..771cbc891da17de2ba7b995721ac388c2c63a483 100644 (file)
@@ -13,6 +13,7 @@ import { GroupClass } from '~/models/group';
 import { SearchView } from '~/store/search-bar/search-bar-reducer';
 import { navigateToSearchResults, navigateTo } from '~/store/navigation/navigation-action';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { SearchBarAdvanceFormData } from '~/models/search-bar';
 
 export const searchBarActions = unionize({
     SET_CURRENT_VIEW: ofType<string>(),
@@ -25,17 +26,6 @@ export const searchBarActions = unionize({
 
 export type SearchBarActions = UnionOf<typeof searchBarActions>;
 
-export interface SearchBarAdvanceFormData {
-    type?: GroupContentsResource;
-    cluster?: string;
-    project?: string;
-    inTrash: boolean;
-    dataFrom: string;
-    dataTo: string;
-    saveQuery: boolean;
-    searchQuery: string;
-}
-
 export const SEARCH_BAR_ADVANCE_FORM_NAME = 'searchBarAdvanceFormName';
 
 export const goToView = (currentView: string) => searchBarActions.SET_CURRENT_VIEW(currentView);
@@ -75,12 +65,16 @@ export const openSearchView = () =>
         dispatch(searchBarActions.SET_SAVED_QUERIES(savedSearchQueries));
     };
 
-export const closeSearchView = () =>
+export const closeSearchView = () => 
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
-        dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
+        const isOpen = getState().searchBar.open;
+        if(isOpen) {
+            dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+            dispatch(searchBarActions.SET_CURRENT_VIEW(SearchView.BASIC));
+        }
     };
 
+
 export const navigateToItem = (uuid: string) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
index 9ca6184acd1b2410a4015d0672965fb9c25350d9..a988e8961733545dbc075c2e793bcbb537e8b968 100644 (file)
@@ -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,30 @@ export const getProjectsTreePickerIds = (pickerId: string) => ({
     shared: `${pickerId}_shared`,
     favorites: `${pickerId}_favorites`,
 });
+
+export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
+    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) =>
+                filter(node)
+                    ? map.set(node.id, node)
+                    : map, new Map<string, TreeNode<Value>>())
+            .values(),
+
+        uniqueNodes => Array.from(uniqueNodes),
+    )();
+export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
+    getAllNodes<Value>(pickerId, node => node.selected)(state);
+    
 export const initProjectsTreePicker = (pickerId: string) =>
     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
         const { home, shared, favorites } = getProjectsTreePickerIds(pickerId);
index 2df567efeef0e0390272a7c5522473c31ad962f6..846e445633328256d66b3e4e07e32beb4411d689 100644 (file)
@@ -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 }) =>
index 210affcfdcf6db232177283554bf2eee98dd78c8..4a7c4b11f5bdce86940fc7820abfd498b3ea778f 100644 (file)
@@ -4,26 +4,36 @@
 
 import * as React from "react";
 import { Field } from 'redux-form';
-import { TextField } from "~/components/text-field/text-field";
+import { TextField, DateTextField } from "~/components/text-field/text-field";
 import { CheckboxField } from '~/components/checkbox-field/checkbox-field';
+import { NativeSelectField } from '~/components/select-field/select-field';
+import { ResourceKind } from '~/models/resource';
+import { ClusterObjectType } from '~/models/search-bar';
 
 export const SearchBarTypeField = () =>
     <Field
         name='type'
-        component={TextField}
-        label="Type"/>;
+        component={NativeSelectField}
+        items={[
+            { key: '', value: 'Any'},
+            { key: ResourceKind.COLLECTION, value: 'Collection'},
+            { key: ResourceKind.PROJECT, value: 'Project' },
+            { key: ResourceKind.PROCESS, value: 'Process' }
+        ]}/>;
 
 export const SearchBarClusterField = () =>
     <Field
         name='cluster'
-        component={TextField}
-        label="Cluster name" />;
+        component={NativeSelectField}
+        items={[
+            { key: '', value: 'Any' },
+            { key: ClusterObjectType.INDIANAPOLIS, value: 'Indianapolis' },
+            { key: ClusterObjectType.KAISERAUGST, value: 'Kaiseraugst' },
+            { key: ClusterObjectType.PENZBERG, value: 'Penzberg' }
+        ]} />;
 
 export const SearchBarProjectField = () => 
-    <Field
-        name='project'
-        component={TextField}
-        label="Project name" />;
+    <div>Box</div>;
 
 export const SearchBarTrashField = () => 
     <Field
@@ -33,15 +43,13 @@ export const SearchBarTrashField = () =>
 
 export const SearchBarDataFromField = () => 
     <Field
-        name='dataFrom'
-        component={TextField}
-        label="From" />;
+        name='dateFrom'
+        component={DateTextField} />;
 
 export const SearchBarDataToField = () =>
     <Field
-        name='dataTo'
-        component={TextField}
-        label="To" />;
+        name='dateTo'
+        component={DateTextField} />;
 
 export const SearchBarKeyField = () => 
     <Field
index d96062ea9325b6bf4876bc4d5b51742c58c65ad5..4d9bd97bcfaec524539757d87ff7c956e2d78177 100644 (file)
@@ -7,23 +7,22 @@ import { reduxForm, reset, InjectedFormProps } from 'redux-form';
 import { compose, Dispatch } from 'redux';
 import { Paper, StyleRulesCallback, withStyles, WithStyles, Button, Grid, IconButton, CircularProgress } from '@material-ui/core';
 import { SearchView } from '~/store/search-bar/search-bar-reducer';
-import { SEARCH_BAR_ADVANCE_FORM_NAME, SearchBarAdvanceFormData, saveQuery } from '~/store/search-bar/search-bar-actions';
+import { SEARCH_BAR_ADVANCE_FORM_NAME, saveQuery } from '~/store/search-bar/search-bar-actions';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { CloseIcon } from '~/components/icon/icon';
+import { SearchBarAdvanceFormData } from '~/models/search-bar';
 import { 
     SearchBarTypeField, SearchBarClusterField, SearchBarProjectField, SearchBarTrashField, 
     SearchBarDataFromField, SearchBarDataToField, SearchBarKeyField, SearchBarValueField,
     SearchBarSaveSearchField, SearchBarQuerySearchField
 } from '~/views-components/form-fields/search-bar-form-fields';
 
-type CssRules = 'form' | 'container' | 'closeIcon' | 'label' | 'buttonWrapper' | 'button' | 'circularProgress' | 'searchView';
+type CssRules = 'container' | 'closeIcon' | 'label' | 'buttonWrapper' 
+    | 'button' | 'circularProgress' | 'searchView' | 'selectGrid';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    form: {
-
-    },
     container: {
-        padding: theme.spacing.unit * 3,
+        padding: theme.spacing.unit * 2,
         borderBottom: `1px solid ${theme.palette.grey["200"]}`
     }, 
     closeIcon: {
@@ -37,6 +36,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         alignSelf: 'center'
     },
     buttonWrapper: {
+        paddingRight: '14px',
         paddingTop: '14px',
         position: 'relative',
     },
@@ -54,6 +54,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     searchView: {
         color: theme.palette.common.black,
         borderRadius: `0 0 ${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px`
+    },
+    selectGrid: {
+        marginBottom: theme.spacing.unit * 2
     }
 });
 
@@ -82,16 +85,16 @@ export const SearchBarAdvancedView = compose(
     withStyles(styles))(
         ({ classes, setView, handleSubmit, invalid, submitting, pristine }: SearchBarAdvancedViewProps) =>
             <Paper className={classes.searchView}>
-                <form onSubmit={handleSubmit} className={classes.form}>
+                <form onSubmit={handleSubmit}>
                     <Grid container direction="column" justify="flex-start" alignItems="flex-start">
                         <Grid item xs={12} container className={classes.container}>
-                            <Grid item container xs={12}>
+                            <Grid item container xs={12} className={classes.selectGrid}>
                                 <Grid item xs={2} className={classes.label}>Type</Grid>
                                 <Grid item xs={5}>
                                     <SearchBarTypeField />
                                 </Grid>
                             </Grid>
-                            <Grid item container xs={12}>
+                            <Grid item container xs={12} className={classes.selectGrid}>
                                 <Grid item xs={2} className={classes.label}>Cluster</Grid>
                                 <Grid item xs={5}>
                                     <SearchBarClusterField />
@@ -113,17 +116,17 @@ export const SearchBarAdvancedView = compose(
                                 <CloseIcon />
                             </IconButton>
                         </Grid>
-                        <Grid container item xs={12} className={classes.container}>
+                        <Grid container item xs={12} className={classes.container} spacing={16}>
                             <Grid item xs={2} className={classes.label}>Data modified</Grid>
-                            <Grid item xs={3}>
+                            <Grid item xs={4}>
                                 <SearchBarDataFromField />
                             </Grid>
-                            <Grid item xs={3}>
+                            <Grid item xs={4}>
                                 <SearchBarDataToField />
                             </Grid>
                         </Grid>
                         <Grid container item xs={12} className={classes.container}>
-                            <Grid container item xs={12}>
+                            <Grid container item xs={12} spacing={16}>
                                 <Grid item xs={2} className={classes.label}>Properties</Grid>
                                 <Grid item xs={4}>
                                     <SearchBarKeyField />
@@ -140,7 +143,7 @@ export const SearchBarAdvancedView = compose(
                                     </Button>
                                 </Grid>
                             </Grid>
-                            <Grid container item xs={12} justify="flex-start" alignItems="center">
+                            <Grid container item xs={12} justify="flex-start" alignItems="center" spacing={16}>
                                 <Grid item xs={2} className={classes.label} />
                                 <Grid item xs={4}>
                                     <SearchBarSaveSearchField />
index 51062646cdc1965e4797e0b7d52ebc8a408f487d..8e1f84b01fabdbe0661e2bdaeebc5e7838ba8d40 100644 (file)
@@ -21,7 +21,7 @@ import { SearchBarBasicView } from '~/views-components/search-bar/search-bar-bas
 import { SearchBarAdvancedView } from '~/views-components/search-bar/search-bar-advanced-view';
 import { SearchBarAutocompleteView, SearchBarAutocompleteViewDataProps } from '~/views-components/search-bar/search-bar-autocomplete-view';
 import { ArvadosTheme } from '~/common/custom-theme';
-import { SearchBarAdvanceFormData } from '~/store/search-bar/search-bar-actions';
+import { SearchBarAdvanceFormData } from '~/models/search-bar';
 
 type CssRules = 'container' | 'containerSearchViewOpened' | 'input' | 'view';
 
index 5c42af20caa03e3b88db25f7d55e4debeaae5374..5208a7d61c0259e2ae2724ebc9510edf2ed5b403 100644 (file)
@@ -12,11 +12,12 @@ import {
     saveRecentQuery,
     loadRecentQueries,
     saveQuery,
-    openSearchView
+    openSearchView,
+    closeSearchView,
+    navigateToItem
 } from '~/store/search-bar/search-bar-actions';
 import { SearchBarView } from '~/views-components/search-bar/search-bar-view';
-import { SearchBarAdvanceFormData } from '~/store/search-bar/search-bar-actions';
-import { closeSearchView, navigateToItem } from '~/store/search-bar/search-bar-actions';
+import { SearchBarAdvanceFormData } from '~/models/search-bar';
 
 const mapStateToProps = ({ searchBar }: RootState) => {
     return {
diff --git a/src/views/run-process-panel/inputs/directory-array-input.tsx b/src/views/run-process-panel/inputs/directory-array-input.tsx
new file mode 100644 (file)
index 0000000..d4f4cb6
--- /dev/null
@@ -0,0 +1,298 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as 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, 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, getAllNodes } from '~/store/tree-picker/tree-picker-actions';
+import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
+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 { ResourceKind } from '~/models/resource';
+
+export interface DirectoryArrayInputProps {
+    input: DirectoryArrayCommandInputParameter;
+}
+
+export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={DirectoryArrayInputComponent}
+        parse={parseDirectories}
+        format={formatDirectories}
+        validate={validationSelector(input)} />;
+
+interface FormattedDirectory {
+    name: string;
+    portableDataHash: string;
+}
+
+const parseDirectories = (directories: CollectionResource[] | string) =>
+    typeof directories === 'string'
+        ? undefined
+        : directories.map(parse);
+
+const parse = (directory: CollectionResource): Directory => ({
+    class: CWLType.DIRECTORY,
+    basename: directory.name,
+    location: `keep:${directory.portableDataHash}`,
+});
+
+const formatDirectories = (directories: Directory[] = []) =>
+    directories.map(format);
+
+const format = ({ location = '', basename = '' }: Directory): FormattedDirectory => ({
+    portableDataHash: location.replace('keep:', ''),
+    name: basename,
+});
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value?: Directory[]) =>
+    value && value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+interface DirectoryArrayInputComponentState {
+    open: boolean;
+    directories: CollectionResource[];
+    prevDirectories: CollectionResource[];
+}
+
+interface DirectoryArrayInputComponentProps {
+    treePickerState: TreePicker;
+}
+
+const treePickerSelector = (state: RootState) => state.treePicker;
+
+const mapStateToProps = createStructuredSelector({
+    treePickerState: treePickerSelector,
+});
+
+const DirectoryArrayInputComponent = connect(mapStateToProps)(
+    class DirectoryArrayInputComponent extends React.Component<DirectoryArrayInputComponentProps & GenericInputProps & DispatchProp, DirectoryArrayInputComponentState> {
+        state: DirectoryArrayInputComponentState = {
+            open: false,
+            directories: [],
+            prevDirectories: [],
+        };
+
+        directoryRefreshTimeout = -1;
+
+        componentDidMount() {
+            this.props.dispatch<any>(
+                initProjectsTreePicker(this.props.commandInput.id));
+        }
+
+        render() {
+            return <>
+                <this.input />
+                <this.dialog />
+            </>;
+        }
+
+        openDialog = () => {
+            this.setDirectoriesFromProps(this.props.input.value);
+            this.setState({ open: true });
+        }
+
+
+        closeDialog = () => {
+            this.setState({ open: false });
+        }
+
+        submit = () => {
+            this.closeDialog();
+            this.props.input.onChange(this.state.directories);
+        }
+
+        setDirectories = (directories: CollectionResource[]) => {
+
+            const deletedDirectories = this.state.directories
+                .reduce((deletedDirectories, directory) =>
+                    directories.some(({ uuid }) => uuid === directory.uuid)
+                        ? deletedDirectories
+                        : [...deletedDirectories, directory]
+                    , []);
+
+            this.setState({ directories });
+
+            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+            ids.forEach(pickerId => {
+                this.props.dispatch(
+                    treePickerActions.DESELECT_TREE_PICKER_NODE({
+                        pickerId, id: deletedDirectories.map(({ uuid }) => uuid),
+                    })
+                );
+            });
+
+        }
+
+        setDirectoriesFromProps = (formattedDirectories: FormattedDirectory[]) => {
+            const nodes = getAllNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+            const initialDirectories: CollectionResource[] = [];
+            const directories = nodes
+                .reduce((directories, { value }) =>
+                    'kind' in value &&
+                        value.kind === ResourceKind.COLLECTION &&
+                        formattedDirectories.find(({ portableDataHash }) => value.portableDataHash === portableDataHash)
+                        ? directories.concat(value)
+                        : directories, initialDirectories);
+
+            const addedDirectories = directories
+                .reduce((addedDirectories, directory) =>
+                    this.state.directories.find(({ uuid }) =>
+                        uuid === directory.uuid)
+                        ? addedDirectories
+                        : [...addedDirectories, directory]
+                    , []);
+
+            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+            ids.forEach(pickerId => {
+                this.props.dispatch(
+                    treePickerActions.SELECT_TREE_PICKER_NODE({
+                        pickerId, id: addedDirectories.map(({ uuid }) => uuid),
+                    })
+                );
+            });
+
+            const orderedDirectories = formattedDirectories.reduce((dirs, formattedDir) => {
+                const dir = directories.find(({ portableDataHash }) => portableDataHash === formattedDir.portableDataHash);
+                return dir
+                    ? [...dirs, dir]
+                    : dirs;
+            }, []);
+
+            this.setDirectories(orderedDirectories);
+
+        }
+
+        refreshDirectories = () => {
+            clearTimeout(this.directoryRefreshTimeout);
+            this.directoryRefreshTimeout = setTimeout(this.setSelectedFiles);
+        }
+
+        setSelectedFiles = () => {
+            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+            const initialDirectories: CollectionResource[] = [];
+            const directories = nodes
+                .reduce((directories, { value }) =>
+                    'kind' in value && value.kind === ResourceKind.COLLECTION
+                        ? directories.concat(value)
+                        : directories, initialDirectories);
+            this.setDirectories(directories);
+        }
+        input = () =>
+            <GenericInput
+                component={this.chipsInput}
+                {...this.props} />
+
+        chipsInput = () =>
+            <ChipsInput
+                value={this.props.input.value}
+                onChange={noop}
+                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.openDialog}
+                onKeyPress={this.openDialog}
+                onBlur={this.props.input.onBlur} />
+
+        dialog = () =>
+            <Dialog
+                open={this.state.open}
+                onClose={this.closeDialog}
+                fullWidth
+                maxWidth='md' >
+                <DialogTitle>Choose collections</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
+                            showSelection
+                            toggleItemSelection={this.refreshDirectories} />
+                    </div>
+                    <Divider />
+                    <div className={classes.chips}>
+                        <Typography variant='subheading'>Selected collections ({this.state.directories.length}):</Typography>
+                        <Chips
+                            orderable
+                            deletable
+                            values={this.state.directories}
+                            onChange={this.setDirectories}
+                            getLabel={(directory: CollectionResource) => directory.name} />
+                    </div>
+                </div>
+        );
+
+    });
+
+type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
+
+
+
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 (file)
index 0000000..48fc42d
--- /dev/null
@@ -0,0 +1,281 @@
+// 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[] | string) =>
+    typeof files === 'string'
+        ? undefined
+        : files.map(parse);
+
+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));
+            ids.forEach(pickerId => {
+                this.props.dispatch(
+                    treePickerActions.DESELECT_TREE_PICKER_NODE({
+                        pickerId, id: deletedFiles.map(({ id }) => id),
+                    })
+                );
+            });
+
+        }
+
+        setFilesFromProps = (files: CollectionFile[]) => {
+
+            const addedFiles = files
+                .reduce((addedFiles, file) =>
+                    this.state.files.some(({ id }) => id === file.id)
+                        ? addedFiles
+                        : [...addedFiles, file]
+                    , []);
+
+            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
+            ids.forEach(pickerId => {
+                this.props.dispatch(
+                    treePickerActions.SELECT_TREE_PICKER_NODE({
+                        pickerId, id: addedFiles.map(({ id }) => id),
+                    })
+                );
+            });
+
+            this.setFiles(files);
+
+        }
+
+        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
+                            orderable
+                            deletable
+                            values={this.state.files}
+                            onChange={this.setFiles}
+                            getLabel={(file: CollectionFile) => file.name} />
+                    </div>
+                </div>
+        );
+
+    });
+
+type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
+
+
+
index 838aa5193b4e569b08cda44ee68a2f8af9518e89..f5d3d9391d44cc5f633ae2332cef1c92939995ad 100644 (file)
@@ -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/inputs/string-array-input.tsx b/src/views/run-process-panel/inputs/string-array-input.tsx
new file mode 100644 (file)
index 0000000..da03f29
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { isRequiredInput, StringArrayCommandInputParameter } from '~/models/workflow';
+import { Field } from 'redux-form';
+import { ERROR_MESSAGE } from '~/validators/require';
+import { GenericInputProps, GenericInput } from '~/views/run-process-panel/inputs/generic-input';
+import { ChipsInput } from '~/components/chips-input/chips-input';
+import { identity } from 'lodash';
+import { createSelector } from 'reselect';
+import { Input } from '@material-ui/core';
+
+export interface StringArrayInputProps {
+    input: StringArrayCommandInputParameter;
+}
+export const StringArrayInput = ({ input }: StringArrayInputProps) =>
+    <Field
+        name={input.id}
+        commandInput={input}
+        component={StringArrayInputComponent}
+        validate={validationSelector(input)} />;
+
+
+const validationSelector = createSelector(
+    isRequiredInput,
+    isRequired => isRequired
+        ? [required]
+        : undefined
+);
+
+const required = (value: string[]) =>
+    value.length > 0
+        ? undefined
+        : ERROR_MESSAGE;
+
+const StringArrayInputComponent = (props: GenericInputProps) =>
+    <GenericInput
+        component={InputComponent}
+        {...props} />;
+
+class InputComponent extends React.PureComponent<GenericInputProps>{
+    render() {
+        return <ChipsInput
+            deletable
+            orderable
+            value={this.props.input.value}
+            onChange={this.handleChange}
+            createNewValue={identity}
+            inputComponent={Input}
+            inputProps={{
+                error: this.props.meta.error,
+            }} />;
+    }
+
+    handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+        if (!meta.touched) {
+            input.onBlur(values);
+        }
+        input.onChange(values);
+    }
+}
index 41355b2ab9c8da07dc30fc368ee23d21b1a1cc5b..8ec51f6b4cda9b60fb2403ff2e89dcc4bcb650fd 100644 (file)
@@ -4,10 +4,10 @@
 
 import * as React from 'react';
 import { reduxForm, InjectedFormProps } from 'redux-form';
-import { CommandInputParameter, CWLType, IntCommandInputParameter, BooleanCommandInputParameter, FileCommandInputParameter, DirectoryCommandInputParameter } from '~/models/workflow';
+import { CommandInputParameter, CWLType, IntCommandInputParameter, BooleanCommandInputParameter, FileCommandInputParameter, DirectoryCommandInputParameter, DirectoryArrayCommandInputParameter } 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 } 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';
@@ -16,6 +16,10 @@ import { compose } from 'redux';
 import { Grid, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
 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';
+import { DirectoryArrayInput } from './inputs/directory-array-input';
 
 export const RUN_PROCESS_INPUTS_FORM = 'runProcessInputsForm';
 
@@ -23,12 +27,24 @@ export interface RunProcessInputFormProps {
     inputs: CommandInputParameter[];
 }
 
+const inputsSelector = (props: RunProcessInputFormProps) =>
+    props.inputs;
+
+const initialValuesSelector = createSelector(
+    inputsSelector,
+    inputs => inputs.reduce(
+        (values, input) => ({ ...values, [input.id]: input.default }),
+        {}));
+
+const propsSelector = createStructuredSelector({
+    initialValues: initialValuesSelector,
+});
+
+const mapStateToProps = (_: any, props: RunProcessInputFormProps) =>
+    propsSelector(props);
+
 export const RunProcessInputsForm = compose(
-    connect((_: any, props: RunProcessInputFormProps) => ({
-        initialValues: props.inputs.reduce(
-            (values, input) => ({ ...values, [input.id]: input.default }),
-            {}),
-    })),
+    connect(mapStateToProps),
     reduxForm<WorkflowInputsData, RunProcessInputFormProps>({
         form: RUN_PROCESS_INPUTS_FORM
     }))(
@@ -72,7 +88,7 @@ const getInputComponent = (input: CommandInputParameter) => {
 
         case isPrimitiveOfType(input, CWLType.FILE):
             return <FileInput input={input as FileCommandInputParameter} />;
-        
+
         case isPrimitiveOfType(input, CWLType.DIRECTORY):
             return <DirectoryInput input={input as DirectoryCommandInputParameter} />;
 
@@ -81,6 +97,15 @@ const getInputComponent = (input: CommandInputParameter) => {
             input.type.type === 'enum':
             return <EnumInput input={input as EnumCommandInputParameter} />;
 
+        case isArrayOfType(input, CWLType.STRING):
+            return <StringArrayInput input={input as StringArrayCommandInputParameter} />;
+
+        case isArrayOfType(input, CWLType.FILE):
+            return <FileArrayInput input={input as FileArrayCommandInputParameter} />;
+        
+        case isArrayOfType(input, CWLType.DIRECTORY):
+            return <DirectoryArrayInput input={input as DirectoryArrayCommandInputParameter} />;
+
         default:
             return null;
     }
index 2585136e6ea20a86fb86d3584a471ddf98ede586..0b8563822e4906be2dc62147cee5a0f7139f1bcf 100644 (file)
@@ -12,6 +12,7 @@ import { RootState } from '~/store/store';
 import { isValid } from 'redux-form';
 import { RUN_PROCESS_INPUTS_FORM } from './run-process-inputs-form';
 import { RunProcessAdvancedForm } from './run-process-advanced-form';
+import { createSelector, createStructuredSelector } from 'reselect';
 
 export interface RunProcessSecondStepFormDataProps {
     inputs: CommandInputParameter[];
@@ -23,10 +24,15 @@ export interface RunProcessSecondStepFormActionProps {
     runProcess: () => void;
 }
 
-const mapStateToProps = (state: RootState): RunProcessSecondStepFormDataProps => ({
-    inputs: state.runProcessPanel.inputs,
-    valid: isValid(RUN_PROCESS_BASIC_FORM)(state) &&
-        isValid(RUN_PROCESS_INPUTS_FORM)(state),
+const inputsSelector = (state: RootState) =>
+    state.runProcessPanel.inputs;
+
+const validSelector = (state: RootState) =>
+    isValid(RUN_PROCESS_BASIC_FORM)(state) && isValid(RUN_PROCESS_INPUTS_FORM)(state);
+
+const mapStateToProps = createStructuredSelector({
+    inputs: inputsSelector,
+    valid: validSelector,
 });
 
 export type RunProcessSecondStepFormProps = RunProcessSecondStepFormDataProps & RunProcessSecondStepFormActionProps;
index ade9a4e122dcd61f2e5322e9137da60ff202d390..788c96ae6b2552500ec3922fdd59748493f0347d 100644 (file)
@@ -42,6 +42,12 @@ import { RunProcessPanel } from '~/views/run-process-panel/run-process-panel';
 import SplitterLayout from 'react-splitter-layout';
 import { WorkflowPanel } from '~/views/workflow-panel/workflow-panel';
 import { SearchResultsPanel } from '~/views/search-results-panel/search-results-panel';
+import { HomeTreePicker } from '~/views-components/projects-tree-picker/home-tree-picker';
+import { SharedTreePicker } from '~/views-components/projects-tree-picker/shared-tree-picker';
+import { FavoritesTreePicker } from '../../views-components/projects-tree-picker/favorites-tree-picker';
+import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
+import { Chips } from '~/components/chips/chips';
+import { ChipsInput } from '../../components/chips-input/chips-input';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
 
index 906596caa008ca6562a9976a81fca12812724560..f3bf49b139b2ab3ebe97bddb21b642dd9f5698d4 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   dependencies:
     "@types/react" "*"
 
+"@types/react-dnd@3.0.2":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/react-dnd/-/react-dnd-3.0.2.tgz#939e5a8ca5b83f847c3f64dabbe2f49a9fefb192"
+  dependencies:
+    react-dnd "*"
+
 "@types/react-dom@16.0.8":
   version "16.0.8"
   resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.8.tgz#6e1366ed629cadf55860cbfcc25db533f5d2fa7d"
     "@types/react" "*"
     redux "^3.6.0 || ^4.0.0"
 
+"@types/reselect@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@types/reselect/-/reselect-2.2.0.tgz#c667206cfdc38190e1d379babe08865b2288575f"
+  dependencies:
+    reselect "*"
+
 "@types/shell-quote@1.6.0":
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@types/shell-quote/-/shell-quote-1.6.0.tgz#537b2949a2ebdcb0d353e448fee45b081021963f"
@@ -462,7 +474,7 @@ arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
 
-asap@~2.0.3:
+asap@^2.0.6, asap@~2.0.3:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
 
@@ -526,6 +538,10 @@ attr-accept@^1.1.3:
   dependencies:
     core-js "^2.5.0"
 
+autobind-decorator@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.1.0.tgz#4451240dbfeff46361c506575a63ed40f0e5bc68"
+
 autoprefixer@7.1.6:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.1.6.tgz#fb933039f74af74a83e71225ce78d9fd58ba84d7"
@@ -2333,6 +2349,15 @@ discontinuous-range@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
 
+dnd-core@^4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-4.0.5.tgz#3b83d138d0d5e265c73ec978dec5e1ed441dc665"
+  dependencies:
+    asap "^2.0.6"
+    invariant "^2.2.4"
+    lodash "^4.17.10"
+    redux "^4.0.0"
+
 dns-equal@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d"
@@ -3752,7 +3777,7 @@ interpret@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614"
 
-invariant@^2.0.0, invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4:
+invariant@^2.0.0, invariant@^2.1.0, invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
   dependencies:
@@ -6353,6 +6378,26 @@ react-dev-utils@^5.0.2:
     strip-ansi "3.0.1"
     text-table "0.2.0"
 
+react-dnd-html5-backend@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-5.0.1.tgz#0b578d79c5c01317c70414c8d717f632b919d4f1"
+  dependencies:
+    autobind-decorator "^2.1.0"
+    dnd-core "^4.0.5"
+    lodash "^4.17.10"
+    shallowequal "^1.0.2"
+
+react-dnd@*, react-dnd@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-5.0.0.tgz#c4a17c70109e456dad8906be838e6ee8f32b06b5"
+  dependencies:
+    dnd-core "^4.0.5"
+    hoist-non-react-statics "^2.5.0"
+    invariant "^2.1.0"
+    lodash "^4.17.10"
+    recompose "^0.27.1"
+    shallowequal "^1.0.2"
+
 react-dom@16.5.2:
   version "16.5.2"
   resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7"
@@ -6609,6 +6654,17 @@ readdirp@^2.0.0:
     react-lifecycles-compat "^3.0.2"
     symbol-observable "^1.0.4"
 
+recompose@^0.27.1:
+  version "0.27.1"
+  resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.27.1.tgz#1a49e931f183634516633bbb4f4edbfd3f38a7ba"
+  dependencies:
+    babel-runtime "^6.26.0"
+    change-emitter "^0.1.2"
+    fbjs "^0.8.1"
+    hoist-non-react-statics "^2.3.1"
+    react-lifecycles-compat "^3.0.2"
+    symbol-observable "^1.0.4"
+
 recompose@^0.29.0:
   version "0.29.0"
   resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.29.0.tgz#f1a4e20d5f24d6ef1440f83924e821de0b1bccef"
@@ -6854,7 +6910,7 @@ requires-port@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
 
-reselect@4.0.0:
+reselect@*, reselect@4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7"
 
@@ -7113,6 +7169,10 @@ sha.js@^2.4.0, sha.js@^2.4.8:
     inherits "^2.0.1"
     safe-buffer "^5.0.1"
 
+shallowequal@^1.0.2:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
+
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"