refs #master Merge branch 'origin/master' into 13828-trash-view
authorDaniel Kos <daniel.kos@contractors.roche.com>
Thu, 30 Aug 2018 17:59:36 +0000 (19:59 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Thu, 30 Aug 2018 17:59:36 +0000 (19:59 +0200)
# Conflicts:
# package.json
# src/models/container-request.ts
# src/models/resource.ts
# src/services/collection-service/collection-service.ts
# src/services/services.ts
# src/store/navigation/navigation-action.ts
# src/store/project/project-action.ts
# src/store/project/project-reducer.test.ts
# src/store/project/project-reducer.ts
# src/store/side-panel/side-panel-reducer.ts
# src/store/store.ts
# src/views-components/context-menu/action-sets/collection-action-set.ts
# src/views-components/context-menu/action-sets/collection-resource-action-set.ts
# src/views-components/context-menu/action-sets/project-action-set.ts
# src/views/favorite-panel/favorite-panel-item.ts
# src/views/project-panel/project-panel-item.ts
# src/views/workbench/workbench.tsx

Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

29 files changed:
src/common/formatters.ts
src/components/icon/icon.tsx
src/models/collection.ts
src/models/container-request.ts [deleted file]
src/models/group.ts
src/models/process.ts
src/models/project.ts
src/models/resource.ts
src/models/test-utils.ts
src/services/collection-service/collection-service.ts
src/services/groups-service/groups-service.ts
src/store/collections/collection-trash-actions.ts [new file with mode: 0644]
src/store/context-menu/context-menu-actions.ts
src/store/context-menu/context-menu-reducer.ts
src/store/project/project-action.ts [new file with mode: 0644]
src/store/store.ts
src/store/trash-panel/trash-panel-action.ts [new file with mode: 0644]
src/store/trash-panel/trash-panel-middleware-service.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/collection-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/actions/trash-action.tsx [new file with mode: 0644]
src/views-components/data-explorer/renderers.tsx
src/views/collection-panel/collection-panel.tsx
src/views/favorite-panel/favorite-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/trash-panel/trash-panel-item.ts [new file with mode: 0644]
src/views/trash-panel/trash-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index 49e0690515e868a4b1145c79ccbd2ede9d813314..b1baee7de912e56a4b22b683cd172dfdea542a1c 100644 (file)
@@ -2,10 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-export const formatDate = (isoDate: string) => {
-    const date = new Date(isoDate);
-    const text = date.toLocaleString();
-    return text === 'Invalid Date' ? "" : text;
+export const formatDate = (isoDate?: string) => {
+    if (isoDate) {
+        const date = new Date(isoDate);
+        const text = date.toLocaleString();
+        return text === 'Invalid Date' ? "" : text;
+    }
+    return "";
 };
 
 export const formatFileSize = (size?: number) => {
index 0f0442a53ebd1b7be4b485694b45e86e3a99be7a..86a1a68c3ec1e4ef59a16b77a099041eb6f8d79a 100644 (file)
@@ -32,6 +32,7 @@ import Person from '@material-ui/icons/Person';
 import PersonAdd from '@material-ui/icons/PersonAdd';
 import PlayArrow from '@material-ui/icons/PlayArrow';
 import RateReview from '@material-ui/icons/RateReview';
+import RestoreFromTrash from '@material-ui/icons/RestoreFromTrash';
 import Search from '@material-ui/icons/Search';
 import SettingsApplications from '@material-ui/icons/SettingsApplications';
 import Star from '@material-ui/icons/Star';
@@ -67,6 +68,7 @@ export const RecentIcon: IconType = (props) => <AccessTime {...props} />;
 export const RemoveIcon: IconType = (props) => <Delete {...props} />;
 export const RemoveFavoriteIcon: IconType = (props) => <Star {...props} />;
 export const RenameIcon: IconType = (props) => <Edit {...props} />;
+export const RestoreFromTrashIcon: IconType = (props) => <RestoreFromTrash {...props} />;
 export const ReRunProcessIcon: IconType = (props) => <Cached {...props} />;
 export const SearchIcon: IconType = (props) => <Search {...props} />;
 export const ShareIcon: IconType = (props) => <PersonAdd {...props} />;
index 0e96f7fd3dd6f1473f62d8c2a2e3226aa42f6507..5215998956cde1d419b00e1c47a71beab565f4eb 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Resource, ResourceKind } from "./resource";
+import { ResourceKind, TrashResource } from "./resource";
 
-export interface CollectionResource extends Resource {
+export interface CollectionResource extends TrashResource {
     kind: ResourceKind.COLLECTION;
     name: string;
     description: string;
@@ -14,11 +14,8 @@ export interface CollectionResource extends Resource {
     replicationDesired: number;
     replicationConfirmed: number;
     replicationConfirmedAt: string;
-    trashAt: string;
-    deleteAt: string;
-    isTrashed: boolean;
 }
 
 export const getCollectionUrl = (uuid: string) => {
     return `/collections/${uuid}`;
-};
\ No newline at end of file
+};
diff --git a/src/models/container-request.ts b/src/models/container-request.ts
deleted file mode 100644 (file)
index 78891c7..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { Resource, ResourceKind } from "./resource";
-import { MountType } from "~/models/mount-types";
-import { RuntimeConstraints } from './runtime-constraints';
-import { SchedulingParameters } from './scheduling-parameters';
-
-export enum ContainerRequestState {
-    UNCOMMITTED = "Uncommitted",
-    COMMITTED = "Committed",
-    FINAL = "Final"
-}
-
-export interface ContainerRequestResource extends Resource {
-    kind: ResourceKind.CONTAINER_REQUEST;
-    name: string;
-    description: string;
-    properties: any;
-    state: ContainerRequestState;
-    requestingContainerUuid: string | null;
-    containerUuid: string | null;
-    containerCountMax: number;
-    mounts: MountType[];
-    runtimeConstraints: RuntimeConstraints;
-    schedulingParameters: SchedulingParameters;
-    containerImage: string;
-    environment: any;
-    cwd: string;
-    command: string[];
-    outputPath: string;
-    outputName: string;
-    outputTtl: number;
-    priority: number | null;
-    expiresAt: string;
-    useExisting: boolean;
-    logUuid: string | null;
-    outputUuid: string | null;
-    filters: string;
-}
index 5e8d7a1e0b4a2e76ceaf9c80d035f55417aa55d6..5319250e1bfc406714e30a2f3a909b337488dc19 100644 (file)
@@ -2,20 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Resource, ResourceKind } from "./resource";
+import { ResourceKind, TrashResource } from "./resource";
 
-export interface GroupResource extends Resource {
+export interface GroupResource extends TrashResource {
     kind: ResourceKind.GROUP;
     name: string;
     groupClass: GroupClass | null;
     description: string;
     properties: string;
     writeableBy: string[];
-    trashAt: string;
-    deleteAt: string;
-    isTrashed: boolean;
 }
 
 export enum GroupClass {
     PROJECT = "project"
-}
\ No newline at end of file
+}
index 1e04cb10f395de5284765fbb50244d2e45d4ea1d..bcfbd3a557faa074bb9c654fa50eadd39039dfd7 100644 (file)
@@ -2,6 +2,37 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContainerRequestResource } from "./container-request";
+import { Resource, ResourceKind } from "./resource";
 
-export type ProcessResource = ContainerRequestResource;
+export enum ProcessState {
+    UNCOMMITTED = "Uncommitted",
+    COMMITTED = "Committed",
+    FINAL = "Final"
+}
+
+export interface ProcessResource extends Resource {
+    kind: ResourceKind.PROCESS;
+    name: string;
+    description: string;
+    properties: any;
+    state: ProcessState;
+    requestingContainerUuid: string;
+    containerUuid: string;
+    containerCountMax: number;
+    mounts: any;
+    runtimeConstraints: any;
+    schedulingParameters: any;
+    containerImage: string;
+    environment: any;
+    cwd: string;
+    command: string[];
+    outputPath: string;
+    outputName: string;
+    outputTtl: number;
+    priority: number;
+    expiresAt: string;
+    useExisting: boolean;
+    logUuid: string;
+    outputUuid: string;
+    filters: string;
+}
index b919450774f687084a700d8b4d041772aa142669..8e101ce29ffeaac99cb7c2073aae96b8b79ac9e8 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { GroupResource, GroupClass } from "./group";
+import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
     groupClass: GroupClass.PROJECT;
index 3290bdfe06f07ed60fa27accd067cff47f0d0b6b..aff1b2417d6fe06a04b9ada5e1bf5fbb31282876 100644 (file)
@@ -14,6 +14,12 @@ export interface Resource {
     etag: string;
 }
 
+export interface TrashResource extends Resource {
+    trashAt: string;
+    deleteAt: string;
+    isTrashed: boolean;
+}
+
 export enum ResourceKind {
     COLLECTION = "arvados#collection",
     CONTAINER = "arvados#container",
index 6723430c34872848c26a6b9fa8be172b780f5603..49eea605d563d106cb53cc63817f3074227576f7 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { GroupResource, GroupClass } from "./group";
+import { GroupClass, GroupResource } from "./group";
 import { Resource, ResourceKind } from "./resource";
 import { ProjectResource } from "./project";
 
index c0d61bd27b4d5d789a09a0096c16154ddcbbb461..e26da78875b91963a6a1e51703f2a1f9ffbc17cc 100644 (file)
@@ -68,4 +68,21 @@ export class CollectionService extends CommonResourceService<CollectionResource>
 
     }
 
+    trash(uuid: string): Promise<CollectionResource> {
+        return this.serverApi
+            .post(this.resourceType + `${uuid}/trash`)
+            .then(CommonResourceService.mapResponseKeys);
+    }
+
+    untrash(uuid: string): Promise<CollectionResource> {
+        const params = {
+            ensure_unique_name: true
+        };
+        return this.serverApi
+            .post(this.resourceType + `${uuid}/untrash`, {
+                params: CommonResourceService.mapKeys(_.snakeCase)(params)
+            })
+            .then(CommonResourceService.mapResponseKeys);
+    }
+    
 }
