Merge master branch
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Wed, 13 Jun 2018 06:02:51 +0000 (08:02 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Wed, 13 Jun 2018 06:02:51 +0000 (08:02 +0200)
Feature #13590

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

29 files changed:
Dockerfile [new file with mode: 0644]
Makefile
package.json
public/index.html
run-tests-build.sh [deleted file]
src/common/api/filter-builder.ts [new file with mode: 0644]
src/common/api/server-api.ts [moved from src/common/server-api.ts with 100% similarity]
src/common/api/url-builder.ts [new file with mode: 0644]
src/components/api-token/api-token.tsx
src/components/project-list/project-list.tsx
src/components/project-tree/project-tree.test.tsx [new file with mode: 0644]
src/components/project-tree/project-tree.tsx [new file with mode: 0644]
src/components/tree/tree.test.tsx [new file with mode: 0644]
src/components/tree/tree.tsx
src/index.tsx
src/models/project.ts
src/services/auth-service/auth-service.ts
src/services/project-service/project-service.ts
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/project/project-action.ts
src/store/project/project-reducer.test.ts
src/store/project/project-reducer.ts
src/views/workbench/workbench.test.tsx
src/views/workbench/workbench.tsx
tsconfig.json
tsconfig.test.json
tslint.json
yarn.lock

diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..8f33282
--- /dev/null
@@ -0,0 +1,10 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+FROM node:latest
+MAINTAINER Ward Vandewege <ward@curoverse.com>
+RUN apt-get update
+RUN apt-get -q -y install libsecret-1-0 libsecret-1-dev rpm
+RUN apt-get install -q -y ruby ruby-dev rubygems build-essential
+RUN gem install --no-ri --no-rdoc fpm
index f2f61f7ecace63d26dcb08c85933f68b7f433d52..a543d4649364f0b1a5e87abf02ad39b8dce45e29 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,44 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+APP_NAME?=arvados-workbench2
+
+# GIT_TAG is the last tagged stable release (i.e. 1.2.0)
+GIT_TAG?=$(shell git describe --abbrev=0)
+
+# TS_GIT is the timestamp in the current directory (i.e. 1528815021).
+# Note that it will only change if files change.
+TS_GIT?=$(shell git log -n1 --first-parent "--format=format:%ct" .)
+
+# DATE_FROM_TS_GIT is the human(ish)-readable version of TS_GIT
+# 1528815021 -> 20180612145021
+DATE_FROM_TS_GIT?=$(shell date -ud @$(TS_GIT) +%Y%m%d%H%M%S)
+
+# VERSION uses all the above to produce X.Y.Z.timestamp
+# something in the lines of 1.2.0.20180612145021, this will be the package version
+# it can be overwritten when invoking make as in make packages VERSION=1.2.0
+VERSION?=$(GIT_TAG).$(DATE_FROM_TS_GIT)
+
+# ITERATION is the package iteration, intended for manual change if anything non-code related
+# changes in the package. (i.e. example config files externally added
+ITERATION?=1
+
+DESCRIPTION=Arvados Workbench2 - Arvados is a free and open source platform for big data science.
+MAINTAINER=Ward Vandewege <wvandewege@veritasgenetics.com>
+
+# DEST_DIR will have the build package copied.
+DEST_DIR=/var/www/arvados-workbench2/workbench2/
+
+# Debian package file
+DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
+
+# redHat package file
+RPM_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION).x86_64.rpm
+
 export WORKSPACE?=$(shell pwd)
+
+.PHONY: help clean* yarn-install test build packages packages-with-version 
+
 help:
        @echo >&2
        @echo >&2 "There is no default make target here.  Did you mean 'make test'?"
@@ -14,10 +51,48 @@ help:
        @echo >&2
        @false
 
