Create ProjectTreePicker
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 7 Aug 2018 11:23:50 +0000 (13:23 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 7 Aug 2018 11:23:50 +0000 (13:23 +0200)
Feature #13952

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

src/components/tree/tree.tsx
src/store/store.ts
src/store/tree-picker/tree-picker-actions.ts [new file with mode: 0644]
src/store/tree-picker/tree-picker-reducer.test.ts [new file with mode: 0644]
src/store/tree-picker/tree-picker-reducer.ts [new file with mode: 0644]
src/store/tree-picker/tree-picker.ts [new file with mode: 0644]
src/views-components/project-tree-picker/project-tree-picker.tsx [new file with mode: 0644]
src/views-components/tree-picker/tree-picker.ts [new file with mode: 0644]

index ea15b6b1bd8df4a8b830bbdc6aec48f6c56e7ea2..669b70c0e855c1d551bc4a4dedfeacbe35000814 100644 (file)
@@ -80,7 +80,7 @@ export interface TreeItem<T> {
     items?: Array<TreeItem<T>>;
 }
 
-interface TreeProps<T> {
+export interface TreeProps<T> {
     items?: Array<TreeItem<T>>;
     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
     toggleItemOpen: (id: string, status: TreeItemStatus) => void;
index aeb6a09cd388af3e559a41142590adcfc31234c1..0002a6d2fa00c7956fc83c8cf367c35d3649bca4 100644 (file)
@@ -27,6 +27,8 @@ import { CollectionPanelState, collectionPanelReducer } from './collection-panel
 import { DialogState, dialogReducer } from './dialog/dialog-reducer';
 import { CollectionsState, collectionsReducer } from './collections/collections-reducer';
 import { ServiceRepository } from "../services/services";
+import { treePickerReducer } from './tree-picker/tree-picker-reducer';
+import { TreePicker } from './tree-picker/tree-picker';
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -47,6 +49,7 @@ export interface RootState {
     snackbar: SnackbarState;
     collectionPanelFiles: CollectionPanelFilesState;
     dialog: DialogState;
+    treePicker: TreePicker;
 }
 
 export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
@@ -66,7 +69,8 @@ export function configureStore(history: History, services: ServiceRepository): R
         favorites: favoritesReducer,
         snackbar: snackbarReducer,
         collectionPanelFiles: collectionPanelFilesReducer,
-        dialog: dialogReducer
+        dialog: dialogReducer,
+        treePicker: treePickerReducer,
     });
 
     const projectPanelMiddleware = dataExplorerMiddleware(
diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts
new file mode 100644 (file)
index 0000000..772d89d
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { TreeNode } from "../../models/tree";
+import { TreePickerNode } from "./tree-picker";
+
+export const treePickerActions = unionize({
+    LOAD_TREE_PICKER_NODE: ofType<{ id: string }>(),
+    LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreePickerNode> }>(),
+    TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string }>(),
+    TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ id: string }>()
+}, {
+        tag: 'type',
+        value: 'payload'
+    });
+
+export type TreePickerAction = UnionOf<typeof treePickerActions>;
diff --git a/src/store/tree-picker/tree-picker-reducer.test.ts b/src/store/tree-picker/tree-picker-reducer.test.ts
new file mode 100644 (file)
index 0000000..443da37
--- /dev/null
@@ -0,0 +1,101 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createTree, getNodeValue, getNodeChildren } from "../../models/tree";
+import { TreePickerNode, createTreePickerNode } from "./tree-picker";
+import { treePickerReducer } from "./tree-picker-reducer";
+import { treePickerActions } from "./tree-picker-actions";
+import { TreeItemStatus } from "../../components/tree/tree";
+
+
+describe('TreePickerReducer', () => {
+    it('LOAD_TREE_PICKER_NODE - initial state', () => {
+        const tree = createTree<TreePickerNode>();
+        const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' }));
+        expect(newTree).toEqual(tree);
+    });
+
+    it('LOAD_TREE_PICKER_NODE', () => {
+        const tree = createTree<TreePickerNode>();
+        const node = createTreePickerNode({ id: '1', value: '1' });
+        const [newTree] = [tree]
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' })));
+        expect(getNodeValue('1')(newTree)).toEqual({
+            ...createTreePickerNode({ id: '1', value: '1' }),
+            status: TreeItemStatus.PENDING
+        });
+    });
+
+    it('LOAD_TREE_PICKER_NODE_SUCCESS - initial state', () => {
+        const tree = createTree<TreePickerNode>();
+        const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
+        const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [subNode] }));
+        expect(getNodeChildren('')(newTree)).toEqual(['1.1']);
+    });
+
+    it('LOAD_TREE_PICKER_NODE_SUCCESS', () => {
+        const tree = createTree<TreePickerNode>();
+        const node = createTreePickerNode({ id: '1', value: '1' });
+        const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
+        const [newTree] = [tree]
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode] })));
+        expect(getNodeChildren('1')(newTree)).toEqual(['1.1']);
+        expect(getNodeValue('1')(newTree)).toEqual({
+            ...createTreePickerNode({ id: '1', value: '1' }),
+            status: TreeItemStatus.LOADED
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - collapsed', () => {
+        const tree = createTree<TreePickerNode>();
+        const node = createTreePickerNode({ id: '1', value: '1' });
+        const [newTree] = [tree]
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
+            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
+        expect(getNodeValue('1')(newTree)).toEqual({
+            ...createTreePickerNode({ id: '1', value: '1' }),
+            collapsed: true
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - expanded', () => {
+        const tree = createTree<TreePickerNode>();
+        const node = createTreePickerNode({ id: '1', value: '1' });
+        const [newTree] = [tree]
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
+            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })))
+            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
+        expect(getNodeValue('1')(newTree)).toEqual({
+            ...createTreePickerNode({ id: '1', value: '1' }),
+            collapsed: false
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_SELECT - selected', () => {
+        const tree = createTree<TreePickerNode>();
+        const node = createTreePickerNode({ id: '1', value: '1' });
+        const [newTree] = [tree]
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
+            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
+        expect(getNodeValue('1')(newTree)).toEqual({
+            ...createTreePickerNode({ id: '1', value: '1' }),
+            selected: true
+        });
+    });
+
+    it('TOGGLE_TREE_PICKER_NODE_SELECT - not selected', () => {
+        const tree = createTree<TreePickerNode>();
+        const node = createTreePickerNode({ id: '1', value: '1' });
+        const [newTree] = [tree]
+            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
+            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })))
+            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
+        expect(getNodeValue('1')(newTree)).toEqual({
+            ...createTreePickerNode({ id: '1', value: '1' }),
+            selected: false
+        });
+    });
+});
diff --git a/src/store/tree-picker/tree-picker-reducer.ts b/src/store/tree-picker/tree-picker-reducer.ts
new file mode 100644 (file)
index 0000000..d195a98
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createTree, setNodeValueWith, TreeNode, setNode, mapTree, mapTreeValues } from "../../models/tree";
+import { TreePicker, TreePickerNode } from "./tree-picker";
+import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
+import { TreeItemStatus } from "../../components/tree/tree";
+
+
+export const treePickerReducer = (state: TreePicker = createTree(), action: TreePickerAction) =>
+    treePickerActions.match(action, {
+        LOAD_TREE_PICKER_NODE: ({ id }) =>
+            setNodeValueWith(setPending)(id)(state),
+        LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes }) => {
+            const [newState] = [state]
+                .map(receiveNodes(nodes)(id))
+                .map(setNodeValueWith(setLoaded)(id));
+            return newState;
+        },
+        TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id }) =>
+            setNodeValueWith(toggleCollapse)(id)(state),
+        TOGGLE_TREE_PICKER_NODE_SELECT: ({ id }) =>
+            mapTreeValues(toggleSelect(id))(state),
+        default: () => state
+    });
+
+const setPending = (value: TreePickerNode): TreePickerNode =>
+    ({ ...value, status: TreeItemStatus.PENDING });
+
+const setLoaded = (value: TreePickerNode): TreePickerNode =>
+    ({ ...value, status: TreeItemStatus.LOADED });
+
+const toggleCollapse = (value: TreePickerNode): TreePickerNode =>
+    ({ ...value, collapsed: !value.collapsed });
+
+const toggleSelect = (id: string) => (value: TreePickerNode): TreePickerNode =>
+    value.id === id
+        ? ({ ...value, selected: !value.selected })
+        : ({ ...value, selected: false });
+
+const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: TreePicker) =>
+    nodes.reduce((tree, node) =>
+        setNode(
+            createTreeNode(parent)(node)
+        )(tree), state);
+
+const createTreeNode = (parent: string) => (node: TreePickerNode): TreeNode<TreePickerNode> => ({
+    children: [],
+    id: node.id,
+    parent,
+    value: node
+});
\ No newline at end of file
diff --git a/src/store/tree-picker/tree-picker.ts b/src/store/tree-picker/tree-picker.ts
new file mode 100644 (file)
index 0000000..ee45bec
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Tree } from "../../models/tree";
+import { TreeItemStatus } from "../../components/tree/tree";
+
+export type TreePicker = Tree<TreePickerNode>;
+
+export interface TreePickerNode {
+    id: string;
+    value: any;
+    selected: boolean;
+    collapsed: boolean;
+    status: TreeItemStatus;
+}
+
+export const createTreePickerNode = (data: {id: string, value: any}) => ({
+    ...data,
+    selected: false,
+    collapsed: true,
+    status: TreeItemStatus.INITIAL
+});
\ No newline at end of file
diff --git a/src/views-components/project-tree-picker/project-tree-picker.tsx b/src/views-components/project-tree-picker/project-tree-picker.tsx
new file mode 100644 (file)
index 0000000..c4795e1
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { Typography } from "@material-ui/core";
+import { TreePicker } from "../tree-picker/tree-picker";
+import { TreeProps, TreeItem, TreeItemStatus } from "../../components/tree/tree";
+import { ProjectResource } from "../../models/project";
+import { treePickerActions } from "../../store/tree-picker/tree-picker-actions";
+import { ListItemTextIcon } from "../../components/list-item-text-icon/list-item-text-icon";
+import { ProjectIcon } from "../../components/icon/icon";
+import { createTreePickerNode } from "../../store/tree-picker/tree-picker";
+import { RootState } from "../../store/store";
+import { ServiceRepository } from "../../services/services";
+import { FilterBuilder } from "../../common/api/filter-builder";
+
+type ProjectTreePickerProps = Pick<TreeProps<ProjectResource>, 'toggleItemActive' | 'toggleItemOpen'>;
+
+const mapDispatchToProps = (dispatch: Dispatch): ProjectTreePickerProps => ({
+    toggleItemActive: id => {
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id }));
+    },
+    toggleItemOpen: (id, status) => {
+        status === TreeItemStatus.INITIAL
+            ? dispatch<any>(loadProjectTreePickerProjects(id))
+            : dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+    }
+});
+
+export const ProjectTreePicker = connect(undefined, mapDispatchToProps)((props: ProjectTreePickerProps) =>
+    <div>
+        <Typography variant='caption'>
+            Select a project
+        </Typography>
+        <TreePicker {...props} render={renderTreeItem} />
+    </div>);
+
+// TODO: move action creator to store directory
+export const loadProjectTreePickerProjects = (id: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id }));
+
+        const ownerUuid = id.length === 0 ? services.authService.getUuid() || '' : id;
+
+        const filters = FilterBuilder
+            .create<ProjectResource>()
+            .addEqual('ownerUuid', ownerUuid);
+
+        const { items } = await services.projectService.list({ filters });
+
+        dispatch<any>(receiveProjectTreePickerData(id, items));
+    };
+
+const renderTreeItem = (item: TreeItem<ProjectResource>) =>
+    <ListItemTextIcon
+        icon={ProjectIcon}
+        name={item.data.name}
+        isActive={item.active}
+        hasMargin={true} />;
+
+// TODO: move action creator to store directory
+const receiveProjectTreePickerData = (id: string, projects: ProjectResource[]) =>
+    (dispatch: Dispatch) => {
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id,
+            nodes: projects.map(project => createTreePickerNode({ id: project.uuid, value: project }))
+        }));
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+    };
\ No newline at end of file
diff --git a/src/views-components/tree-picker/tree-picker.ts b/src/views-components/tree-picker/tree-picker.ts
new file mode 100644 (file)
index 0000000..3e0fc6e
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Tree, TreeProps, TreeItem } from "../../components/tree/tree";
+import { RootState } from "../../store/store";
+import { TreePicker as TTreePicker, TreePickerNode, createTreePickerNode } from "../../store/tree-picker/tree-picker";
+import { getNodeValue, getNodeChildren } from "../../models/tree";
+
+const memoizedMapStateToProps = () => {
+    let prevState: TTreePicker;
+    let prevTree: Array<TreeItem<any>>;
+
+    return (state: RootState): Pick<TreeProps<any>, 'items'> => {
+        if (prevState !== state.treePicker) {
+            prevState = state.treePicker;
+            prevTree = getNodeChildren('')(state.treePicker)
+                .map(treePickerToTreeItems(state.treePicker));
+        }
+        return {
+            items: prevTree
+        };
+    };
+};
+
+const mapDispatchToProps = (): Pick<TreeProps<any>, 'onContextMenu'> => ({
+    onContextMenu: () => { return; },
+});
+
+export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
+
+const treePickerToTreeItems = (tree: TTreePicker) =>
+    (id: string): TreeItem<any> => {
+        const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ id: '', value: 'InvalidNode' });
+        const items = getNodeChildren(node.id)(tree)
+            .map(treePickerToTreeItems(tree));
+        return {
+            active: node.selected,
+            data: node.value,
+            id: node.id,
+            items: items.length > 0 ? items : undefined,
+            open: !node.collapsed,
+            status: node.status
+        };
+    };
+