index 822c810ef7ed0203666bdba829c0b13fced7d5ba..4756aa3edcf22c1b000a6b7894fdc265970c9a44 100644 (file)
@@ -5,10 +5,10 @@
 import * as _ from "lodash";
 import { CommonResourceService, ListResults } from "~/common/api/common-resource-service";
 import { AxiosInstance } from "axios";
-import { GroupResource } from "~/models/group";
 import { CollectionResource } from "~/models/collection";
 import { ProjectResource } from "~/models/project";
 import { ProcessResource } from "~/models/process";
+import { TrashResource } from "~/models/resource";
 
 export interface ContentsArguments {
     limit?: number;
@@ -16,6 +16,7 @@ export interface ContentsArguments {
     order?: string;
     filters?: string;
     recursive?: boolean;
+    includeTrash?: boolean;
 }
 
 export type GroupContentsResource =
@@ -23,7 +24,7 @@ export type GroupContentsResource =
     ProjectResource |
     ProcessResource;
 
-export class GroupsService<T extends GroupResource = GroupResource> extends CommonResourceService<T> {
+export class GroupsService<T extends TrashResource = TrashResource> extends CommonResourceService<T> {
 
     constructor(serverApi: AxiosInstance) {
         super(serverApi, "groups");
@@ -37,11 +38,29 @@ export class GroupsService<T extends GroupResource = GroupResource> extends Comm
             order: order ? order : undefined
         };
         return this.serverApi
-            .get(this.resourceType + `${uuid}/contents/`, {
+            .get(this.resourceType + `${uuid}/contents`, {
                 params: CommonResourceService.mapKeys(_.snakeCase)(params)
             })
             .then(CommonResourceService.mapResponseKeys);
     }
+
+    trash(uuid: string): Promise<T> {
+        return this.serverApi
+            .post(this.resourceType + `${uuid}/trash`)
+            .then(CommonResourceService.mapResponseKeys);
+    }
+
+    untrash(uuid: string): Promise<T> {
+        const params = {
+            ensure_unique_name: true
+        };
+        return this.serverApi
+            .post(this.resourceType + `${uuid}/untrash`, {
+                params: CommonResourceService.mapKeys(_.snakeCase)(params)
+            })
+            .then(CommonResourceService.mapResponseKeys);
+    }
+
 }
 
 export enum GroupContentsResourcePrefix {
diff --git a/src/store/collections/collection-trash-actions.ts b/src/store/collections/collection-trash-actions.ts
new file mode 100644 (file)
index 0000000..c6d4ee0
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { snackbarActions } from "~/store/snackbar/snackbar-actions";
+import { sidePanelActions } from "~/store/side-panel/side-panel-action";
+import { SidePanelId } from "~/store/side-panel/side-panel-reducer";
+import { trashPanelActions } from "~/store/trash-panel/trash-panel-action";
+import { getProjectList, projectActions } from "~/store/project/project-action";
+
+export const toggleCollectionTrashed = (resource: { uuid: string; name: string, isTrashed?: boolean, ownerUuid?: string }) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Working..." }));
+        if (resource.isTrashed) {
+            return services.collectionService.untrash(resource.uuid).then(() => {
+                dispatch<any>(getProjectList(resource.ownerUuid)).then(() => {
+                    dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(SidePanelId.PROJECTS));
+                    dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN({ itemId: resource.ownerUuid!!, open: true, recursive: true }));
+                });
+                dispatch(trashPanelActions.REQUEST_ITEMS());
+                dispatch(snackbarActions.CLOSE_SNACKBAR());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Restored from trash",
+                    hideDuration: 2000
+                }));
+            });
+        } else {
+            return services.collectionService.trash(resource.uuid).then(() => {
+                dispatch(snackbarActions.CLOSE_SNACKBAR());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Added to trash",
+                    hideDuration: 2000
+                }));
+            });
+        }
+    };
index cf66a53d2361587823219d3d698cfa2572fd07d5..a1ed6c5536bc4b71bb2b6b8e26ed5fc4f16dfcf7 100644 (file)
@@ -9,7 +9,7 @@ import { Dispatch } from 'redux';
 import { RootState } from '~/store/store';
 import { getResource } from '../resources/resources';
 import { ProjectResource } from '~/models/project';