-test:
-       @yarn install
-       @yarn test      --no-watchAll
+clean-deb:
+       rm -f $(WORKSPACE)/*.deb
+
+clean-rpm:
+       rm -f $(WORKSPACE)/*.rpm
+
+clean-node-modules:
+       rm -rf $(WORKSPACE)/node_modules
+
+clean: clean-rpm clean-deb clean-node-modules
+
+yarn-install:
+       yarn install
+
+test: yarn-install
+       yarn test       --no-watchAll --bail --ci
+
+build: test
+       yarn build
+
+$(DEB_FILE): build
+       fpm \
+        -s dir \
+        -t deb \
+        -n "$(APP_NAME)" \
+        -v "$(VERSION)" \
+        --iteration "$(ITERATION)" \
+        --maintainer="$(MAINTAINER)" \
+        --description="$(DESCRIPTION)" \
+        --deb-no-default-config-files \
+       $(WORKSPACE)/build/=$(DEST_DIR)
+
+$(RPM_FILE): build
+       fpm \
+        -s dir \
+        -t rpm \
+        -n "$(APP_NAME)" \
+        -v "$(VERSION)" \
+        --iteration "$(ITERATION)" \
+        --maintainer="$(MAINTAINER)" \
+        --description="$(DESCRIPTION)" \
+        $(WORKSPACE)/build/=$(DEST_DIR)
 
-build:
-       @yarn install
-       @yarn build
+# use FPM to create DEB and RPM
+packages: $(DEB_FILE) $(RPM_FILE)
index e0e6b0785bb643bd11dd678c32bff20596342bce..967faf77823e761ce636b40c81b9726a0fd5c70a 100644 (file)
@@ -5,7 +5,9 @@
   "dependencies": {
     "@material-ui/core": "1.2.0",
     "@material-ui/icons": "^1.1.0",
+    "@types/lodash": "^4.14.109",
     "axios": "0.18.0",
+    "lodash": "4.17.10",
     "react": "16.4.0",
     "react-dom": "16.4.0",
     "react-redux": "5.0.7",
index f6111b91895db5aad0429e757fb378ec2ba66c3a..a8d655e5872080571e26bf3f5a830046b3ffcffe 100644 (file)
@@ -10,6 +10,7 @@
     -->
     <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
     <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+    <link href="//netdna.bootstrapcdn.com/font-awesome/3.2.1/css/font-awesome.css" rel="stylesheet">
     <!--
       Notice the use of %PUBLIC_URL% in the tags above.
       It will be replaced with the URL of the `public` folder during the build.
@@ -20,6 +21,8 @@
       Learn how to configure a non-root public URL by running `npm run build`.
     -->
     <title>Arvados Workbench 2</title>
+    <script>FontAwesomeConfig = { autoReplaceSvg: 'nest' }</script>
+    <script defer src="https://use.fontawesome.com/releases/v5.0.13/js/all.js" integrity="sha384-xymdQtn1n3lH2wcu0qhcdaOpQwyoarkgLVxC/wZ5q7h9gHtxICrpcaSUfygqZGOe" crossorigin="anonymous"></script>
   </head>
   <body>
     <noscript>
diff --git a/run-tests-build.sh b/run-tests-build.sh
deleted file mode 100755 (executable)
index e55b1e0..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/bin/bash -x
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-# The script uses the docker image composer-build:latest
-# Usage docker run -ti -v /var/lib/jenkins/workspace/build-packages-workbench2/:/tmp/workbench2 composer-build:latest /tmp/workbench2/run-tests-build.sh --build_version 1.0.1
-format_last_commit_here() {
-    local format="$1"; shift
-    TZ=UTC git log -n1 --first-parent "--format=format:$format" .
-}
-
-version_from_git() {
-    # Output the version being built, or if we're building a
-    # dev/prerelease, output a version number based on the git log for
-    # the current working directory.
-    if [[ -n "$ARVADOS_BUILDING_VERSION" ]]; then
-        echo "$ARVADOS_BUILDING_VERSION"
-        return
-    fi
-
-    local git_ts git_hash prefix
-    if [[ -n "$1" ]] ; then
-        prefix="$1"
-    else
-        prefix="0.1"
-    fi
-
-    declare $(format_last_commit_here "git_ts=%ct git_hash=%h")
-    ARVADOS_BUILDING_VERSION="$(git describe --abbrev=0).$(date -ud "@$git_ts" +%Y%m%d%H%M%S)"
-    echo "$ARVADOS_BUILDING_VERSION"
-} 
-
-nohash_version_from_git() {
-    version_from_git $1 | cut -d. -f1-3
-}
-
-timestamp_from_git() {
-    format_last_commit_here "%ct"
-}
-
-WORKDIR="/tmp/workbench2"
-cd $WORKDIR
-if [[ -n "$2" ]]; then
-    build_version="$2"
-else
-    build_version="$(version_from_git)"
-fi
-rm -Rf $WORKDIR/node_modules
-rm -f $WORKDIR/*.deb; rm -f $WORKDIR/*.rpm
-# run test and build dist 
-make test
-#make build
-yarn build
-
-# Build deb and rpm packages using fpm from dist passing the destination folder for the deploy to be /var/www/arvados-workbench2/
-fpm -s dir -t deb  -n arvados-workbench2 -v "$build_version" "--maintainer=Ward Vandewege <ward@curoverse.com>" --description "workbench2 Package" --deb-no-default-config-files $WORKDIR/build/=/var/www/arvados-workbench2/workbench2/
-fpm -s dir -t rpm  -n arvados-workbench2 -v "$build_version" "--maintainer=Ward Vandewege <ward@curoverse.com>" --description "workbench2 Package" $WORKDIR/build/=/var/www/arvados-workbench2/workbench2/
-
-mkdir $WORKDIR/packages
-mkdir $WORKDIR/packages/centos7
-mkdir $WORKDIR/packages/ubuntu1404
-mkdir $WORKDIR/packages/ubuntu1604
-mkdir $WORKDIR/packages/debian8
-mkdir $WORKDIR/packages/debian9
-cp $WORKDIR/*.rpm $WORKDIR/packages/centos7/
-cp $WORKDIR/*.deb $WORKDIR/packages/ubuntu1404/
-cp $WORKDIR/*.deb $WORKDIR/packages/ubuntu1604/
-cp $WORKDIR/*.deb $WORKDIR/packages/debian8
-cp $WORKDIR/*.deb $WORKDIR/packages/debian9
diff --git a/src/common/api/filter-builder.ts b/src/common/api/filter-builder.ts
new file mode 100644 (file)
index 0000000..3f8e323
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum FilterField {
+    UUID = "uuid",
+    OWNER_UUID = "owner_uuid"
+}
+
+export default class FilterBuilder {
+    private filters = "";
+
+    private addCondition(field: FilterField, cond: string, value?: string) {
+        if (value) {
+            this.filters += `["${field}","${cond}","${value}"]`;
+        }
+        return this;
+    }
+
+    public addEqual(field: FilterField, value?: string) {
+        return this.addCondition(field, "=", value);
+    }
+
+    public addLike(field: FilterField, value?: string) {
+        return this.addCondition(field, "like", value);
+    }
+
+    public addILike(field: FilterField, value?: string) {
+        return this.addCondition(field, "ilike", value);
+    }
+
+    public get() {
+        return "[" + this.filters + "]";
+    }
+}
diff --git a/src/common/api/url-builder.ts b/src/common/api/url-builder.ts
new file mode 100644 (file)
index 0000000..e5786a2
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export default class UrlBuilder {
+       private url: string = "";
+       private query: string = "";
+
+       constructor(host: string) {
+               this.url = host;
+       }
+
+       public addParam(param: string, value: string) {
+               if (this.query.length === 0) {
+                       this.query += "?";
+               } else {
+                       this.query += "&";
+               }
+               this.query += `${param}=${value}`;
+               return this;
+       }
+
+       public get() {
+               return this.url + this.query;
+       }
+}
index 34e2d64c110ca43a55cba2c7f0f2e11e361c2446..87da39b025e9706c05ebdade6e600f936fae1a89 100644 (file)
@@ -24,7 +24,7 @@ class ApiToken extends React.Component<ApiTokenProps & RouteProps & DispatchProp
         const apiToken = ApiToken.getUrlParameter(search, 'api_token');
         this.props.dispatch(authActions.SAVE_API_TOKEN(apiToken));
         this.props.dispatch(authService.getUserDetails());
-        this.props.dispatch(projectService.getTopProjectList());
+        this.props.dispatch(projectService.getProjectList());
     }
     render() {
         return <Redirect to="/"/>
index 3526da391cd6d2585140b9c993d31029f676a674..ec16a677f45a83926f7ec79d15c17913ba10d10a 100644 (file)
@@ -4,9 +4,8 @@
 
 import * as React from 'react';
 import { Theme } from "@material-ui/core";
-import { StyleRulesCallback, WithStyles } from "@material-ui/core/styles";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core/styles";
 import Paper from "@material-ui/core/Paper/Paper";
-import withStyles from "@material-ui/core/es/styles/withStyles";
 import Table from "@material-ui/core/Table/Table";
 import TableHead from "@material-ui/core/TableHead/TableHead";
 import TableRow from "@material-ui/core/TableRow/TableRow";
diff --git a/src/components/project-tree/project-tree.test.tsx b/src/components/project-tree/project-tree.test.tsx
new file mode 100644 (file)
index 0000000..d42df08
--- /dev/null
@@ -0,0 +1,106 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { mount } from 'enzyme';
+import * as Enzyme from 'enzyme';
+import * as Adapter from 'enzyme-adapter-react-16';
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import { Collapse } from '@material-ui/core';
+
+import ProjectTree from './project-tree';
+import { TreeItem } from '../tree/tree';
+import { Project } from '../../models/project';
+Enzyme.configure({ adapter: new Adapter() });
+
+describe("ProjectTree component", () => {
+
+    it("checks is there ListItemIcon in the ProjectTree component", () => {
+        const project: TreeItem<Project> = {
+            data: {
+                name: "sample name",
+                createdAt: "2018-06-12",
+                modifiedAt: "2018-06-13",
+                uuid: "uuid",
+                ownerUuid: "ownerUuid",
+                href: "href",
+            },
+            id: "3",
+            open: true,
+            active: true
+        };
+        const wrapper = mount(<ProjectTree projects={[project]} toggleProjectTreeItem={() => { }} />);
+
+        expect(wrapper.find(ListItemIcon).length).toEqual(1);
+    });
+
+    it("checks are there two ListItemIcon's in the ProjectTree component", () => {
+        const project: Array<TreeItem<Project>> = [
+            {
+                data: {
+                    name: "sample name",
+                    createdAt: "2018-06-12",
+                    modifiedAt: "2018-06-13",
+                    uuid: "uuid",
+                    ownerUuid: "ownerUuid",
+                    href: "href",
+                },
+                id: "3",
+                open: false,
+                active: true
+            },
+            {
+                data: {
+                    name: "sample name",
+                    createdAt: "2018-06-12",
+                    modifiedAt: "2018-06-13",
+                    uuid: "uuid",
+                    ownerUuid: "ownerUuid",
+                    href: "href",
+                },
+                id: "3",
+                open: false,
+                active: true
+            }
+        ];
+        const wrapper = mount(<ProjectTree projects={project} toggleProjectTreeItem={() => { }} />);
+
+        expect(wrapper.find(ListItemIcon).length).toEqual(2);
+    });
+
+    it("check ProjectTree, when open is changed", () => {
+        const project: TreeItem<Project> = {
+            data: {
+                name: "sample name",
+                createdAt: "2018-06-12",
+                modifiedAt: "2018-06-13",
+                uuid: "uuid",
+                ownerUuid: "ownerUuid",
+                href: "href",
+            },
+            id: "3",
+            open: true,
+            active: true,
+            items: [
+                {
+                    data: {
+                        name: "sample name",
+                        createdAt: "2018-06-12",
+                        modifiedAt: "2018-06-13",
+                        uuid: "uuid",
+                        ownerUuid: "ownerUuid",
+                        href: "href",
+                    },
+                    id: "4",
+                    open: false,
+                    active: true
+                }
+            ]
+        };
+        const wrapper = mount(<ProjectTree projects={[project]} toggleProjectTreeItem={() => { }} />);
+        wrapper.setState({open: true });
+
+        expect(wrapper.find(Collapse).length).toEqual(1);
+    });
+});
diff --git a/src/components/project-tree/project-tree.tsx b/src/components/project-tree/project-tree.tsx
new file mode 100644 (file)
index 0000000..5243b5e
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { ReactElement } from 'react';
+import { StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core/styles';
+import ListItemText from "@material-ui/core/ListItemText/ListItemText";
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import Typography from '@material-ui/core/Typography';
+
+import Tree, { TreeItem } from '../tree/tree';
+import { Project } from '../../models/project';
+
+type CssRules = 'active' | 'listItemText' | 'row' | 'treeContainer';
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    active: {
+        color: '#4285F6',
+    },
+    listItemText: {
+        padding: '0px',
+    },
+    row: {
+        display: 'flex',
+        alignItems: 'center',
+        marginLeft: '20px',
+    },
+    treeContainer: {
+        position: 'absolute',
+        overflowX: 'visible',
+        marginTop: '80px',
+        minWidth: '240px',
+        whiteSpace: 'nowrap',
+    }
+});
+
+export interface ProjectTreeProps {
+    projects: Array<TreeItem<Project>>;
+    toggleProjectTreeItem: (id: string) => void;
+}
+
+class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
+    render(): ReactElement<any> {
+        const {classes, projects} = this.props;
+        const {active, listItemText, row, treeContainer} = classes;
+        return (
+            <div className={treeContainer}>
+                <Tree items={projects}
+                    toggleItem={this.props.toggleProjectTreeItem}
+                    render={(project: TreeItem<Project>, level: number) =>
+                        <span className={row}>
+                            <ListItemIcon className={project.active ? active : ''}>
+                                {level === 0 ? <i className="fas fa-th"/> : <i className="fas fa-folder"/>}
+                            </ListItemIcon>
+                            <ListItemText className={listItemText} primary={
+                                <Typography className={project.active ? active : ''}>
+                                    {project.data.name}
+                                </Typography>
+                            }/>
+                        </span>
+                    }/>
+            </div>
+        );
+    }
+}
+
+export default withStyles(styles)(ProjectTree)
diff --git a/src/components/tree/tree.test.tsx b/src/components/tree/tree.test.tsx
new file mode 100644 (file)
index 0000000..ffdc74f
--- /dev/null
@@ -0,0 +1,7 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+it("should render the tree", () => {
+       expect(true).toBe(true);
+});
\ No newline at end of file
index 12369d57e12b1415624839a8244dbe95bb7442d6..d8397d6673a7a9f2c30a95ee7e7f1c090f1a24d1 100644 (file)
@@ -5,23 +5,71 @@
 import * as React from 'react';
 import List from "@material-ui/core/List/List";
 import ListItem from "@material-ui/core/ListItem/ListItem";
+import { StyleRulesCallback, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
 import { ReactElement } from "react";
+import Collapse from "@material-ui/core/Collapse/Collapse";
+
+type CssRules = 'list' | 'activeArrow' | 'arrow' | 'arrowRotate';
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    list: {
+        paddingBottom: '3px',
+        paddingTop: '3px',
+    },
+    activeArrow: {
+        color: '#4285F6',
+        position: 'absolute',
+    },
+    arrow: {
+        position: 'absolute',
+    },
+    arrowRotate: {
+        transform: 'rotate(-90deg)',
+    }
+});
+
+export interface TreeItem<T> {
+    data: T;
+    id: string;
+    open: boolean;
+    active: boolean;
+    items?: Array<TreeItem<T>>;
+}
 
 interface TreeProps<T> {
-    items: T[],
-    render: (item: T) => ReactElement<{}>
+    items?: Array<TreeItem<T>>;
+    render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
+    toggleItem: (id: string) => any;
+    level?: number;
 }
 
-class Tree<T> extends React.Component<TreeProps<T>, {}> {
-    render() {
-        return <List>
-            {this.props.items && this.props.items.map((it: T, idx: number) =>
-                <ListItem key={`item/${idx}`} button>
-                    {this.props.render(it)}
+class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
+    renderArrow (items: boolean, arrowClass: string, open: boolean){
+        return <i className={`${arrowClass} ${open ? "fas fa-caret-down" : `fas fa-caret-down ${this.props.classes.arrowRotate}`}`} />
+    }
+    render(): ReactElement<any> {
+        const level = this.props.level ? this.props.level : 0;
+        const {classes, render, toggleItem, items} = this.props;
+        const {list, arrow, activeArrow} = classes;
+        return <List component="div" className={list}>
+            {items && items.map((it: TreeItem<T>, idx: number) =>
+             <div key={`item/${level}/${idx}`}>
+                <ListItem button onClick={() => toggleItem(it.id)} className={list} style={{paddingLeft: (level + 1) * 20}}>
+                    {this.renderArrow(true, it.active ? activeArrow : arrow, it.open)}
+                    {render(it, level)}
                 </ListItem>
-            )}
+                {it.items && it.items.length > 0 &&
+                <Collapse in={it.open} timeout="auto" unmountOnExit>
+                    <StyledTree
+                        items={it.items}
+                        render={render}
+                        toggleItem={toggleItem}
+                        level={level + 1}/>
+                </Collapse>}
+             </div>)}
         </List>
     }
 }
 
-export default Tree;
+const StyledTree = withStyles(styles)(Tree);
+export default StyledTree
index 67de95fb3af108699e853dee79f84ee623678b6a..351ed2e04b7df755e29fc8131fac218bee9921e9 100644 (file)
@@ -17,6 +17,7 @@ import authActions from "./store/auth/auth-action";
 import { projectService } from "./services/services";
 
 const history = createBrowserHistory();
+
 const store = configureStore({
     projects: [
     ],
@@ -29,7 +30,7 @@ const store = configureStore({
 }, history);
 
 store.dispatch(authActions.INIT());
-store.dispatch<any>(projectService.getTopProjectList());
+store.dispatch<any>(projectService.getProjectList());
 
 const App = () =>
     <Provider store={store}>
index 83862c94001a575f55136faa8b1d93653a44f415..83fb59bd3eb0b4f77854848a3637488ce9894eb2 100644 (file)
@@ -8,5 +8,5 @@ export interface Project {
     modifiedAt: string;
     uuid: string;
     ownerUuid: string;
-    href: string
+    href: string;
 }
index 80d13e3c64676b3be8ef21604420f01b34abe1c7..da593c2dfb7a44fad7d88b29ef8ebff70febffc7 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { API_HOST, serverApi } from "../../common/server-api";
+import { API_HOST, serverApi } from "../../common/api/server-api";
 import { User } from "../../models/user";
 import { Dispatch } from "redux";
 import actions from "../../store/auth/auth-action";
index f35ca9cd4102e7d5a97b515b910ab4c737301430..9350dabdc4894f7ef4ebecb3105da204a0a98f14 100644 (file)
@@ -2,10 +2,12 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { serverApi } from "../../common/server-api";
+import { serverApi } from "../../common/api/server-api";
 import { Dispatch } from "redux";
 import actions from "../../store/project/project-action";
 import { Project } from "../../models/project";
+import UrlBuilder from "../../common/api/url-builder";
+import FilterBuilder, { FilterField } from "../../common/api/filter-builder";
 
 interface GroupsResponse {
     offset: number;
@@ -31,9 +33,15 @@ interface GroupsResponse {
 }
 
 export default class ProjectService {
-    public getTopProjectList = () => (dispatch: Dispatch) => {
-        dispatch(actions.TOP_PROJECTS_REQUEST());
-        serverApi.get<GroupsResponse>('/groups').then(groups => {
+    public getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise<Project[]> => {
+        dispatch(actions.PROJECTS_REQUEST());
+
+        const ub = new UrlBuilder('/groups');
+        const fb = new FilterBuilder();
+        fb.addEqual(FilterField.OWNER_UUID, parentUuid);
+        const url = ub.addParam('filters', fb.get()).get();
+
+        return serverApi.get<GroupsResponse>(url).then(groups => {
             const projects = groups.data.items.map(g => ({
                 name: g.name,
                 createdAt: g.created_at,
@@ -42,7 +50,8 @@ export default class ProjectService {
                 uuid: g.uuid,
                 ownerUuid: g.owner_uuid
             } as Project));
-            dispatch(actions.TOP_PROJECTS_SUCCESS(projects));
+            dispatch(actions.PROJECTS_SUCCESS({projects, parentItemId: parentUuid}));
+            return projects;
         });
-    }
+    };
 }
index f2e20baa8971c93f2b036f2a762697e4f7fc7161..17bd42175f8df6647a98d847f6c0a48d79ee4b1d 100644 (file)
@@ -10,7 +10,7 @@ import {
     USER_FIRST_NAME_KEY,
     USER_LAST_NAME_KEY
 } from "../../services/auth-service/auth-service";
-import { API_HOST } from "../../common/server-api";
+import { API_HOST } from "../../common/api/server-api";
 
 import 'jest-localstorage-mock';
 
index 3fad4cf7569019b3ecbc1ed0d7b8d047e5f2c219..57a17ae53cfbb45f9c69529ad34d866771b0f215 100644 (file)
@@ -5,7 +5,7 @@
 import actions, { AuthAction } from "./auth-action";
 import { User } from "../../models/user";
 import { authService } from "../../services/services";
-import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/server-api";
+import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/api/server-api";
 import { UserDetailsResponse } from "../../services/auth-service/auth-service";
 
 export interface AuthState {
index 7c91cc551a521fdb97db5879ab7f089fb6f85250..87ecbda9b56fbe1850d202d5cbb824373554d758 100644 (file)
@@ -8,8 +8,9 @@ import { default as unionize, ofType, UnionOf } from "unionize";
 const actions = unionize({
     CREATE_PROJECT: ofType<Project>(),
     REMOVE_PROJECT: ofType<string>(),
-    TOP_PROJECTS_REQUEST: {},
-    TOP_PROJECTS_SUCCESS: ofType<Project[]>()
+    PROJECTS_REQUEST: {},
+    PROJECTS_SUCCESS: ofType<{ projects: Project[], parentItemId?: string }>(),
+    TOGGLE_PROJECT_TREE_ITEM: ofType<string>()
 }, {
     tag: 'type',
     value: 'payload'
index e5f5c27532ebb71a02039aa961cd125968d769ec..9c1ed3b4ded0db64082dbc3879dcafc9f98eae91 100644 (file)
@@ -32,8 +32,21 @@ describe('project-reducer', () => {
             uuid: 'test123'
         };
 
-        const topProjects = [project, project];
-        const state = projectsReducer(initialState, actions.TOP_PROJECTS_SUCCESS(topProjects));
-        expect(state).toEqual(topProjects);
+        const projects = [project, project];
+        const state = projectsReducer(initialState, actions.PROJECTS_SUCCESS({projects, parentItemId: undefined}));
+        expect(state).toEqual([{
+                active: false,
+                open: false,
+                id: "test123",
+                items: [],
+                data: project
+            }, {
+                active: false,
+                open: false,
+                id: "test123",
+                items: [],
+                data: project
+            }
+        ]);
     });
 });
index 64e7925522614ca08c80d4a3912dee646cb2c3cb..887cf89b334fea055334b6871c2e3dff00ca2271 100644 (file)
@@ -4,16 +4,70 @@
 
 import { Project } from "../../models/project";
 import actions, { ProjectAction } from "./project-action";
+import { TreeItem } from "../../components/tree/tree";
+import * as _ from "lodash";
+
+export type ProjectState = Array<TreeItem<Project>>;
+
+function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
+    let item;
+    for (const t of tree) {
+        item = t.id === itemId
+            ? t
+            : findTreeItem(t.items ? t.items : [], itemId);
+        if (item) {
+            break;
+        }
+    }
+    return item;
+}
+
+function resetTreeActivity<T>(tree: Array<TreeItem<T>>) {
+    for (const t of tree) {
+        t.active = false;
+        resetTreeActivity(t.items ? t.items : []);
+    }
+}
+
+function updateProjectTree(tree: Array<TreeItem<Project>>, projects: Project[], parentItemId?: string): Array<TreeItem<Project>> {
+    let treeItem;
+    if (parentItemId) {
+        treeItem = findTreeItem(tree, parentItemId);
+    }
+    const items = projects.map((p, idx) => ({
+        id: p.uuid,
+        open: false,
+        active: false,
+        data: p,
+        items: []
+    } as TreeItem<Project>));
+
+    if (treeItem) {
+        treeItem.items = items;
+        return tree;
+    }
+
+    return items;
+}
 
-export type ProjectState = Project[];
 
 const projectsReducer = (state: ProjectState = [], action: ProjectAction) => {
     return actions.match(action, {
         CREATE_PROJECT: project => [...state, project],
         REMOVE_PROJECT: () => state,
-        TOP_PROJECTS_REQUEST: () => state,
-        TOP_PROJECTS_SUCCESS: projects => {
-            return projects;
+        PROJECTS_REQUEST: () => state,
+        PROJECTS_SUCCESS: ({ projects, parentItemId }) => {
+            return updateProjectTree(state, projects, parentItemId);
+        },
+        TOGGLE_PROJECT_TREE_ITEM: itemId => {
+            const tree = _.cloneDeep(state);
+            resetTreeActivity(tree);
+            const item = findTreeItem(tree, itemId);
+            if (item) {
+                item.open = !item.open;
+                item.active = true;
+            }
+            return tree;
         },
         default: () => state
     });
index fc89609acc43b45767fcebb0cec2f0518953423f..7b9b74d095c65a8a1ad760a2c4c87f360cb13836 100644 (file)
@@ -8,10 +8,19 @@ import Workbench from '../../views/workbench/workbench';
 import { Provider } from "react-redux";
 import configureStore from "../../store/store";
 import createBrowserHistory from "history/createBrowserHistory";
+import { ConnectedRouter } from "react-router-redux";
+
+const history = createBrowserHistory();
 
 it('renders without crashing', () => {
     const div = document.createElement('div');
     const store = configureStore({ projects: [], router: { location: null }, auth: {} }, createBrowserHistory());
-    ReactDOM.render(<Provider store={store}><Workbench/></Provider>, div);
+    ReactDOM.render(
+        <Provider store={store}>
+            <ConnectedRouter history={history}>
+                <Workbench/>
+            </ConnectedRouter>
+        </Provider>,
+    div);
     ReactDOM.unmountComponentAtNode(div);
 });
index f002ad9a9ae4774f6bf2fe01055cf6406fd06feb..3d0a7ad310b0c135b08a2eade23690aa0627c3ae 100644 (file)
@@ -10,8 +10,6 @@ import AppBar from '@material-ui/core/AppBar';
 import Toolbar from '@material-ui/core/Toolbar';
 import Typography from '@material-ui/core/Typography';
 import { connect, DispatchProp } from "react-redux";
-import Tree from "../../components/tree/tree";
-import { Project } from "../../models/project";
 import ProjectList from "../../components/project-list/project-list";
 import { Route, Switch } from "react-router";
 import { Link } from "react-router-dom";
@@ -27,6 +25,11 @@ import { RootState } from "../../store/store";
 import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItems, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar';
 import { Breadcrumb } from '../../components/breadcrumbs/breadcrumbs';
 import { push } from 'react-router-redux';
+import projectActions from "../../store/project/project-action"
+import ProjectTree from '../../components/project-tree/project-tree';
+import { TreeItem } from "../../components/tree/tree";
+import { Project } from "../../models/project";
+import { projectService } from '../../services/services';
 
 const drawerWidth = 240;
 
@@ -63,7 +66,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
 });
 
 interface WorkbenchDataProps {
-    projects: Project[];
+    projects: Array<TreeItem<Project>>;
     user?: User;
 }
 
@@ -140,6 +143,12 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
         onMenuItemClick: (menuItem: NavMenuItem) => menuItem.action()
     }
 
+    toggleProjectTreeItem = (itemId: string) => {
+        this.props.dispatch<any>(projectService.getProjectList(itemId)).then(() => {
+            this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM(itemId));
+        });
+    };
+
     render() {
         const { classes, user } = this.props;
         return (
@@ -154,17 +163,16 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                     />
                 </div>
                 {user &&
-                    <Drawer
-                        variant="permanent"
-                        classes={{
-                            paper: classes.drawerPaper,
-                        }}>
-                        <div className={classes.toolbar} />
-                        <div className={classes.toolbar} />
-                        <Tree items={this.props.projects} render={(p: Project) =>
-                            <Link to={`/project/${p.name}`}>{p.name}</Link>
-                        } />
-                    </Drawer>}
+                <Drawer
+                    variant="permanent"
+                    classes={{
+                        paper: classes.drawerPaper,
+                    }}>
+                    <div className={classes.toolbar}/>
+                    <ProjectTree
+                        projects={this.props.projects}
+                        toggleProjectTreeItem={this.toggleProjectTreeItem}/>
+                </Drawer>}
                 <main className={classes.content}>
                     <div className={classes.toolbar} />
                     <div className={classes.toolbar} />
index 98d5d9151c7b8f706dcfd3944f0bdee6a573f2c8..af933d9fa9a09755a64da1f8f3f8c4c5b54c1d68 100644 (file)
@@ -31,6 +31,7 @@
     "acceptance-tests",
     "webpack",
     "jest",
-    "src/setupTests.ts"
+    "src/setupTests.ts",
+    "**/*.test.tsx"
   ]
 }