-import { UserResource } from '../../models/user';
+import { UserResource } from '~/models/user';
 import { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { extractUuidKind, ResourceKind } from '~/models/resource';
 
@@ -20,7 +20,16 @@ export const contextMenuActions = unionize({
 
 export type ContextMenuAction = UnionOf<typeof contextMenuActions>;
 
-export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; description?: string; kind: ContextMenuKind; }) =>
+export type ContextMenuResource = {
+    name: string;
+    uuid: string;
+    ownerUuid: string;
+    description?: string;
+    kind: ContextMenuKind;
+    isTrashed?: boolean;
+}
+
+export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) =>
     (dispatch: Dispatch) => {
         event.preventDefault();
         dispatch(
@@ -33,24 +42,28 @@ export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource:
 
 export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const userResource = getResource<UserResource>(projectUuid)(getState().resources);
-        if (userResource) {
+        const res = getResource<UserResource>(projectUuid)(getState().resources);
+        if (res) {
             dispatch<any>(openContextMenu(event, {
                 name: '',
-                uuid: userResource.uuid,
-                kind: ContextMenuKind.ROOT_PROJECT
+                uuid: res.uuid,
+                ownerUuid: res.uuid,
+                kind: ContextMenuKind.ROOT_PROJECT,
+                isTrashed: false
             }));
         }
     };
 
 export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const projectResource = getResource<ProjectResource>(projectUuid)(getState().resources);
-        if (projectResource) {
+        const res = getResource<ProjectResource>(projectUuid)(getState().resources);
+        if (res) {
             dispatch<any>(openContextMenu(event, {
-                name: projectResource.name,
-                uuid: projectResource.uuid,
-                kind: ContextMenuKind.PROJECT
+                name: res.name,
+                uuid: res.uuid,
+                kind: ContextMenuKind.PROJECT,
+                ownerUuid: res.ownerUuid,
+                isTrashed: res.isTrashed
             }));
         }
     };
index ac14c35534dba5b5fd0576ec0f10bac47631b7d8..8026c1d1d66125f2ee5a597dc5e3eb95b2cb8089 100644 (file)
@@ -20,6 +20,8 @@ export interface ContextMenuResource {
     kind: string;
     name: string;
     description?: string;
+    isTrashed?: boolean;
+    ownerUuid?: string;
 }
 
 const initialState = {
diff --git a/src/store/project/project-action.ts b/src/store/project/project-action.ts
new file mode 100644 (file)
index 0000000..bb5da19
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { default as unionize, ofType, UnionOf } from "unionize";
+
+import { ProjectResource } from "~/models/project";
+import { Dispatch } from "redux";
+import { FilterBuilder } from "~/common/api/filter-builder";
+import { RootState } from "../store";
+import { checkPresenceInFavorites } from "../favorites/favorites-actions";
+import { ServiceRepository } from "~/services/services";
+import { projectPanelActions } from "~/store/project-panel/project-panel-action";
+import { updateDetails } from "~/store/details-panel/details-panel-action";
+import { snackbarActions } from "~/store/snackbar/snackbar-actions";
+import { trashPanelActions } from "~/store/trash-panel/trash-panel-action";
+import { sidePanelActions } from "~/store/side-panel/side-panel-action";
+import { SidePanelId } from "~/store/side-panel/side-panel-reducer";
+
+export const projectActions = unionize({
+    OPEN_PROJECT_CREATOR: ofType<{ ownerUuid: string }>(),
+    CLOSE_PROJECT_CREATOR: ofType<{}>(),
+    CREATE_PROJECT: ofType<Partial<ProjectResource>>(),
+    CREATE_PROJECT_SUCCESS: ofType<ProjectResource>(),
+    OPEN_PROJECT_UPDATER: ofType<{ uuid: string}>(),
+    CLOSE_PROJECT_UPDATER: ofType<{}>(),
+    UPDATE_PROJECT_SUCCESS: ofType<ProjectResource>(),
+    REMOVE_PROJECT: ofType<string>(),
+    PROJECTS_REQUEST: ofType<string>(),
+    PROJECTS_SUCCESS: ofType<{ projects: ProjectResource[], parentItemId?: string }>(),
+    TOGGLE_PROJECT_TREE_ITEM_OPEN: ofType<{ itemId: string, open?: boolean, recursive?: boolean }>(),
+    TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType<{ itemId: string, active?: boolean, recursive?: boolean }>(),
+    RESET_PROJECT_TREE_ACTIVITY: ofType<string>()
+}, {
+    tag: 'type',
+    value: 'payload'
+});
+
+export const PROJECT_FORM_NAME = 'projectEditDialog';
+
+export const getProjectList = (parentUuid: string = '') =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(projectActions.PROJECTS_REQUEST(parentUuid));
+        return services.projectService.list({
+            filters: new FilterBuilder()
+                .addEqual("ownerUuid", parentUuid)
+                .getFilters()
+        }).then(({ items: projects }) => {
+            dispatch(projectActions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid }));
+            dispatch<any>(checkPresenceInFavorites(projects.map(project => project.uuid)));
+            return projects;
+        });
+    };
+
+export const createProject = (project: Partial<ProjectResource>) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { ownerUuid } = getState().projects.creator;
+        const projectData = { ownerUuid, ...project };
+        dispatch(projectActions.CREATE_PROJECT(projectData));
+        return services.projectService
+            .create(projectData)
+            .then(project => dispatch(projectActions.CREATE_PROJECT_SUCCESS(project)));
+    };
+
+export const updateProject = (project: Partial<ProjectResource>) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { uuid } = getState().projects.updater;
+        return services.projectService
+            .update(uuid, project)
+            .then(project => {
+                dispatch(projectActions.UPDATE_PROJECT_SUCCESS(project));
+                dispatch(projectPanelActions.REQUEST_ITEMS());
+                dispatch<any>(getProjectList(project.ownerUuid));
+                dispatch<any>(updateDetails(project));
+            });
+    };
+
+export const toggleProjectTrashed = (resource: { uuid: string; name: string, isTrashed?: boolean, ownerUuid?: string }) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Working..." }));
+        if (resource.isTrashed) {
+            return services.groupsService.untrash(resource.uuid).then(() => {
+                dispatch<any>(getProjectList(resource.ownerUuid)).then(() => {
+                    dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(SidePanelId.PROJECTS));
+                    dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN({ itemId: resource.ownerUuid!!, open: true, recursive: true }));
+                });
+                dispatch(trashPanelActions.REQUEST_ITEMS());
+                dispatch(snackbarActions.CLOSE_SNACKBAR());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Restored from trash",
+                    hideDuration: 2000
+                }));
+            });
+        } else {
+            return services.groupsService.trash(resource.uuid).then(() => {
+                dispatch<any>(getProjectList(resource.ownerUuid)).then(() => {
+                    dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN({ itemId: resource.ownerUuid!!, open: true, recursive: true }));
+                });
+                dispatch(snackbarActions.CLOSE_SNACKBAR());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Added to trash",
+                    hideDuration: 2000
+                }));
+            });
+        }
+    };
+
+export type ProjectAction = UnionOf<typeof projectActions>;
index 4fe0f97a697718ee2e5c37b0375291d658b33e25..584d05e923fd55d8f443be7512c7f16f88234c26 100644 (file)
@@ -47,12 +47,16 @@ export function configureStore(history: History, services: ServiceRepository): R
     const favoritePanelMiddleware = dataExplorerMiddleware(
         new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID)
     );
+    const trashPanelMiddleware = dataExplorerMiddleware(
+        new TrashPanelMiddlewareService(services, TRASH_PANEL_ID)
+    );
 
     const middlewares: Middleware[] = [
         routerMiddleware(history),
         thunkMiddleware.withExtraArgument(services),
         projectPanelMiddleware,
-        favoritePanelMiddleware
+        favoritePanelMiddleware,
+        trashPanelMiddleware
     ];
     const enhancer = composeEnhancers(applyMiddleware(...middlewares));
     return createStore(rootReducer, enhancer);
diff --git a/src/store/trash-panel/trash-panel-action.ts b/src/store/trash-panel/trash-panel-action.ts
new file mode 100644 (file)
index 0000000..84d5602
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
+
+export const TRASH_PANEL_ID = "trashPanel";
+export const trashPanelActions = bindDataExplorerActions(TRASH_PANEL_ID);
diff --git a/src/store/trash-panel/trash-panel-middleware-service.ts b/src/store/trash-panel/trash-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..3a4da39
--- /dev/null
@@ -0,0 +1,78 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-middleware-service";
+import { RootState } from "../store";
+import { DataColumns } from "~/components/data-table/data-table";
+import { ServiceRepository } from "~/services/services";
+import { SortDirection } from "~/components/data-table/data-column";
+import { FilterBuilder } from "~/common/api/filter-builder";
+import { checkPresenceInFavorites } from "../favorites/favorites-actions";
+import { trashPanelActions } from "./trash-panel-action";
+import { Dispatch, MiddlewareAPI } from "redux";
+import { OrderBuilder, OrderDirection } from "~/common/api/order-builder";
+import { GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
+import { resourceToDataItem, TrashPanelItem } from "~/views/trash-panel/trash-panel-item";
+import { TrashPanelColumnNames, TrashPanelFilter } from "~/views/trash-panel/trash-panel";
+import { ProjectResource } from "~/models/project";
+import { ProjectPanelColumnNames } from "~/views/project-panel/project-panel";
+
+export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = api.getState().dataExplorer[this.getId()];
+        const columns = dataExplorer.columns as DataColumns<TrashPanelItem, TrashPanelFilter>;
+        const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE);
+        const typeFilters = this.getColumnFilters(columns, TrashPanelColumnNames.TYPE);
+
+        const order = new OrderBuilder<ProjectResource>();
+
+        if (sortColumn) {
+            const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
+                ? OrderDirection.ASC
+                : OrderDirection.DESC;
+
+            const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt";
+            order
+                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION)
+                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT);
+        }
+
+        const userUuid = this.services.authService.getUuid()!;
+
+        this.services.groupsService
+            .contents(userUuid, {
+                limit: dataExplorer.rowsPerPage,
+                offset: dataExplorer.page * dataExplorer.rowsPerPage,
+                order: order.getOrder(),
+                filters: new FilterBuilder()
+                    .addIsA("uuid", typeFilters.map(f => f.type))
+                    .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
+                    .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
+                    .getFilters(),
+                recursive: true,
+                includeTrash: true
+            })
+            .then(response => {
+                api.dispatch(trashPanelActions.SET_ITEMS({
+                    items: response.items.map(resourceToDataItem).filter(it => it.isTrashed),
+                    itemsAvailable: response.itemsAvailable,
+                    page: Math.floor(response.offset / response.limit),
+                    rowsPerPage: response.limit
+                }));
+                api.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
+            })
+            .catch(() => {
+                api.dispatch(trashPanelActions.SET_ITEMS({
+                    items: [],
+                    itemsAvailable: 0,
+                    page: 0,
+                    rowsPerPage: dataExplorer.rowsPerPage
+                }));
+            });
+    }
+}
index b3fdc3fbab642561d823cddd003a70b9ab288149..f26003653382701cf8b700143bd30db830788c46 100644 (file)
@@ -10,6 +10,8 @@ import { openCollectionUpdateDialog } from "~/store/collections/collection-updat
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
 import { openMoveCollectionDialog } from '~/store/collections/collection-move-actions';
 import { openCollectionCopyDialog } from "~/store/collections/collection-copy-actions";