index 65ffdd493929cf996f7f185609fb9f3f7f14184b..2c7b284162f4cafdbef8875c7ae7cb517c8e7abd 100644 (file)
@@ -3,4 +3,4 @@
   "compilerOptions": {
     "module": "commonjs"
   }
-}
\ No newline at end of file
+}
index ccb194f75b7577c150c09125d6b0d8ef6b0edc0d..1b26ab5f0f629330f31409d8b463422f1ee668d1 100644 (file)
@@ -10,7 +10,8 @@
     "jsx-boolean-value": false,
     "jsx-no-lambda": false,
     "no-debugger": false,
-    "no-console": false
+    "no-console": false,
+    "no-shadowed-variable": false
   },
   "linterOptions": {
     "exclude": [
index bfa456cc5f5086814cc0b057d914c7051ea9e5a2..309773f01a2af6e57d7aecc169a7b80d1e076620 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     csstype "^2.0.0"
     indefinite-observable "^1.0.1"
 
+"@types/lodash@^4.14.109":
+  version "4.14.109"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.109.tgz#b1c4442239730bf35cabaf493c772b18c045886d"
+
 "@types/node@*", "@types/node@10.3.0":
   version "10.3.0"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.3.0.tgz#078516315a84d56216b5d4fed8f75d59d3b16cac"
@@ -4640,7 +4644,7 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
-"lodash@>=3.5 <5", lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0:
+lodash@4.17.10, "lodash@>=3.5 <5", lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0:
   version "4.17.10"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7"