+import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
+import { toggleCollectionTrashed } from "~/store/collections/collection-trash-actions";
 
 export const collectionActionSet: ContextMenuActionSet = [[
     {
@@ -39,6 +41,12 @@ export const collectionActionSet: ContextMenuActionSet = [[
             });
         }
     },
+    {
+        component: ToggleTrashAction,
+        execute: (dispatch, resource) => {
+            dispatch<any>(toggleCollectionTrashed(resource));
+        }
+    },
     {
         icon: CopyIcon,
         name: "Copy to project",
index a299b9370ca93f39a437338b486ab639935b323e..a1df8385a4e1ad1a4a369412c5815d31a9cebb83 100644 (file)
@@ -4,6 +4,7 @@
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, RemoveIcon } from "~/components/icon/icon";
 import { openCollectionUpdateDialog } from "~/store/collections/collection-update-actions";
@@ -39,6 +40,12 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
             });
         }
     },
+    {
+        component: ToggleTrashAction,
+        execute: (dispatch, resource) => {
+            dispatch<any>(toggleCollectionTrashed(resource));
+        }
+    },
     {
         icon: CopyIcon,
         name: "Copy to project",
index af10aedf804201b2a1c8bb662e1dcf4d15313936..d2412e7eda495394e339d25cee97f76cff517d02 100644 (file)
@@ -2,28 +2,32 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { reset, initialize } from "redux-form";
+
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { NewProjectIcon, RenameIcon, CopyIcon, MoveToIcon } from "~/components/icon/icon";
+import { projectActions, PROJECT_FORM_NAME, toggleProjectTrashed } from "~/store/project/project-action";
+import { NewProjectIcon, RenameIcon } from "~/components/icon/icon";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
-import { openMoveProjectDialog } from '~/store/projects/project-move-actions';
-import { openProjectCreateDialog } from '~/store/projects/project-create-actions';
-import { openProjectUpdateDialog } from '~/store/projects/project-update-actions';
+import { PROJECT_CREATE_DIALOG } from "../../dialog-create/dialog-project-create";
+import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 
 export const projectActionSet: ContextMenuActionSet = [[
     {
         icon: NewProjectIcon,
         name: "New project",
         execute: (dispatch, resource) => {
-            dispatch<any>(openProjectCreateDialog(resource.uuid));
+            dispatch(reset(PROJECT_CREATE_DIALOG));
+            dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: resource.uuid }));
         }
     },
     {
         icon: RenameIcon,
         name: "Edit project",
         execute: (dispatch, resource) => {
-            dispatch<any>(openProjectUpdateDialog(resource));
+            dispatch(projectActions.OPEN_PROJECT_UPDATER({ uuid: resource.uuid }));
+            dispatch(initialize(PROJECT_FORM_NAME, { name: resource.name, description: resource.description }));
         }
     },
     {
@@ -35,15 +39,9 @@ export const projectActionSet: ContextMenuActionSet = [[
         }
     },
     {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => dispatch<any>(openMoveProjectDialog(resource))
-    },
-    {
-        icon: CopyIcon,
-        name: "Copy to project",
+        component: ToggleTrashAction,
         execute: (dispatch, resource) => {
-            // add code
+            dispatch<any>(toggleProjectTrashed(resource));
         }
-    },
+    }
 ]];
diff --git a/src/views-components/context-menu/actions/trash-action.tsx b/src/views-components/context-menu/actions/trash-action.tsx
new file mode 100644 (file)
index 0000000..d6c8b2f
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { RestoreFromTrashIcon, TrashIcon } from "~/components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "~/store/store";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isTrashed: state.contextMenu.resource && state.contextMenu.resource.isTrashed,
+    onClick: props.onClick
+});
+
+export const ToggleTrashAction = connect(mapStateToProps)((props: { isTrashed?: boolean, onClick: () => void }) =>
+    <ListItem button
+        onClick={props.onClick}>
+        <ListItemIcon>
+            {props.isTrashed
+                ? <RestoreFromTrashIcon/>
+                : <TrashIcon/>}
+        </ListItemIcon>
+        <ListItemText style={{ textDecoration: 'none' }}>
+            {props.isTrashed
+                ? <>Restore</>
+                : <>Move to trash</>}
+        </ListItemText>
+    </ListItem >);
index abf1839286997117c0ecc56d2a5e87f917456e23..390e601f9dbd8bac5e74967f316db3211c35163f 100644 (file)
@@ -52,7 +52,7 @@ export const renderIcon = (item: { kind: string }) => {
     }
 };
 
-export const renderDate = (date: string) => {
+export const renderDate = (date?: string) => {
     return <Typography noWrap>{formatDate(date)}</Typography>;
 };
 
index 348b548bdb4845eef161ce30210bcf5fde7833d0..8a0e2f8108332c5110218b1711d5d4ac36242767 100644 (file)
@@ -71,7 +71,6 @@ export const CollectionPanel = withStyles(styles)(
         };
     })(
         class extends React.Component<CollectionPanelProps> {
-
             render() {
                 const { classes, item, tags } = this.props;
                 return <div>
@@ -154,7 +153,6 @@ export const CollectionPanel = withStyles(styles)(
                     hideDuration: 2000
                 }));
             }
-
         }
     )
 );
index 9fbae5ced889902d4771af6dbc7bb821e6f15360..62b037e3a56da989baa16aa1c91679c2d47a7a77 100644 (file)
@@ -9,7 +9,7 @@ import { DispatchProp, connect } from 'react-redux';
 import { DataColumns } from '~/components/data-table/data-table';
 import { RouteComponentProps } from 'react-router';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
-import { ContainerRequestState } from '~/models/container-request';
+import { ProcessState } from '~/models/process';
 import { SortDirection } from '~/components/data-table/data-column';
 import { ResourceKind } from '~/models/resource';
 import { resourceLabel } from '~/common/labels';
@@ -45,7 +45,7 @@ export enum FavoritePanelColumnNames {
 }
 
 export interface FavoritePanelFilter extends DataTableFilterItem {
-    type: ResourceKind | ContainerRequestState;
+    type: ResourceKind | ProcessState;
 }
 
 export const favoritePanelColumns: DataColumns<string, FavoritePanelFilter> = [
@@ -65,19 +65,19 @@ export const favoritePanelColumns: DataColumns<string, FavoritePanelFilter> = [
         sortDirection: SortDirection.NONE,
         filters: [
             {
-                name: ContainerRequestState.COMMITTED,
+                name: ProcessState.COMMITTED,
                 selected: true,
-                type: ContainerRequestState.COMMITTED
+                type: ProcessState.COMMITTED
             },
             {
-                name: ContainerRequestState.FINAL,
+                name: ProcessState.FINAL,
                 selected: true,
-                type: ContainerRequestState.FINAL
+                type: ProcessState.FINAL
             },
             {
-                name: ContainerRequestState.UNCOMMITTED,
+                name: ProcessState.UNCOMMITTED,
                 selected: true,
-                type: ContainerRequestState.UNCOMMITTED
+                type: ProcessState.UNCOMMITTED
             }
         ],
         render: uuid => <ProcessStatus uuid={uuid} />,
index 06946430e71909d711f1bdc6c32b1ac4c0f80021..37a6d202214a6fb1b80114ad4805fac08aace93f 100644 (file)
@@ -10,7 +10,7 @@ import { DataColumns } from '~/components/data-table/data-table';
 import { RouteComponentProps } from 'react-router';
 import { RootState } from '~/store/store';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
-import { ContainerRequestState } from '~/models/container-request';
+import { ProcessState } from '~/models/process';
 import { SortDirection } from '~/components/data-table/data-column';
 import { ResourceKind } from '~/models/resource';
 import { resourceLabel } from '~/common/labels';
@@ -57,7 +57,7 @@ export enum ProjectPanelColumnNames {
 }
 
 export interface ProjectPanelFilter extends DataTableFilterItem {
-    type: ResourceKind | ContainerRequestState;
+    type: ResourceKind | ProcessState;
 }
 
 export const projectPanelColumns: DataColumns<string, ProjectPanelFilter> = [
@@ -77,19 +77,19 @@ export const projectPanelColumns: DataColumns<string, ProjectPanelFilter> = [
         sortDirection: SortDirection.NONE,
         filters: [
             {
-                name: ContainerRequestState.COMMITTED,
+                name: ProcessState.COMMITTED,
                 selected: true,
-                type: ContainerRequestState.COMMITTED
+                type: ProcessState.COMMITTED
             },
             {
-                name: ContainerRequestState.FINAL,
+                name: ProcessState.FINAL,
                 selected: true,
-                type: ContainerRequestState.FINAL
+                type: ProcessState.FINAL
             },
             {
-                name: ContainerRequestState.UNCOMMITTED,
+                name: ProcessState.UNCOMMITTED,
                 selected: true,
-                type: ContainerRequestState.UNCOMMITTED
+                type: ProcessState.UNCOMMITTED
             }
         ],
         render: uuid => <ProcessStatus uuid={uuid} />,
diff --git a/src/views/trash-panel/trash-panel-item.ts b/src/views/trash-panel/trash-panel-item.ts
new file mode 100644 (file)
index 0000000..a2f59ac
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { GroupContentsResource } from "~/services/groups-service/groups-service";
+import { TrashResource } from "~/models/resource";
+
+export interface TrashPanelItem {
+    uuid: string;
+    name: string;
+    kind: string;
+    owner: string;
+    fileSize?: number;
+    trashAt?: string;
+    deleteAt?: string;
+    isTrashed?: boolean;
+}
+
+export function resourceToDataItem(r: GroupContentsResource): TrashPanelItem {
+    return {
+        uuid: r.uuid,
+        name: r.name,
+        kind: r.kind,
+        owner: r.ownerUuid,
+        trashAt: (r as TrashResource).trashAt,
+        deleteAt: (r as TrashResource).deleteAt,
+        isTrashed: (r as TrashResource).isTrashed
+    };
+}
diff --git a/src/views/trash-panel/trash-panel.tsx b/src/views/trash-panel/trash-panel.tsx
new file mode 100644 (file)
index 0000000..fa73c0b
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { TrashPanelItem } from './trash-panel-item';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
+import { DispatchProp, connect } from 'react-redux';
+import { DataColumns } from '~/components/data-table/data-table';
+import { RouteComponentProps } from 'react-router';
+import { RootState } from '~/store/store';
+import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
+import { SortDirection } from '~/components/data-table/data-column';
+import { ResourceKind } from '~/models/resource';
+import { resourceLabel } from '~/common/labels';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { renderName, renderType, renderFileSize, renderDate } from '~/views-components/data-explorer/renderers';
+import { TrashIcon } from '~/components/icon/icon';
+import { TRASH_PANEL_ID } from "~/store/trash-panel/trash-panel-action";
+
+type CssRules = "toolbar" | "button";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    toolbar: {
+        paddingBottom: theme.spacing.unit * 3,
+        textAlign: "right"
+    },
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+});
+
+export enum TrashPanelColumnNames {
+    NAME = "Name",
+    TYPE = "Type",
+    FILE_SIZE = "File size",
+    TRASHED_DATE = "Trashed date",
+    TO_BE_DELETED = "To be deleted"
+}
+
+export interface TrashPanelFilter extends DataTableFilterItem {
+    type: ResourceKind;
+}
+
+export const columns: DataColumns<TrashPanelItem, TrashPanelFilter> = [
+    {
+        name: TrashPanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.ASC,
+        filters: [],
+        render: renderName,
+        width: "450px"
+    },
+    {
+        name: TrashPanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: [
+            {
+                name: resourceLabel(ResourceKind.COLLECTION),
+                selected: true,
+                type: ResourceKind.COLLECTION
+            },
+            {
+                name: resourceLabel(ResourceKind.PROCESS),
+                selected: true,
+                type: ResourceKind.PROCESS
+            },
+            {
+                name: resourceLabel(ResourceKind.PROJECT),
+                selected: true,
+                type: ResourceKind.PROJECT
+            }
+        ],
+        render: item => renderType(item.kind),
+        width: "125px"
+    },
+    {
+        name: TrashPanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: [],
+        render: item => renderFileSize(item.fileSize),
+        width: "50px"
+    },
+    {
+        name: TrashPanelColumnNames.TRASHED_DATE,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: [],
+        render: item => renderDate(item.trashAt),
+        width: "50px"
+    },
+    {
+        name: TrashPanelColumnNames.TO_BE_DELETED,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.NONE,
+        filters: [],
+        render: item => renderDate(item.deleteAt),
+        width: "50px"
+    },
+];
+
+interface TrashPanelDataProps {
+    currentItemId: string;
+}
+
+interface TrashPanelActionProps {
+    onItemClick: (item: TrashPanelItem) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TrashPanelItem) => void;
+    onDialogOpen: (ownerUuid: string) => void;
+    onItemDoubleClick: (item: TrashPanelItem) => void;
+    onItemRouteChange: (itemId: string) => void;
+}
+
+type TrashPanelProps = TrashPanelDataProps & TrashPanelActionProps & DispatchProp
+                        & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+
+export const TrashPanel = withStyles(styles)(
+    connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))(
+        class extends React.Component<TrashPanelProps> {
+            render() {
+                return <DataExplorer
+                    id={TRASH_PANEL_ID}
+                    columns={columns}
+                    onRowClick={this.props.onItemClick}
+                    onRowDoubleClick={this.props.onItemDoubleClick}
+                    onContextMenu={this.props.onContextMenu}
+                    extractKey={(item: TrashPanelItem) => item.uuid}
+                    defaultIcon={TrashIcon}
+                    defaultMessages={['Your trash list is empty.']}/>
+                ;
+            }
+
+            componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: TrashPanelProps) {
+                if (match.params.id !== currentItemId) {
+                    onItemRouteChange(match.params.id);
+                }
+            }
+        }
+    )
+);
index ef5fe215290e4e33dfac8988840d0cb4740ed83d..ea3a278bf9090cbe73b29a2a0b64f4b357d6e399 100644 (file)
@@ -39,6 +39,9 @@ import { MoveCollectionDialog } from '~/views-components/dialog-forms/move-colle
 import { FilesUploadCollectionDialog } from '~/views-components/dialog-forms/files-upload-collection-dialog';
 import { PartialCopyCollectionDialog } from '~/views-components/dialog-forms/partial-copy-collection-dialog';
 
+import { TrashPanel } from "~/views/trash-panel/trash-panel";
+import { trashPanelActions } from "~/store/trash-panel/trash-panel-action";
+
 const APP_BAR_HEIGHT = 100;
 
 type CssRules = 'root' | 'appBar' | 'content' | 'contentWrapper';
@@ -167,6 +170,7 @@ export const Workbench = withStyles(styles)(
                                     <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
                                     <Route path={Routes.FAVORITES} component={FavoritePanel} />
                                     <Route path={Routes.PROCESSES} component={ProcessPanel} />
+                                    <Route path="/trash" render={this.renderTrashPanel} />
                                 </Switch>
                             </div>
                             {user && <DetailsPanel />}