From: Pawel Kowalczyk Date: Tue, 28 Aug 2018 11:08:29 +0000 (+0200) Subject: merge master X-Git-Tag: 1.3.0~130^2~6 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/cdc8a73914399a401642ca553e9d3d8b2d42db5c?hp=fb15af5c500ad8469240c59b17ff73b889bb022b merge master Feature #13858 Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk --- diff --git a/src/common/unionize.ts b/src/common/unionize.ts new file mode 100644 index 00000000..b6844311 --- /dev/null +++ b/src/common/unionize.ts @@ -0,0 +1,14 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +export * from 'unionize'; +import { unionize as originalUnionize, SingleValueRec } from 'unionize'; + +export function unionize(record: Record) { + return originalUnionize(record, { + tag: 'type', + value: 'payload' + }); +} + diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index da549dba..444ac75e 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -25,7 +25,7 @@ const styles: StyleRulesCallback = theme => ({ } }); -interface BreadcrumbsProps { +export interface BreadcrumbsProps { items: Breadcrumb[]; onClick: (breadcrumb: Breadcrumb) => void; onContextMenu: (event: React.MouseEvent, breadcrumb: Breadcrumb) => void; diff --git a/src/components/side-panel/side-panel.tsx b/src/components/side-panel/side-panel.tsx deleted file mode 100644 index 84e5c547..00000000 --- a/src/components/side-panel/side-panel.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import * as React from 'react'; -import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; -import { ArvadosTheme } from '~/common/custom-theme'; -import { List, ListItem, ListItemIcon, Collapse } from "@material-ui/core"; -import { SidePanelRightArrowIcon, IconType } from '../icon/icon'; -import * as classnames from "classnames"; -import { ListItemTextIcon } from '../list-item-text-icon/list-item-text-icon'; -import { Dispatch } from "redux"; -import { RouteComponentProps, withRouter } from "react-router"; - -type CssRules = 'active' | 'row' | 'root' | 'list' | 'iconClose' | 'iconOpen' | 'toggableIconContainer' | 'toggableIcon'; - -const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ - root: { - overflowY: 'auto', - minWidth: '240px', - whiteSpace: 'nowrap', - marginTop: '52px', - display: 'flex', - flexGrow: 1, - }, - list: { - padding: '5px 0px 5px 14px', - minWidth: '240px', - }, - row: { - display: 'flex', - alignItems: 'center', - }, - toggableIconContainer: { - color: theme.palette.grey["700"], - height: '14px', - width: '14px' - }, - toggableIcon: { - fontSize: '14px' - }, - active: { - color: theme.palette.primary.main, - }, - iconClose: { - transition: 'all 0.1s ease', - }, - iconOpen: { - transition: 'all 0.1s ease', - transform: 'rotate(90deg)', - } -}); - -export interface SidePanelItem { - id: string; - name: string; - url: string; - icon: IconType; - open?: boolean; - margin?: boolean; - openAble?: boolean; - activeAction?: (dispatch: Dispatch, uuid?: string) => void; -} - -interface SidePanelDataProps { - toggleOpen: (id: string) => void; - toggleActive: (id: string) => void; - sidePanelItems: SidePanelItem[]; - onContextMenu: (event: React.MouseEvent, item: SidePanelItem) => void; -} - -type SidePanelProps = RouteComponentProps<{}> & SidePanelDataProps & WithStyles; - -export const SidePanel = withStyles(styles)(withRouter( - class extends React.Component { - render() { - const { classes, toggleOpen, toggleActive, sidePanelItems, children } = this.props; - const { root, row, list, toggableIconContainer } = classes; - - const path = this.props.location.pathname.split('/'); - const activeUrl = path.length > 1 ? "/" + path[1] : "/"; - return ( -
- - {sidePanelItems.map(it => { - const active = it.url === activeUrl; - return - toggleActive(it.id)} - onContextMenu={this.handleRowContextMenu(it)}> - - {it.openAble ? ( - toggleOpen(it.id)} className={toggableIconContainer}> - - < SidePanelRightArrowIcon/> - - - ) : null} - - - - {it.openAble ? ( - - {children} - - ) : null} - ; - })} - -
- ); - } - - getToggableIconClassNames = (isOpen?: boolean, isActive ?: boolean) => { - const { iconOpen, iconClose, active, toggableIcon } = this.props.classes; - return classnames(toggableIcon, { - [iconOpen]: isOpen, - [iconClose]: !isOpen, - [active]: isActive - }); - } - - handleRowContextMenu = (item: SidePanelItem) => - (event: React.MouseEvent) => - item.openAble ? this.props.onContextMenu(event, item) : null - } -)); diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index 8d657f8d..c892d7d2 100644 --- a/src/components/tree/tree.tsx +++ b/src/components/tree/tree.tsx @@ -107,7 +107,7 @@ export const Tree = withStyles(styles)( onContextMenu={this.handleRowContextMenu(it)}> {it.status === TreeItemStatus.PENDING ? : null} - this.props.toggleItemOpen(it.id, it.status)} + {this.getProperArrowAnimation(it.status, it.items!)} @@ -171,5 +171,10 @@ export const Tree = withStyles(styles)( } : undefined; } + + handleToggleItemOpen = (id: string, status: TreeItemStatus) => (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.toggleItemOpen(id, status); + } } ); diff --git a/src/index.tsx b/src/index.tsx index c6a5da24..d3115a67 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,14 +7,14 @@ import * as ReactDOM from 'react-dom'; import { Provider } from "react-redux"; import { Workbench } from './views/workbench/workbench'; import './index.css'; -import { Route } from "react-router"; +import { Route } from 'react-router'; import createBrowserHistory from "history/createBrowserHistory"; -import { configureStore } from "./store/store"; +import { History } from "history"; +import { configureStore, RootStore } from './store/store'; import { ConnectedRouter } from "react-router-redux"; import { ApiToken } from "./views-components/api-token/api-token"; import { initAuth } from "./store/auth/auth-action"; import { createServices } from "./services/services"; -import { getProjectList } from "./store/project/project-action"; import { MuiThemeProvider } from '@material-ui/core/styles'; import { CustomTheme } from './common/custom-theme'; import { fetchConfig } from './common/config'; @@ -28,6 +28,9 @@ import { collectionFilesItemActionSet } from './views-components/context-menu/ac import { collectionActionSet } from './views-components/context-menu/action-sets/collection-action-set'; import { collectionResourceActionSet } from './views-components/context-menu/action-sets/collection-resource-action-set'; import { processActionSet } from './views-components/context-menu/action-sets/process-action-set'; +import { addRouteChangeHandlers } from './routes/routes'; +import { loadWorkbench } from './store/workbench/workbench-actions'; +import { Routes } from '~/routes/routes'; const getBuildNumber = () => "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev"); const getGitCommit = () => "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substr(0, 7); @@ -48,24 +51,25 @@ addMenuActionSet(ContextMenuKind.COLLECTION_RESOURCE, collectionResourceActionSe addMenuActionSet(ContextMenuKind.PROCESS, processActionSet); fetchConfig() - .then(config => { + .then((config) => { const history = createBrowserHistory(); const services = createServices(config); const store = configureStore(history, services); + store.subscribe(initListener(history, store)); + store.dispatch(initAuth()); - store.dispatch(getProjectList(services.authService.getUuid())); - const TokenComponent = (props: any) => ; - const WorkbenchComponent = (props: any) => ; + const TokenComponent = (props: any) => ; + const WorkbenchComponent = (props: any) => ; const App = () =>
- - + +
@@ -75,6 +79,20 @@ fetchConfig() , document.getElementById('root') as HTMLElement ); + + }); +const initListener = (history: History, store: RootStore) => { + let initialized = false; + return async () => { + const { router, auth } = store.getState(); + if (router.location && auth.user && !initialized) { + initialized = true; + await store.dispatch(loadWorkbench()); + addRouteChangeHandlers(history, store); + } + }; +}; + diff --git a/src/models/resource.ts b/src/models/resource.ts index 6a76b070..ff95c1a9 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -20,5 +20,39 @@ export enum ResourceKind { GROUP = "arvados#group", PROCESS = "arvados#containerRequest", PROJECT = "arvados#group", - WORKFLOW = "arvados#workflow" + WORKFLOW = "arvados#workflow", + USER = "arvados#user", } + +export enum ResourceObjectType { + USER = 'tpzed', + GROUP = 'j7d0g', + COLLECTION = '4zz18' +} + +export const RESOURCE_UUID_PATTERN = '.{5}-.{5}-.{15}'; +export const RESOURCE_UUID_REGEX = new RegExp(RESOURCE_UUID_PATTERN); + +export const isResourceUuid = (uuid: string) => + RESOURCE_UUID_REGEX.test(uuid); + +export const extractUuidObjectType = (uuid: string) => { + const match = RESOURCE_UUID_REGEX.exec(uuid); + return match + ? match[0].split('-')[1] + : undefined; +}; + +export const extractUuidKind = (uuid: string = '') => { + const objectType = extractUuidObjectType(uuid); + switch (objectType) { + case ResourceObjectType.USER: + return ResourceKind.USER; + case ResourceObjectType.GROUP: + return ResourceKind.GROUP; + case ResourceObjectType.COLLECTION: + return ResourceKind.COLLECTION; + default: + return undefined; + } +}; diff --git a/src/models/user.ts b/src/models/user.ts index 4cc29ba7..c2f21e58 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { Resource, ResourceKind } from '~/models/resource'; + export interface User { email: string; firstName: string; @@ -12,4 +14,18 @@ export interface User { export const getUserFullname = (user?: User) => { return user ? `${user.firstName} ${user.lastName}` : ""; -}; \ No newline at end of file +}; + +export interface UserResource extends Resource { + kind: ResourceKind.USER; + email: string; + username: string; + firstName: string; + lastName: string; + identityUrl: string; + isAdmin: boolean; + prefs: string; + defaultOwnerUuid: string; + isActive: boolean; + writableBy: string[]; +} \ No newline at end of file diff --git a/src/routes/routes.ts b/src/routes/routes.ts new file mode 100644 index 00000000..0bf71101 --- /dev/null +++ b/src/routes/routes.ts @@ -0,0 +1,72 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { History, Location } from 'history'; +import { RootStore } from '../store/store'; +import { matchPath } from 'react-router'; +import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind } from '~/models/resource'; +import { getProjectUrl } from '../models/project'; +import { getCollectionUrl } from '~/models/collection'; +import { loadProject, loadFavorites, loadCollection } from '../store/workbench/workbench-actions'; + +export const Routes = { + ROOT: '/', + TOKEN: '/token', + PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`, + COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`, + PROCESS: `/processes/:id(${RESOURCE_UUID_PATTERN})`, + FAVORITES: '/favorites', +}; + +export const getResourceUrl = (uuid: string) => { + const kind = extractUuidKind(uuid); + switch (kind) { + case ResourceKind.PROJECT: + return getProjectUrl(uuid); + case ResourceKind.COLLECTION: + return getCollectionUrl(uuid); + default: + return undefined; + } +}; + +export const addRouteChangeHandlers = (history: History, store: RootStore) => { + const handler = handleLocationChange(store); + handler(history.location); + history.listen(handler); +}; + +export const matchRootRoute = (route: string) => + matchPath(route, { path: Routes.ROOT, exact: true }); + +export const matchFavoritesRoute = (route: string) => + matchPath(route, { path: Routes.FAVORITES }); + +export interface ProjectRouteParams { + id: string; +} + +export const matchProjectRoute = (route: string) => + matchPath(route, { path: Routes.PROJECTS }); + +export interface CollectionRouteParams { + id: string; +} + +export const matchCollectionRoute = (route: string) => + matchPath(route, { path: Routes.COLLECTIONS }); + + +const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { + const projectMatch = matchProjectRoute(pathname); + const collectionMatch = matchCollectionRoute(pathname); + const favoriteMatch = matchFavoritesRoute(pathname); + if (projectMatch) { + store.dispatch(loadProject(projectMatch.params.id)); + } else if (collectionMatch) { + store.dispatch(loadCollection(collectionMatch.params.id)); + } else if (favoriteMatch) { + store.dispatch(loadFavorites()); + } +}; diff --git a/src/services/ancestors-service/ancestors-service.ts b/src/services/ancestors-service/ancestors-service.ts new file mode 100644 index 00000000..1cd42fb5 --- /dev/null +++ b/src/services/ancestors-service/ancestors-service.ts @@ -0,0 +1,44 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { GroupsService } from "~/services/groups-service/groups-service"; +import { UserService } from '../user-service/user-service'; +import { GroupResource } from '~/models/group'; +import { UserResource } from '~/models/user'; +import { extractUuidObjectType, ResourceObjectType } from "~/models/resource"; + +export class AncestorService { + constructor( + private groupsService: GroupsService, + private userService: UserService + ) { } + + async ancestors(uuid: string, rootUuid: string): Promise> { + const service = this.getService(extractUuidObjectType(uuid)); + if (service) { + const resource = await service.get(uuid); + if (uuid === rootUuid) { + return [resource]; + } else { + return [ + ...await this.ancestors(resource.ownerUuid, rootUuid), + resource + ]; + } + } else { + return []; + } + } + + private getService = (objectType?: string) => { + switch (objectType) { + case ResourceObjectType.GROUP: + return this.groupsService; + case ResourceObjectType.USER: + return this.userService; + default: + return undefined; + } + } +} \ No newline at end of file diff --git a/src/services/services.ts b/src/services/services.ts index 0e1f4b43..6295527b 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -14,6 +14,9 @@ import { CollectionFilesService } from "./collection-files-service/collection-fi import { KeepService } from "./keep-service/keep-service"; import { WebDAV } from "../common/webdav"; import { Config } from "../common/config"; +import { UserService } from './user-service/user-service'; +import { AncestorService } from "~/services/ancestors-service/ancestors-service"; +import { ResourceKind } from "~/models/resource"; export type ServiceRepository = ReturnType; @@ -33,6 +36,8 @@ export const createServices = (config: Config) => { const collectionService = new CollectionService(apiClient, webdavClient, authService); const tagService = new TagService(linkService); const collectionFilesService = new CollectionFilesService(collectionService); + const userService = new UserService(apiClient); + const ancestorsService = new AncestorService(groupsService, userService); return { apiClient, @@ -45,6 +50,21 @@ export const createServices = (config: Config) => { favoriteService, collectionService, tagService, - collectionFilesService + collectionFilesService, + userService, + ancestorsService, }; }; + +export const getResourceService = (kind?: ResourceKind) => (serviceRepository: ServiceRepository) => { + switch (kind) { + case ResourceKind.USER: + return serviceRepository.userService; + case ResourceKind.GROUP: + return serviceRepository.groupsService; + case ResourceKind.COLLECTION: + return serviceRepository.collectionService; + default: + return undefined; + } +}; \ No newline at end of file diff --git a/src/services/user-service/user-service.ts b/src/services/user-service/user-service.ts new file mode 100644 index 00000000..3c09a87d --- /dev/null +++ b/src/services/user-service/user-service.ts @@ -0,0 +1,13 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { AxiosInstance } from "axios"; +import { CommonResourceService } from "~/common/api/common-resource-service"; +import { UserResource } from "~/models/user"; + +export class UserService extends CommonResourceService { + constructor(serverApi: AxiosInstance) { + super(serverApi, "users"); + } +} \ No newline at end of file diff --git a/src/store/auth/auth-action.ts b/src/store/auth/auth-action.ts index 00af5ce5..ac2e0b7e 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { ofType, default as unionize, UnionOf } from "unionize"; +import { ofType, unionize, UnionOf } from '~/common/unionize'; import { Dispatch } from "redux"; import { User } from "~/models/user"; import { RootState } from "../store"; @@ -16,9 +16,6 @@ export const authActions = unionize({ INIT: ofType<{ user: User, token: string }>(), USER_DETAILS_REQUEST: {}, USER_DETAILS_SUCCESS: ofType() -}, { - tag: 'type', - value: 'payload' }); function setAuthorizationHeader(services: ServiceRepository, token: string) { diff --git a/src/store/breadcrumbs/breadcrumbs-actions.ts b/src/store/breadcrumbs/breadcrumbs-actions.ts new file mode 100644 index 00000000..254a8d3e --- /dev/null +++ b/src/store/breadcrumbs/breadcrumbs-actions.ts @@ -0,0 +1,46 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from 'redux'; +import { RootState } from '~/store/store'; +import { Breadcrumb } from '~/components/breadcrumbs/breadcrumbs'; +import { getResource } from '~/store/resources/resources'; +import { TreePicker } from '../tree-picker/tree-picker'; +import { getSidePanelTreeBranch } from '../side-panel-tree/side-panel-tree-actions'; +import { propertiesActions } from '../properties/properties-actions'; + +export const BREADCRUMBS = 'breadcrumbs'; + +export interface ResourceBreadcrumb extends Breadcrumb { + uuid: string; +} + +export const setBreadcrumbs = (breadcrumbs: Breadcrumb[]) => + propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs }); + +const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): ResourceBreadcrumb[] => { + const nodes = getSidePanelTreeBranch(uuid)(treePicker); + return nodes.map(node => + typeof node.value === 'string' + ? { label: node.value, uuid: node.nodeId } + : { label: node.value.name, uuid: node.value.uuid }); +}; + +export const setSidePanelBreadcrumbs = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const { treePicker } = getState(); + const breadcrumbs = getSidePanelTreeBreadcrumbs(uuid)(treePicker); + dispatch(setBreadcrumbs(breadcrumbs)); + }; + +export const setProjectBreadcrumbs = setSidePanelBreadcrumbs; + +export const setCollectionBreadcrumbs = (collectionUuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const { resources } = getState(); + const collection = getResource(collectionUuid)(resources); + if (collection) { + dispatch(setProjectBreadcrumbs(collection.ownerUuid)); + } + }; diff --git a/src/store/collection-panel/collection-panel-action.ts b/src/store/collection-panel/collection-panel-action.ts index 06d4d276..5b2690bf 100644 --- a/src/store/collection-panel/collection-panel-action.ts +++ b/src/store/collection-panel/collection-panel-action.ts @@ -2,16 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from "unionize"; import { Dispatch } from "redux"; import { loadCollectionFiles } from "./collection-panel-files/collection-panel-files-actions"; -import { CollectionResource } from "~/models/collection"; +import { CollectionResource } from '~/models/collection'; import { collectionPanelFilesAction } from "./collection-panel-files/collection-panel-files-actions"; import { createTree } from "~/models/tree"; import { RootState } from "../store"; import { ServiceRepository } from "~/services/services"; import { TagResource, TagProperty } from "~/models/tag"; import { snackbarActions } from "../snackbar/snackbar-actions"; +import { resourcesActions } from "~/store/resources/resources-actions"; +import { unionize, ofType, UnionOf } from '~/common/unionize'; export const collectionPanelActions = unionize({ LOAD_COLLECTION: ofType<{ uuid: string }>(), @@ -22,22 +23,21 @@ export const collectionPanelActions = unionize({ CREATE_COLLECTION_TAG_SUCCESS: ofType<{ tag: TagResource }>(), DELETE_COLLECTION_TAG: ofType<{ uuid: string }>(), DELETE_COLLECTION_TAG_SUCCESS: ofType<{ uuid: string }>() -}, { tag: 'type', value: 'payload' }); +}); export type CollectionPanelAction = UnionOf; export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm'; -export const loadCollection = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const loadCollectionPanel = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid })); dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() })); - return services.collectionService - .get(uuid) - .then(item => { - dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item })); - dispatch(loadCollectionFiles(uuid)); - }); + const collection = await services.collectionService.get(uuid); + dispatch(resourcesActions.SET_RESOURCES([collection])); + dispatch(loadCollectionFiles(collection.uuid)); + dispatch(loadCollectionTags(collection.uuid)); + return collection; }; export const loadCollectionTags = (uuid: string) => @@ -50,7 +50,6 @@ export const loadCollectionTags = (uuid: string) => }); }; - export const createCollectionTag = (data: TagProperty) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(collectionPanelActions.CREATE_COLLECTION_TAG({ data })); diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts index 97abfef0..01b4fe4f 100644 --- a/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts +++ b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; import { Dispatch } from "redux"; import { CollectionFilesTree, CollectionFileType } from "~/models/collection-file"; import { ServiceRepository } from "~/services/services"; @@ -22,7 +22,7 @@ export const collectionPanelFilesAction = unionize({ TOGGLE_COLLECTION_FILE_SELECTION: ofType<{ id: string }>(), SELECT_ALL_COLLECTION_FILES: ofType<{}>(), UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(), -}, { tag: 'type', value: 'payload' }); +}); export type CollectionPanelFilesAction = UnionOf; diff --git a/src/store/collections/collection-copy-actions.ts b/src/store/collections/collection-copy-actions.ts index 80f577e7..15ea8553 100644 --- a/src/store/collections/collection-copy-actions.ts +++ b/src/store/collections/collection-copy-actions.ts @@ -9,8 +9,6 @@ import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree import { RootState } from '~/store/store'; import { ServiceRepository } from '~/services/services'; import { getCommonResourceServiceError, CommonResourceServiceError } from '~/common/api/common-resource-service'; -import { snackbarActions } from '~/store/snackbar/snackbar-actions'; -import { projectPanelActions } from '~/store/project-panel/project-panel-action'; export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName'; @@ -36,16 +34,16 @@ export const copyCollection = (resource: CollectionCopyFormDialogData) => const uuidKey = 'uuid'; delete collection[uuidKey]; await services.collectionService.create({ ...collection, ownerUuid: resource.ownerUuid, name: resource.name }); - dispatch(projectPanelActions.REQUEST_ITEMS()); dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME })); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied', hideDuration: 2000 })); + return collection; } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { dispatch(stopSubmit(COLLECTION_COPY_FORM_NAME, { ownerUuid: 'A collection with the same name already exists in the target project.' })); } else { dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME })); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not copy the collection', hideDuration: 2000 })); + throw new Error('Could not copy the collection.'); } + return ; } }; \ No newline at end of file diff --git a/src/store/collections/collection-create-actions.ts b/src/store/collections/collection-create-actions.ts index d8d292c0..1981af0d 100644 --- a/src/store/collections/collection-create-actions.ts +++ b/src/store/collections/collection-create-actions.ts @@ -6,8 +6,6 @@ import { Dispatch } from "redux"; import { reset, startSubmit, stopSubmit, initialize } from 'redux-form'; import { RootState } from '~/store/store'; import { uploadCollectionFiles } from '~/store/collections/uploader/collection-uploader-actions'; -import { projectPanelActions } from "~/store/project-panel/project-panel-action"; -import { snackbarActions } from "~/store/snackbar/snackbar-actions"; import { dialogActions } from "~/store/dialog/dialog-actions"; import { CollectionResource } from '~/models/collection'; import { ServiceRepository } from '~/services/services'; @@ -28,28 +26,20 @@ export const openCollectionCreateDialog = (ownerUuid: string) => dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_CREATE_FORM_NAME, data: { ownerUuid } })); }; -export const addCollection = (data: CollectionCreateFormDialogData) => - async (dispatch: Dispatch) => { - await dispatch(createCollection(data)); - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "Collection has been successfully created.", - hideDuration: 2000 - })); - }; - export const createCollection = (collection: Partial) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(startSubmit(COLLECTION_CREATE_FORM_NAME)); try { const newCollection = await services.collectionService.create(collection); await dispatch(uploadCollectionFiles(newCollection.uuid)); - dispatch(projectPanelActions.REQUEST_ITEMS()); dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME })); dispatch(reset(COLLECTION_CREATE_FORM_NAME)); + return newCollection; } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' })); } + return ; } }; \ No newline at end of file diff --git a/src/store/collections/collection-move-actions.ts b/src/store/collections/collection-move-actions.ts index 6fc836f8..dcd7b1aa 100644 --- a/src/store/collections/collection-move-actions.ts +++ b/src/store/collections/collection-move-actions.ts @@ -31,6 +31,7 @@ export const moveCollection = (resource: MoveToFormDialogData) => dispatch(projectPanelActions.REQUEST_ITEMS()); dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME })); dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been moved', hideDuration: 2000 })); + return collection; } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { @@ -39,5 +40,6 @@ export const moveCollection = (resource: MoveToFormDialogData) => dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME })); dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the collection.', hideDuration: 2000 })); } + return ; } }; diff --git a/src/store/collections/collection-update-actions.ts b/src/store/collections/collection-update-actions.ts index 27c3bfce..75e03d50 100644 --- a/src/store/collections/collection-update-actions.ts +++ b/src/store/collections/collection-update-actions.ts @@ -6,7 +6,7 @@ import { Dispatch } from "redux"; import { initialize, startSubmit, stopSubmit } from 'redux-form'; import { RootState } from "~/store/store"; import { collectionPanelActions } from "~/store/collection-panel/collection-panel-action"; -import { updateDetails } from "~/store/details-panel/details-panel-action"; +import { loadDetailsPanel } from "~/store/details-panel/details-panel-action"; import { dialogActions } from "~/store/dialog/dialog-actions"; import { dataExplorerActions } from "~/store/data-explorer/data-explorer-action"; import { snackbarActions } from "~/store/snackbar/snackbar-actions"; @@ -30,15 +30,6 @@ export const openCollectionUpdateDialog = (resource: ContextMenuResource) => dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME, data: {} })); }; -export const editCollection = (data: CollectionUpdateFormDialogData) => - async (dispatch: Dispatch) => { - await dispatch(updateCollection(data)); - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "Collection has been successfully updated.", - hideDuration: 2000 - })); - }; - export const updateCollection = (collection: Partial) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = collection.uuid || ''; @@ -46,13 +37,13 @@ export const updateCollection = (collection: Partial) => try { const updatedCollection = await services.collectionService.update(uuid, collection); dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource })); - dispatch(updateDetails(updatedCollection)); - dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID })); dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME })); - } catch(e) { + return updatedCollection; + } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' })); } + return; } }; \ No newline at end of file diff --git a/src/store/collections/uploader/collection-uploader-actions.ts b/src/store/collections/uploader/collection-uploader-actions.ts index 0fa55d83..58dcdc4c 100644 --- a/src/store/collections/uploader/collection-uploader-actions.ts +++ b/src/store/collections/uploader/collection-uploader-actions.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; import { Dispatch } from 'redux'; import { RootState } from '~/store/store'; import { ServiceRepository } from '~/services/services'; @@ -26,9 +26,6 @@ export const collectionUploaderActions = unionize({ START_UPLOAD: ofType(), SET_UPLOAD_PROGRESS: ofType<{ fileId: number, loaded: number, total: number, currentTime: number }>(), CLEAR_UPLOAD: ofType() -}, { - tag: 'type', - value: 'payload' }); export type CollectionUploaderAction = UnionOf; diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 8e5eb1e7..40623999 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -2,15 +2,81 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from '~/common/unionize'; import { ContextMenuPosition, ContextMenuResource } from "./context-menu-reducer"; +import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; +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 { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions'; +import { extractUuidKind, ResourceKind } from '~/models/resource'; export const contextMenuActions = unionize({ OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(), CLOSE_CONTEXT_MENU: ofType<{}>() -}, { - tag: 'type', - value: 'payload' - }); +}); export type ContextMenuAction = UnionOf; + +export const openContextMenu = (event: React.MouseEvent, resource: { name: string; uuid: string; description?: string; kind: ContextMenuKind; }) => + (dispatch: Dispatch) => { + event.preventDefault(); + dispatch( + contextMenuActions.OPEN_CONTEXT_MENU({ + position: { x: event.clientX, y: event.clientY }, + resource + }) + ); + }; + +export const openRootProjectContextMenu = (event: React.MouseEvent, projectUuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const userResource = getResource(projectUuid)(getState().resources); + if (userResource) { + dispatch(openContextMenu(event, { + name: '', + uuid: userResource.uuid, + kind: ContextMenuKind.ROOT_PROJECT + })); + } + }; + +export const openProjectContextMenu = (event: React.MouseEvent, projectUuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const projectResource = getResource(projectUuid)(getState().resources); + if (projectResource) { + dispatch(openContextMenu(event, { + name: projectResource.name, + uuid: projectResource.uuid, + kind: ContextMenuKind.PROJECT + })); + } + }; + +export const openSidePanelContextMenu = (event: React.MouseEvent, id: string) => + (dispatch: Dispatch, getState: () => RootState) => { + if (!isSidePanelTreeCategory(id)) { + const kind = extractUuidKind(id); + if (kind === ResourceKind.USER) { + dispatch(openRootProjectContextMenu(event, id)); + } else if (kind === ResourceKind.PROJECT) { + dispatch(openProjectContextMenu(event, id)); + } + } + }; + +export const resourceKindToContextMenuKind = (uuid: string) => { + const kind = extractUuidKind(uuid); + switch (kind) { + case ResourceKind.PROJECT: + return ContextMenuKind.PROJECT; + case ResourceKind.COLLECTION: + return ContextMenuKind.COLLECTION_RESOURCE; + case ResourceKind.USER: + return ContextMenuKind.ROOT_PROJECT; + default: + return; + } +}; diff --git a/src/store/data-explorer/data-explorer-action.ts b/src/store/data-explorer/data-explorer-action.ts index abb293fd..e637043d 100644 --- a/src/store/data-explorer/data-explorer-action.ts +++ b/src/store/data-explorer/data-explorer-action.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters"; import { DataColumns } from "~/components/data-table/data-table"; @@ -17,7 +17,7 @@ export const dataExplorerActions = unionize({ TOGGLE_COLUMN: ofType<{ id: string, columnName: string }>(), TOGGLE_SORT: ofType<{ id: string, columnName: string }>(), SET_SEARCH_VALUE: ofType<{ id: string, searchValue: string }>(), -}, { tag: "type", value: "payload" }); +}); export type DataExplorerAction = UnionOf; diff --git a/src/store/data-explorer/data-explorer-middleware-service.ts b/src/store/data-explorer/data-explorer-middleware-service.ts index 7c64020e..059c0784 100644 --- a/src/store/data-explorer/data-explorer-middleware-service.ts +++ b/src/store/data-explorer/data-explorer-middleware-service.ts @@ -6,6 +6,8 @@ import { Dispatch, MiddlewareAPI } from "redux"; import { RootState } from "../store"; import { DataColumns } from "~/components/data-table/data-table"; import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters"; +import { DataExplorer } from './data-explorer-reducer'; +import { ListArguments, ListResults } from '~/common/api/common-resource-service'; export abstract class DataExplorerMiddlewareService { protected readonly id: string; @@ -25,3 +27,19 @@ export abstract class DataExplorerMiddlewareService { abstract requestItems(api: MiddlewareAPI): void; } + +export const getDataExplorerColumnFilters = (columns: DataColumns, columnName: string): F[] => { + const column = columns.find(c => c.name === columnName); + return column ? column.filters.filter(f => f.selected) : []; +}; + +export const dataExplorerToListParams = (dataExplorer: DataExplorer) => ({ + limit: dataExplorer.rowsPerPage, + offset: dataExplorer.page * dataExplorer.rowsPerPage, +}); + +export const listResultsToDataExplorerItemsMeta = ({ itemsAvailable, offset, limit }: ListResults) => ({ + itemsAvailable, + page: Math.floor(offset / limit), + rowsPerPage: limit +}); \ No newline at end of file diff --git a/src/store/details-panel/details-panel-action.ts b/src/store/details-panel/details-panel-action.ts index b8021fb6..2724a3e3 100644 --- a/src/store/details-panel/details-panel-action.ts +++ b/src/store/details-panel/details-panel-action.ts @@ -2,48 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from "unionize"; -import { Dispatch } from "redux"; -import { Resource, ResourceKind } from "~/models/resource"; -import { RootState } from "../store"; -import { ServiceRepository } from "~/services/services"; +import { unionize, ofType, UnionOf } from '~/common/unionize'; export const detailsPanelActions = unionize({ TOGGLE_DETAILS_PANEL: ofType<{}>(), - LOAD_DETAILS: ofType<{ uuid: string, kind: ResourceKind }>(), - LOAD_DETAILS_SUCCESS: ofType<{ item: Resource }>(), - UPDATE_DETAILS: ofType<{ item: Resource }>() -}, { tag: 'type', value: 'payload' }); + LOAD_DETAILS_PANEL: ofType() +}); export type DetailsPanelAction = UnionOf; -export const loadDetails = (uuid: string, kind: ResourceKind) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(detailsPanelActions.LOAD_DETAILS({ uuid, kind })); - const item = await getService(services, kind).get(uuid); - dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item })); - }; - -export const updateDetails = (item: Resource) => - async (dispatch: Dispatch, getState: () => RootState) => { - const currentItem = getState().detailsPanel.item; - if (currentItem && (currentItem.uuid === item.uuid)) { - dispatch(detailsPanelActions.UPDATE_DETAILS({ item })); - dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item })); - } - }; - - -const getService = (services: ServiceRepository, kind: ResourceKind) => { - switch (kind) { - case ResourceKind.PROJECT: - return services.projectService; - case ResourceKind.COLLECTION: - return services.collectionService; - default: - return services.projectService; - } -}; +export const loadDetailsPanel = (uuid: string) => detailsPanelActions.LOAD_DETAILS_PANEL(uuid); + diff --git a/src/store/details-panel/details-panel-reducer.ts b/src/store/details-panel/details-panel-reducer.ts index f22add3d..091b2fa2 100644 --- a/src/store/details-panel/details-panel-reducer.ts +++ b/src/store/details-panel/details-panel-reducer.ts @@ -3,21 +3,20 @@ // SPDX-License-Identifier: AGPL-3.0 import { detailsPanelActions, DetailsPanelAction } from "./details-panel-action"; -import { Resource } from "~/models/resource"; export interface DetailsPanelState { - item: Resource | null; + resourceUuid: string; isOpened: boolean; } const initialState = { - item: null, + resourceUuid: '', isOpened: false }; export const detailsPanelReducer = (state: DetailsPanelState = initialState, action: DetailsPanelAction) => detailsPanelActions.match(action, { default: () => state, - LOAD_DETAILS_SUCCESS: ({ item }) => ({ ...state, item }), + LOAD_DETAILS_PANEL: resourceUuid => ({ ...state, resourceUuid }), TOGGLE_DETAILS_PANEL: () => ({ ...state, isOpened: !state.isOpened }) }); diff --git a/src/store/dialog/dialog-actions.ts b/src/store/dialog/dialog-actions.ts index df4418f4..22629b69 100644 --- a/src/store/dialog/dialog-actions.ts +++ b/src/store/dialog/dialog-actions.ts @@ -2,14 +2,11 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; export const dialogActions = unionize({ OPEN_DIALOG: ofType<{ id: string, data: any }>(), CLOSE_DIALOG: ofType<{ id: string }>() -}, { - tag: 'type', - value: 'payload' - }); +}); export type DialogAction = UnionOf; diff --git a/src/store/favorite-panel/favorite-panel-action.ts b/src/store/favorite-panel/favorite-panel-action.ts index aa1ec8d0..067d5cee 100644 --- a/src/store/favorite-panel/favorite-panel-action.ts +++ b/src/store/favorite-panel/favorite-panel-action.ts @@ -6,3 +6,5 @@ import { bindDataExplorerActions } from "../data-explorer/data-explorer-action"; export const FAVORITE_PANEL_ID = "favoritePanel"; export const favoritePanelActions = bindDataExplorerActions(FAVORITE_PANEL_ID); + +export const loadFavoritePanel = () => favoritePanelActions.REQUEST_ITEMS(); diff --git a/src/store/favorite-panel/favorite-panel-middleware-service.ts b/src/store/favorite-panel/favorite-panel-middleware-service.ts index 1c2f0622..e5857dd3 100644 --- a/src/store/favorite-panel/favorite-panel-middleware-service.ts +++ b/src/store/favorite-panel/favorite-panel-middleware-service.ts @@ -6,16 +6,18 @@ import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-mi import { FavoritePanelColumnNames, FavoritePanelFilter } from "~/views/favorite-panel/favorite-panel"; import { RootState } from "../store"; import { DataColumns } from "~/components/data-table/data-table"; -import { FavoritePanelItem, resourceToDataItem } from "~/views/favorite-panel/favorite-panel-item"; 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 { updateFavorites } from "../favorites/favorites-actions"; import { favoritePanelActions } from "./favorite-panel-action"; import { Dispatch, MiddlewareAPI } from "redux"; import { OrderBuilder, OrderDirection } from "~/common/api/order-builder"; import { LinkResource } from "~/models/link"; import { GroupContentsResource, GroupContentsResourcePrefix } from "~/services/groups-service/groups-service"; +import { resourcesActions } from "~/store/resources/resources-actions"; +import { snackbarActions } from '~/store/snackbar/snackbar-actions'; +import { getDataExplorer } from "~/store/data-explorer/data-explorer-reducer"; export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -23,53 +25,64 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic } requestItems(api: MiddlewareAPI) { - const dataExplorer = api.getState().dataExplorer[this.getId()]; - const columns = dataExplorer.columns as DataColumns; - const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE); - const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE); + const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId()); + if (!dataExplorer) { + api.dispatch(favoritesPanelDataExplorerIsNotSet()); + } else { - const linkOrder = new OrderBuilder(); - const contentOrder = new OrderBuilder(); + const columns = dataExplorer.columns as DataColumns; + const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE); + const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE); - if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) { - const direction = sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; + const linkOrder = new OrderBuilder(); + const contentOrder = new OrderBuilder(); - linkOrder.addOrder(direction, "name"); - contentOrder - .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION) - .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS) - .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT); - } + if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) { + const direction = sortColumn.sortDirection === SortDirection.ASC + ? OrderDirection.ASC + : OrderDirection.DESC; + + linkOrder.addOrder(direction, "name"); + contentOrder + .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION) + .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS) + .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT); + } - this.services.favoriteService - .list(this.services.authService.getUuid()!, { - limit: dataExplorer.rowsPerPage, - offset: dataExplorer.page * dataExplorer.rowsPerPage, - linkOrder: linkOrder.getOrder(), - contentOrder: contentOrder.getOrder(), - filters: new FilterBuilder() - .addIsA("headUuid", typeFilters.map(filter => filter.type)) - .addILike("name", dataExplorer.searchValue) - .getFilters() - }) - .then(response => { - api.dispatch(favoritePanelActions.SET_ITEMS({ - items: response.items.map(resourceToDataItem), - itemsAvailable: response.itemsAvailable, - page: Math.floor(response.offset / response.limit), - rowsPerPage: response.limit - })); - api.dispatch(checkPresenceInFavorites(response.items.map(item => item.uuid))); - }) - .catch(() => { - api.dispatch(favoritePanelActions.SET_ITEMS({ - items: [], - itemsAvailable: 0, - page: 0, - rowsPerPage: dataExplorer.rowsPerPage - })); - }); + this.services.favoriteService + .list(this.services.authService.getUuid()!, { + limit: dataExplorer.rowsPerPage, + offset: dataExplorer.page * dataExplorer.rowsPerPage, + linkOrder: linkOrder.getOrder(), + contentOrder: contentOrder.getOrder(), + filters: new FilterBuilder() + .addIsA("headUuid", typeFilters.map(filter => filter.type)) + .addILike("name", dataExplorer.searchValue) + .getFilters() + }) + .then(response => { + api.dispatch(resourcesActions.SET_RESOURCES(response.items)); + api.dispatch(favoritePanelActions.SET_ITEMS({ + items: response.items.map(resource => resource.uuid), + itemsAvailable: response.itemsAvailable, + page: Math.floor(response.offset / response.limit), + rowsPerPage: response.limit + })); + api.dispatch(updateFavorites(response.items.map(item => item.uuid))); + }) + .catch(() => { + api.dispatch(favoritePanelActions.SET_ITEMS({ + items: [], + itemsAvailable: 0, + page: 0, + rowsPerPage: dataExplorer.rowsPerPage + })); + }); + } } } + +const favoritesPanelDataExplorerIsNotSet = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Favorites panel is not ready.' + }); diff --git a/src/store/favorites/favorites-actions.ts b/src/store/favorites/favorites-actions.ts index 9e1b3ef1..e5a8e591 100644 --- a/src/store/favorites/favorites-actions.ts +++ b/src/store/favorites/favorites-actions.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; import { Dispatch } from "redux"; import { RootState } from "../store"; import { checkFavorite } from "./favorites-reducer"; @@ -13,7 +13,7 @@ export const favoritesActions = unionize({ TOGGLE_FAVORITE: ofType<{ resourceUuid: string }>(), CHECK_PRESENCE_IN_FAVORITES: ofType(), UPDATE_FAVORITES: ofType>() -}, { tag: 'type', value: 'payload' }); +}); export type FavoritesAction = UnionOf; @@ -40,7 +40,7 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) => }); }; -export const checkPresenceInFavorites = (resourceUuids: string[]) => +export const updateFavorites = (resourceUuids: string[]) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const userUuid = getState().auth.user!.uuid; dispatch(favoritesActions.CHECK_PRESENCE_IN_FAVORITES(resourceUuids)); @@ -50,4 +50,3 @@ export const checkPresenceInFavorites = (resourceUuids: string[]) => dispatch(favoritesActions.UPDATE_FAVORITES(results)); }); }; - diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index 50ec93d2..1d36beec 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -2,99 +2,30 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from "redux"; -import { getProjectList, projectActions } from "../project/project-action"; +import { Dispatch, compose } from 'redux'; import { push } from "react-router-redux"; -import { TreeItemStatus } from "~/components/tree/tree"; -import { findTreeItem } from "../project/project-reducer"; -import { RootState } from "../store"; -import { ResourceKind } from "~/models/resource"; -import { projectPanelActions } from "../project-panel/project-panel-action"; +import { ResourceKind, extractUuidKind } from '~/models/resource'; import { getCollectionUrl } from "~/models/collection"; -import { getProjectUrl, ProjectResource } from "~/models/project"; -import { ProjectService } from "~/services/project-service/project-service"; -import { ServiceRepository } from "~/services/services"; -import { sidePanelActions } from "../side-panel/side-panel-action"; -import { SidePanelId } from "../side-panel/side-panel-reducer"; -import { getUuidObjectType, ObjectTypes } from "~/models/object-types"; - -export const getResourceUrl = (resourceKind: ResourceKind, resourceUuid: string): string => { - switch (resourceKind) { - case ResourceKind.PROJECT: return getProjectUrl(resourceUuid); - case ResourceKind.COLLECTION: return getCollectionUrl(resourceUuid); - default: - return ''; - } -}; - -export enum ItemMode { - BOTH, - OPEN, - ACTIVE -} - -export const setProjectItem = (itemId: string, itemMode: ItemMode) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { projects, router } = getState(); - const treeItem = findTreeItem(projects.items, itemId); - - if (treeItem) { - const resourceUrl = getResourceUrl(treeItem.data.kind, treeItem.data.uuid); - - if (itemMode === ItemMode.ACTIVE || itemMode === ItemMode.BOTH) { - if (router.location && !router.location.pathname.includes(resourceUrl)) { - dispatch(push(resourceUrl)); - } - dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(treeItem.data.uuid)); - } - - const promise = treeItem.status === TreeItemStatus.LOADED - ? Promise.resolve() - : dispatch(getProjectList(itemId)); - - promise - .then(() => dispatch(() => { - if (itemMode === ItemMode.OPEN || itemMode === ItemMode.BOTH) { - dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(treeItem.data.uuid)); - } - dispatch(projectPanelActions.RESET_PAGINATION()); - dispatch(projectPanelActions.REQUEST_ITEMS()); - })); - } else { - const uuid = services.authService.getUuid(); - if (itemId === uuid) { - dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(uuid)); - dispatch(projectPanelActions.RESET_PAGINATION()); - dispatch(projectPanelActions.REQUEST_ITEMS()); - } +import { getProjectUrl } from "~/models/project"; + +import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions'; +import { Routes } from '~/routes/routes'; + +export const navigateTo = (uuid: string) => + async (dispatch: Dispatch) => { + const kind = extractUuidKind(uuid); + if (kind === ResourceKind.PROJECT || kind === ResourceKind.USER) { + dispatch(navigateToProject(uuid)); + } else if (kind === ResourceKind.COLLECTION) { + dispatch(navigateToCollection(uuid)); + } + if (uuid === SidePanelTreeCategory.FAVORITES) { + dispatch(navigateToFavorites); } }; -export const restoreBranch = (itemId: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const ancestors = await loadProjectAncestors(itemId, services.projectService); - const uuids = ancestors.map(ancestor => ancestor.uuid); - await loadBranch(uuids, dispatch); - dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(SidePanelId.PROJECTS)); - uuids.forEach(uuid => { - dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(uuid)); - }); - }; +export const navigateToFavorites = push(Routes.FAVORITES); -export const loadProjectAncestors = async (uuid: string, projectService: ProjectService): Promise> => { - if (getUuidObjectType(uuid) === ObjectTypes.USER) { - return []; - } else { - const currentProject = await projectService.get(uuid); - const ancestors = await loadProjectAncestors(currentProject.ownerUuid, projectService); - return [...ancestors, currentProject]; - } -}; +export const navigateToProject = compose(push, getProjectUrl); -const loadBranch = async (uuids: string[], dispatch: Dispatch): Promise => { - const [uuid, ...rest] = uuids; - if (uuid) { - await dispatch(getProjectList(uuid)); - return loadBranch(rest, dispatch); - } -}; +export const navigateToCollection = compose(push, getCollectionUrl); diff --git a/src/store/project-panel/project-panel-action.ts b/src/store/project-panel/project-panel-action.ts index 33cedd71..6e639017 100644 --- a/src/store/project-panel/project-panel-action.ts +++ b/src/store/project-panel/project-panel-action.ts @@ -3,6 +3,20 @@ // SPDX-License-Identifier: AGPL-3.0 import { bindDataExplorerActions } from "../data-explorer/data-explorer-action"; - +import { propertiesActions } from "~/store/properties/properties-actions"; +import { Dispatch } from 'redux'; +import { ServiceRepository } from "~/services/services"; +import { RootState } from '~/store/store'; +import { getProperty } from "~/store/properties/properties"; export const PROJECT_PANEL_ID = "projectPanel"; +export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid"; export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID); + +export const openProjectPanel = (projectUuid: string) => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid })); + dispatch(projectPanelActions.REQUEST_ITEMS()); + }; + +export const getProjectPanelCurrentUuid = (state: RootState) => getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties); + diff --git a/src/store/project-panel/project-panel-middleware-service.ts b/src/store/project-panel/project-panel-middleware-service.ts index 663add3e..da7f5b33 100644 --- a/src/store/project-panel/project-panel-middleware-service.ts +++ b/src/store/project-panel/project-panel-middleware-service.ts @@ -2,77 +2,106 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-middleware-service"; +import { DataExplorerMiddlewareService, getDataExplorerColumnFilters, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from '../data-explorer/data-explorer-middleware-service'; import { ProjectPanelColumnNames, ProjectPanelFilter } from "~/views/project-panel/project-panel"; import { RootState } from "../store"; import { DataColumns } from "~/components/data-table/data-table"; import { ServiceRepository } from "~/services/services"; -import { ProjectPanelItem, resourceToDataItem } from "~/views/project-panel/project-panel-item"; import { SortDirection } from "~/components/data-table/data-column"; import { OrderBuilder, OrderDirection } from "~/common/api/order-builder"; import { FilterBuilder } from "~/common/api/filter-builder"; -import { GroupContentsResourcePrefix } from "~/services/groups-service/groups-service"; -import { checkPresenceInFavorites } from "../favorites/favorites-actions"; -import { projectPanelActions } from "./project-panel-action"; +import { GroupContentsResourcePrefix, GroupContentsResource } from "~/services/groups-service/groups-service"; +import { updateFavorites } from "../favorites/favorites-actions"; +import { projectPanelActions, PROJECT_PANEL_CURRENT_UUID } from './project-panel-action'; import { Dispatch, MiddlewareAPI } from "redux"; import { ProjectResource } from "~/models/project"; +import { updateResources } from "~/store/resources/resources-actions"; +import { getProperty } from "~/store/properties/properties"; +import { snackbarActions } from '../snackbar/snackbar-actions'; +import { DataExplorer, getDataExplorer } from '../data-explorer/data-explorer-reducer'; +import { ListResults } from '~/common/api/common-resource-service'; export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { super(id); } - requestItems(api: MiddlewareAPI) { + async requestItems(api: MiddlewareAPI) { const state = api.getState(); - const dataExplorer = state.dataExplorer[this.getId()]; - const columns = dataExplorer.columns as DataColumns; - const typeFilters = this.getColumnFilters(columns, ProjectPanelColumnNames.TYPE); - const statusFilters = this.getColumnFilters(columns, ProjectPanelColumnNames.STATUS); - const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE); + const dataExplorer = getDataExplorer(state.dataExplorer, this.getId()); + const projectUuid = getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties); + if (!projectUuid) { + api.dispatch(projectPanelCurrentUuidIsNotSet()); + } else if (!dataExplorer) { + api.dispatch(projectPanelDataExplorerIsNotSet()); + } else { + try { + const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer)); + api.dispatch(updateFavorites(response.items.map(item => item.uuid))); + api.dispatch(updateResources(response.items)); + api.dispatch(setItems(response)); + } catch (e) { + api.dispatch(couldNotFetchProjectContents()); + } + } + } +} - const order = new OrderBuilder(); +const setItems = (listResults: ListResults) => + projectPanelActions.SET_ITEMS({ + ...listResultsToDataExplorerItemsMeta(listResults), + items: listResults.items.map(resource => resource.uuid), + }); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; +const getParams = (dataExplorer: DataExplorer) => ({ + ...dataExplorerToListParams(dataExplorer), + order: getOrder(dataExplorer), + filters: getFilters(dataExplorer), +}); - const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt"; - order - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT); - } +const getFilters = (dataExplorer: DataExplorer) => { + const columns = dataExplorer.columns as DataColumns; + const typeFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE); + const statusFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.STATUS); + return new FilterBuilder() + .addIsA("uuid", typeFilters.map(f => f.type)) + .addIn("state", statusFilters.map(f => f.type), GroupContentsResourcePrefix.PROCESS) + .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION) + .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS) + .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT) + .getFilters(); +}; + +const getOrder = (dataExplorer: DataExplorer) => { + const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE); + const order = new OrderBuilder(); + if (sortColumn) { + const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC + ? OrderDirection.ASC + : OrderDirection.DESC; - this.services.groupsService - .contents(state.projects.currentItemId, { - limit: dataExplorer.rowsPerPage, - offset: dataExplorer.page * dataExplorer.rowsPerPage, - order: order.getOrder(), - filters: new FilterBuilder() - .addIsA("uuid", typeFilters.map(f => f.type)) - .addIn("state", statusFilters.map(f => f.type), GroupContentsResourcePrefix.PROCESS) - .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION) - .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS) - .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT) - .getFilters() - }) - .then(response => { - api.dispatch(projectPanelActions.SET_ITEMS({ - items: response.items.map(resourceToDataItem), - itemsAvailable: response.itemsAvailable, - page: Math.floor(response.offset / response.limit), - rowsPerPage: response.limit - })); - api.dispatch(checkPresenceInFavorites(response.items.map(item => item.uuid))); - }) - .catch(() => { - api.dispatch(projectPanelActions.SET_ITEMS({ - items: [], - itemsAvailable: 0, - page: 0, - rowsPerPage: dataExplorer.rowsPerPage - })); - }); + const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt"; + return order + .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION) + .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS) + .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT) + .getOrder(); + } else { + return order.getOrder(); } -} +}; + +const projectPanelCurrentUuidIsNotSet = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Project panel is not opened.' + }); + +const couldNotFetchProjectContents = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Could not fetch project contents.' + }); + +const projectPanelDataExplorerIsNotSet = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Project panel is not ready.' + }); diff --git a/src/store/project/project-action.ts b/src/store/project/project-action.ts deleted file mode 100644 index ff66a9cf..00000000 --- a/src/store/project/project-action.ts +++ /dev/null @@ -1,39 +0,0 @@ -// 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"; - -export const projectActions = unionize({ - REMOVE_PROJECT: ofType(), - PROJECTS_REQUEST: ofType(), - PROJECTS_SUCCESS: ofType<{ projects: ProjectResource[], parentItemId?: string }>(), - TOGGLE_PROJECT_TREE_ITEM_OPEN: ofType(), - TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType(), - RESET_PROJECT_TREE_ACTIVITY: ofType() -}, { - tag: 'type', - value: 'payload' -}); - -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(checkPresenceInFavorites(projects.map(project => project.uuid))); - return projects; - }); - }; - -export type ProjectAction = UnionOf; diff --git a/src/store/project/project-reducer.test.ts b/src/store/project/project-reducer.test.ts deleted file mode 100644 index 56a62534..00000000 --- a/src/store/project/project-reducer.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { projectsReducer, getTreePath } from "./project-reducer"; -import { projectActions } from "./project-action"; -import { TreeItem, TreeItemStatus } from "~/components/tree/tree"; -import { mockProjectResource } from "~/models/test-utils"; - -describe('project-reducer', () => { - - it('should load projects', () => { - const initialState = undefined; - - const projects = [mockProjectResource({ uuid: "1" }), mockProjectResource({ uuid: "2" })]; - const state = projectsReducer(initialState, projectActions.PROJECTS_SUCCESS({ projects, parentItemId: undefined })); - expect(state).toEqual({ - items: [{ - active: false, - open: false, - id: "1", - items: [], - data: mockProjectResource({ uuid: "1" }), - status: TreeItemStatus.INITIAL - }, { - active: false, - open: false, - id: "2", - items: [], - data: mockProjectResource({ uuid: "2" }), - status: TreeItemStatus.INITIAL - } - ], - currentItemId: "" - }); - }); - - it('should remove activity on projects list', () => { - const initialState = { - items: [{ - data: mockProjectResource(), - id: "1", - open: true, - active: true, - status: TreeItemStatus.PENDING - }], - currentItemId: "1" - }; - const project = { - items: [{ - data: mockProjectResource(), - id: "1", - open: true, - active: false, - status: TreeItemStatus.PENDING - }], - currentItemId: "" - }; - - const state = projectsReducer(initialState, projectActions.RESET_PROJECT_TREE_ACTIVITY(initialState.items[0].id)); - expect(state).toEqual(project); - }); - - it('should toggle project tree item activity', () => { - const initialState = { - items: [{ - data: mockProjectResource(), - id: "1", - open: true, - active: false, - status: TreeItemStatus.PENDING - }], - currentItemId: "1" - }; - const project = { - items: [{ - data: mockProjectResource(), - id: "1", - open: true, - active: true, - status: TreeItemStatus.PENDING, - }], - currentItemId: "1" - }; - - const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(initialState.items[0].id)); - expect(state).toEqual(project); - }); - - - it('should close project tree item ', () => { - const initialState = { - items: [{ - data: mockProjectResource(), - id: "1", - open: true, - active: false, - status: TreeItemStatus.PENDING, - }], - currentItemId: "1" - }; - const project = { - items: [{ - data: mockProjectResource(), - id: "1", - open: false, - active: false, - status: TreeItemStatus.PENDING, - }], - currentItemId: "1" - }; - - const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(initialState.items[0].id)); - expect(state).toEqual(project); - }); -}); - -describe("findTreeBranch", () => { - const createTreeItem = (id: string, items?: Array>): TreeItem => ({ - id, - items, - active: false, - data: "", - open: false, - status: TreeItemStatus.INITIAL - }); - - it("should return an array that matches path to the given item", () => { - const tree: Array> = [ - createTreeItem("1", [ - createTreeItem("1.1", [ - createTreeItem("1.1.1"), - createTreeItem("1.1.2") - ]) - ]), - createTreeItem("2", [ - createTreeItem("2.1", [ - createTreeItem("2.1.1"), - createTreeItem("2.1.2") - ]) - ]) - ]; - const branch = getTreePath(tree, "2.1.1"); - expect(branch.map(item => item.id)).toEqual(["2", "2.1", "2.1.1"]); - }); - - it("should return empty array if item is not found", () => { - const tree: Array> = [ - createTreeItem("1", [ - createTreeItem("1.1", [ - createTreeItem("1.1.1"), - createTreeItem("1.1.2") - ]) - ]), - createTreeItem("2", [ - createTreeItem("2.1", [ - createTreeItem("2.1.1"), - createTreeItem("2.1.2") - ]) - ]) - ]; - expect(getTreePath(tree, "3")).toHaveLength(0); - }); - -}); diff --git a/src/store/project/project-reducer.ts b/src/store/project/project-reducer.ts deleted file mode 100644 index 452f6be3..00000000 --- a/src/store/project/project-reducer.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import * as _ from "lodash"; - -import { projectActions, ProjectAction } from "./project-action"; -import { TreeItem, TreeItemStatus } from "~/components/tree/tree"; -import { ProjectResource } from "~/models/project"; - -export type ProjectState = { - items: Array>, - currentItemId: string -}; - -interface ProjectUpdater { - opened: boolean; - uuid: string; -} - -export function findTreeItem(tree: Array>, itemId: string): TreeItem | undefined { - let item; - for (const t of tree) { - item = t.id === itemId - ? t - : findTreeItem(t.items ? t.items : [], itemId); - if (item) { - break; - } - } - return item; -} - -export function getActiveTreeItem(tree: Array>): TreeItem | undefined { - let item; - for (const t of tree) { - item = t.active - ? t - : getActiveTreeItem(t.items ? t.items : []); - if (item) { - break; - } - } - return item; -} - -export function getTreePath(tree: Array>, itemId: string): Array> { - for (const item of tree) { - if (item.id === itemId) { - return [item]; - } else { - const branch = getTreePath(item.items || [], itemId); - if (branch.length > 0) { - return [item, ...branch]; - } - } - } - return []; -} - -function resetTreeActivity(tree: Array>) { - for (const t of tree) { - t.active = false; - resetTreeActivity(t.items ? t.items : []); - } -} - -function updateProjectTree(tree: Array>, projects: ProjectResource[], parentItemId?: string): Array> { - let treeItem; - if (parentItemId) { - treeItem = findTreeItem(tree, parentItemId); - if (treeItem) { - treeItem.status = TreeItemStatus.LOADED; - } - } - const items = projects.map(p => ({ - id: p.uuid, - open: false, - active: false, - status: TreeItemStatus.INITIAL, - data: p, - items: [] - } as TreeItem)); - - if (treeItem) { - treeItem.items = items; - return tree; - } - - return items; -} - -const initialState: ProjectState = { - items: [], - currentItemId: "" -}; - - -export const projectsReducer = (state: ProjectState = initialState, action: ProjectAction) => { - return projectActions.match(action, { - REMOVE_PROJECT: () => state, - PROJECTS_REQUEST: itemId => { - const items = _.cloneDeep(state.items); - const item = findTreeItem(items, itemId); - if (item) { - item.status = TreeItemStatus.PENDING; - state.items = items; - } - return { ...state, items }; - }, - PROJECTS_SUCCESS: ({ projects, parentItemId }) => { - const items = _.cloneDeep(state.items); - return { - ...state, - items: updateProjectTree(items, projects, parentItemId) - }; - }, - TOGGLE_PROJECT_TREE_ITEM_OPEN: itemId => { - const items = _.cloneDeep(state.items); - const item = findTreeItem(items, itemId); - if (item) { - item.open = !item.open; - } - return { - ...state, - items, - currentItemId: itemId - }; - }, - TOGGLE_PROJECT_TREE_ITEM_ACTIVE: itemId => { - const items = _.cloneDeep(state.items); - resetTreeActivity(items); - const item = findTreeItem(items, itemId); - if (item) { - item.active = true; - } - return { - ...state, - items, - currentItemId: itemId - }; - }, - RESET_PROJECT_TREE_ACTIVITY: () => { - const items = _.cloneDeep(state.items); - resetTreeActivity(items); - return { - ...state, - items, - currentItemId: "" - }; - }, - default: () => state - }); -}; diff --git a/src/store/projects/project-create-actions.ts b/src/store/projects/project-create-actions.ts index d1bc827f..76f20590 100644 --- a/src/store/projects/project-create-actions.ts +++ b/src/store/projects/project-create-actions.ts @@ -5,15 +5,11 @@ import { Dispatch } from "redux"; import { reset, startSubmit, stopSubmit, initialize } from 'redux-form'; import { RootState } from '~/store/store'; -import { snackbarActions } from '~/store/snackbar/snackbar-actions'; import { dialogActions } from "~/store/dialog/dialog-actions"; -import { projectPanelActions } from '~/store/project-panel/project-panel-action'; -import { getProjectList } from '~/store/project/project-action'; import { getCommonResourceServiceError, CommonResourceServiceError } from '~/common/api/common-resource-service'; import { ProjectResource } from '~/models/project'; import { ServiceRepository } from '~/services/services'; - export interface ProjectCreateFormDialogData { ownerUuid: string; name: string; @@ -28,29 +24,19 @@ export const openProjectCreateDialog = (ownerUuid: string) => dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_CREATE_FORM_NAME, data: {} })); }; -export const addProject = (data: ProjectCreateFormDialogData) => - async (dispatch: Dispatch) => { - await dispatch(createProject(data)); - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "Project has been successfully created.", - hideDuration: 2000 - })); - }; - - -const createProject = (project: Partial) => +export const createProject = (project: Partial) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(startSubmit(PROJECT_CREATE_FORM_NAME)); try { const newProject = await services.projectService.create(project); - dispatch(getProjectList(newProject.ownerUuid)); - dispatch(projectPanelActions.REQUEST_ITEMS()); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME })); dispatch(reset(PROJECT_CREATE_FORM_NAME)); + return newProject; } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Project with the same name already exists.' })); } + return undefined; } - }; \ No newline at end of file + }; diff --git a/src/store/projects/project-move-actions.ts b/src/store/projects/project-move-actions.ts index 52d17ac8..6e24314c 100644 --- a/src/store/projects/project-move-actions.ts +++ b/src/store/projects/project-move-actions.ts @@ -8,12 +8,8 @@ import { startSubmit, stopSubmit, initialize } from 'redux-form'; import { ServiceRepository } from '~/services/services'; import { RootState } from '~/store/store'; import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service"; -import { snackbarActions } from '~/store/snackbar/snackbar-actions'; -import { projectPanelActions } from '~/store/project-panel/project-panel-action'; -import { getProjectList } from '~/store/project/project-action'; import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog'; import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions'; -import { findTreeItem } from '~/store/project/project-reducer'; export const PROJECT_MOVE_FORM_NAME = 'projectMoveFormName'; @@ -29,15 +25,9 @@ export const moveProject = (resource: MoveToFormDialogData) => dispatch(startSubmit(PROJECT_MOVE_FORM_NAME)); try { const project = await services.projectService.get(resource.uuid); - await services.projectService.update(resource.uuid, { ...project, ownerUuid: resource.ownerUuid }); - dispatch(projectPanelActions.REQUEST_ITEMS()); - dispatch(getProjectList(project.ownerUuid)); - const { projects } = getState(); - if (findTreeItem(projects.items, resource.ownerUuid)) { - dispatch(getProjectList(resource.ownerUuid)); - } + const newProject = await services.projectService.update(resource.uuid, { ...project, ownerUuid: resource.ownerUuid }); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME })); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Project has been moved', hideDuration: 2000 })); + return newProject; } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { @@ -46,7 +36,8 @@ export const moveProject = (resource: MoveToFormDialogData) => dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'Cannot move a project into itself.' })); } else { dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME })); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the project.', hideDuration: 2000 })); + throw new Error('Could not move the project.'); } + return; } }; diff --git a/src/store/projects/project-update-actions.ts b/src/store/projects/project-update-actions.ts index e674d296..39b97b24 100644 --- a/src/store/projects/project-update-actions.ts +++ b/src/store/projects/project-update-actions.ts @@ -5,15 +5,11 @@ import { Dispatch } from "redux"; import { initialize, startSubmit, stopSubmit } from 'redux-form'; import { RootState } from "~/store/store"; -import { updateDetails } from "~/store/details-panel/details-panel-action"; import { dialogActions } from "~/store/dialog/dialog-actions"; -import { snackbarActions } from "~/store/snackbar/snackbar-actions"; import { ContextMenuResource } from '~/store/context-menu/context-menu-reducer'; import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service"; import { ServiceRepository } from "~/services/services"; import { ProjectResource } from '~/models/project'; -import { getProjectList } from '~/store/project/project-action'; -import { projectPanelActions } from '~/store/project-panel/project-panel-action'; export interface ProjectUpdateFormDialogData { uuid: string; @@ -29,29 +25,19 @@ export const openProjectUpdateDialog = (resource: ContextMenuResource) => dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {} })); }; -export const editProject = (data: ProjectUpdateFormDialogData) => - async (dispatch: Dispatch) => { - await dispatch(updateProject(data)); - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "Project has been successfully updated.", - hideDuration: 2000 - })); - }; - export const updateProject = (project: Partial) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = project.uuid || ''; dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME)); try { const updatedProject = await services.projectService.update(uuid, project); - dispatch(projectPanelActions.REQUEST_ITEMS()); - dispatch(getProjectList(updatedProject.ownerUuid)); - dispatch(updateDetails(updatedProject)); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); + return updatedProject; } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_VIOLATION) { dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' })); } + return ; } }; \ No newline at end of file diff --git a/src/store/properties/properties-actions.ts b/src/store/properties/properties-actions.ts new file mode 100644 index 00000000..8647a9c2 --- /dev/null +++ b/src/store/properties/properties-actions.ts @@ -0,0 +1,12 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { unionize, ofType, UnionOf } from '~/common/unionize'; + +export const propertiesActions = unionize({ + SET_PROPERTY: ofType<{ key: string, value: any }>(), + DELETE_PROPERTY: ofType(), +}); + +export type PropertiesAction = UnionOf; diff --git a/src/store/properties/properties-reducer.ts b/src/store/properties/properties-reducer.ts new file mode 100644 index 00000000..27fdf850 --- /dev/null +++ b/src/store/properties/properties-reducer.ts @@ -0,0 +1,14 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { PropertiesState, setProperty, deleteProperty } from './properties'; +import { PropertiesAction, propertiesActions } from './properties-actions'; + + +export const propertiesReducer = (state: PropertiesState = {}, action: PropertiesAction) => + propertiesActions.match(action, { + SET_PROPERTY: ({ key, value }) => setProperty(key, value)(state), + DELETE_PROPERTY: key => deleteProperty(key)(state), + default: () => state, + }); \ No newline at end of file diff --git a/src/store/properties/properties.ts b/src/store/properties/properties.ts new file mode 100644 index 00000000..bee5b19d --- /dev/null +++ b/src/store/properties/properties.ts @@ -0,0 +1,23 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +export type PropertiesState = { [key: string]: any }; + +export const getProperty = (id: string) => + (state: PropertiesState): T | undefined => + state[id]; + +export const setProperty = (id: string, data: T) => + (state: PropertiesState) => ({ + ...state, + [id]: data + }); + +export const deleteProperty = (id: string) => + (state: PropertiesState) => { + const newState = { ...state }; + delete newState[id]; + return newState; + }; + diff --git a/src/store/resources/resources-actions.ts b/src/store/resources/resources-actions.ts new file mode 100644 index 00000000..0034e7aa --- /dev/null +++ b/src/store/resources/resources-actions.ts @@ -0,0 +1,31 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { unionize, ofType, UnionOf } from '~/common/unionize'; +import { Resource, extractUuidKind } from '~/models/resource'; +import { Dispatch } from 'redux'; +import { RootState } from '~/store/store'; +import { ServiceRepository } from '~/services/services'; +import { getResourceService } from '~/services/services'; + +export const resourcesActions = unionize({ + SET_RESOURCES: ofType(), + DELETE_RESOURCES: ofType() +}); + +export type ResourcesAction = UnionOf; + +export const updateResources = (resources: Resource[]) => resourcesActions.SET_RESOURCES(resources); + +export const loadResource = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const kind = extractUuidKind(uuid); + const service = getResourceService(kind)(services); + if (service) { + const resource = await service.get(uuid); + dispatch(updateResources([resource])); + return resource; + } + return undefined; + }; \ No newline at end of file diff --git a/src/store/resources/resources-reducer.ts b/src/store/resources/resources-reducer.ts new file mode 100644 index 00000000..22108e04 --- /dev/null +++ b/src/store/resources/resources-reducer.ts @@ -0,0 +1,13 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { ResourcesState, setResource, deleteResource } from './resources'; +import { ResourcesAction, resourcesActions } from './resources-actions'; + +export const resourcesReducer = (state: ResourcesState = {}, action: ResourcesAction) => + resourcesActions.match(action, { + SET_RESOURCES: resources => resources.reduce((state, resource) => setResource(resource.uuid, resource)(state), state), + DELETE_RESOURCES: ids => ids.reduce((state, id) => deleteResource(id)(state), state), + default: () => state, + }); \ No newline at end of file diff --git a/src/store/resources/resources.ts b/src/store/resources/resources.ts new file mode 100644 index 00000000..add4efef --- /dev/null +++ b/src/store/resources/resources.ts @@ -0,0 +1,37 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Resource } from "~/models/resource"; +import { ResourceKind } from '../../models/resource'; + +export type ResourcesState = { [key: string]: Resource }; + +export const getResource = (id: string) => + (state: ResourcesState): T | undefined => + state[id] as T; + +export const setResource = (id: string, data: T) => + (state: ResourcesState) => ({ + ...state, + [id]: data + }); + +export const deleteResource = (id: string) => + (state: ResourcesState) => { + const newState = {...state}; + delete newState[id]; + return newState; + }; + +export const filterResources = (filter: (resource: Resource) => boolean) => + (state: ResourcesState) => + Object + .keys(state) + .map(id => getResource(id)(state)) + .filter(filter); + +export const filterResourcesByKind = (kind: ResourceKind) => + (state: ResourcesState) => + filterResources(resource => resource.kind === kind)(state); + diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts new file mode 100644 index 00000000..8fbc375c --- /dev/null +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -0,0 +1,162 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from 'redux'; +import { treePickerActions } from "~/store/tree-picker/tree-picker-actions"; +import { createTreePickerNode, TreePickerNode } from '~/store/tree-picker/tree-picker'; +import { RootState } from '../store'; +import { ServiceRepository } from '~/services/services'; +import { FilterBuilder } from '~/common/api/filter-builder'; +import { resourcesActions } from '../resources/resources-actions'; +import { getTreePicker, TreePicker } from '../tree-picker/tree-picker'; +import { TreeItemStatus } from "~/components/tree/tree"; +import { getNodeAncestors, getNodeValue, getNodeAncestorsIds, getNode } from '~/models/tree'; +import { ProjectResource } from '~/models/project'; + +export enum SidePanelTreeCategory { + PROJECTS = 'Projects', + SHARED_WITH_ME = 'Shared with me', + WORKFLOWS = 'Workflows', + RECENT_OPEN = 'Recent open', + FAVORITES = 'Favorites', + TRASH = 'Trash' +} + +export const SIDE_PANEL_TREE = 'sidePanelTree'; + +export const getSidePanelTree = (treePicker: TreePicker) => + getTreePicker(SIDE_PANEL_TREE)(treePicker); + +export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker): Array> => { + const tree = getSidePanelTree(treePicker); + if (tree) { + const ancestors = getNodeAncestors(uuid)(tree).map(node => node.value); + const node = getNodeValue(uuid)(tree); + if (node) { + return [...ancestors, node]; + } + } + return []; +}; + +const SIDE_PANEL_CATEGORIES = [ + SidePanelTreeCategory.SHARED_WITH_ME, + SidePanelTreeCategory.WORKFLOWS, + SidePanelTreeCategory.RECENT_OPEN, + SidePanelTreeCategory.FAVORITES, + SidePanelTreeCategory.TRASH, +]; + +export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id); + +export const initSidePanelTree = () => + (dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => { + const rootProjectUuid = authService.getUuid() || ''; + const nodes = SIDE_PANEL_CATEGORIES.map(nodeId => createTreePickerNode({ nodeId, value: nodeId })); + const projectsNode = createTreePickerNode({ nodeId: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS }); + dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ + nodeId: '', + pickerId: SIDE_PANEL_TREE, + nodes: [projectsNode, ...nodes] + })); + SIDE_PANEL_CATEGORIES.forEach(category => { + dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ + nodeId: category, + pickerId: SIDE_PANEL_TREE, + nodes: [] + })); + }); + }; + +export const loadSidePanelTreeProjects = (projectUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const treePicker = getTreePicker(SIDE_PANEL_TREE)(getState().treePicker); + const node = treePicker ? getNode(projectUuid)(treePicker) : undefined; + if (node || projectUuid === '') { + dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: projectUuid, pickerId: SIDE_PANEL_TREE })); + const params = { + filters: new FilterBuilder() + .addEqual('ownerUuid', projectUuid) + .getFilters() + }; + const { items } = await services.projectService.list(params); + dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ + nodeId: projectUuid, + pickerId: SIDE_PANEL_TREE, + nodes: items.map(item => createTreePickerNode({ nodeId: item.uuid, value: item })), + })); + dispatch(resourcesActions.SET_RESOURCES(items)); + } + }; + +export const activateSidePanelTreeItem = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const node = getSidePanelTreeNode(nodeId)(getState().treePicker); + if (node && !node.selected) { + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId: SIDE_PANEL_TREE })); + } + if (!isSidePanelTreeCategory(nodeId)) { + await dispatch(activateSidePanelTreeProject(nodeId)); + } + }; + +export const activateSidePanelTreeProject = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const { treePicker } = getState(); + const node = getSidePanelTreeNode(nodeId)(treePicker); + if (node && node.status !== TreeItemStatus.LOADED) { + await dispatch(loadSidePanelTreeProjects(nodeId)); + } else if (node === undefined) { + await dispatch(activateSidePanelTreeBranch(nodeId)); + } + dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({ + nodeIds: getSidePanelTreeNodeAncestorsIds(nodeId)(treePicker), + pickerId: SIDE_PANEL_TREE + })); + dispatch(expandSidePanelTreeItem(nodeId)); + }; + +export const activateSidePanelTreeBranch = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const ancestors = await services.ancestorsService.ancestors(nodeId, services.authService.getUuid() || ''); + for (const ancestor of ancestors) { + await dispatch(loadSidePanelTreeProjects(ancestor.uuid)); + } + dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({ + nodeIds: ancestors.map(ancestor => ancestor.uuid), + pickerId: SIDE_PANEL_TREE + })); + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId: SIDE_PANEL_TREE })); + }; + +export const toggleSidePanelTreeItemCollapse = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState) => { + const node = getSidePanelTreeNode(nodeId)(getState().treePicker); + if (node && node.status === TreeItemStatus.INITIAL) { + await dispatch(loadSidePanelTreeProjects(node.nodeId)); + } + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId: SIDE_PANEL_TREE })); + }; + +export const expandSidePanelTreeItem = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState) => { + const node = getSidePanelTreeNode(nodeId)(getState().treePicker); + if (node && node.collapsed) { + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId: SIDE_PANEL_TREE })); + } + }; + +const getSidePanelTreeNode = (nodeId: string) => (treePicker: TreePicker) => { + const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(treePicker); + return sidePanelTree + ? getNodeValue(nodeId)(sidePanelTree) + : undefined; +}; + +const getSidePanelTreeNodeAncestorsIds = (nodeId: string) => (treePicker: TreePicker) => { + const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(treePicker); + return sidePanelTree + ? getNodeAncestorsIds(nodeId)(sidePanelTree) + : []; +}; diff --git a/src/store/side-panel/side-panel-action.ts b/src/store/side-panel/side-panel-action.ts index ecea3535..8c7ef4a7 100644 --- a/src/store/side-panel/side-panel-action.ts +++ b/src/store/side-panel/side-panel-action.ts @@ -2,14 +2,31 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; -import { SidePanelId } from "~/store/side-panel/side-panel-reducer"; +import { Dispatch } from 'redux'; +import { isSidePanelTreeCategory, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions'; +import { navigateToFavorites, navigateTo } from '../navigation/navigation-action'; +import { snackbarActions } from '~/store/snackbar/snackbar-actions'; -export const sidePanelActions = unionize({ - TOGGLE_SIDE_PANEL_ITEM_OPEN: ofType() -}, { - tag: 'type', - value: 'payload' -}); +export const navigateFromSidePanel = (id: string) => + (dispatch: Dispatch) => { + if (isSidePanelTreeCategory(id)) { + dispatch(getSidePanelTreeCategoryAction(id)); + } else { + dispatch(navigateTo(id)); + } + }; -export type SidePanelAction = UnionOf; +const getSidePanelTreeCategoryAction = (id: string) => { + switch (id) { + case SidePanelTreeCategory.FAVORITES: + return navigateToFavorites; + default: + return sidePanelTreeCategoryNotAvailable(id); + } +}; + +const sidePanelTreeCategoryNotAvailable = (id: string) => + snackbarActions.OPEN_SNACKBAR({ + message: `${id} not available`, + hideDuration: 3000, + }); \ No newline at end of file diff --git a/src/store/side-panel/side-panel-reducer.test.ts b/src/store/side-panel/side-panel-reducer.test.ts deleted file mode 100644 index a76e33a4..00000000 --- a/src/store/side-panel/side-panel-reducer.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { sidePanelReducer } from "./side-panel-reducer"; -import { sidePanelActions } from "./side-panel-action"; -import { ProjectsIcon } from "~/components/icon/icon"; - -describe('side-panel-reducer', () => { - it('should open side-panel item', () => { - const initialState = [ - { - id: "1", - name: "Projects", - url: "/projects", - icon: ProjectsIcon, - open: false - } - ]; - const project = [ - { - id: "1", - name: "Projects", - icon: ProjectsIcon, - open: true, - url: "/projects" - } - ]; - - const state = sidePanelReducer(initialState, sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(initialState[0].id)); - expect(state).toEqual(project); - }); -}); diff --git a/src/store/side-panel/side-panel-reducer.ts b/src/store/side-panel/side-panel-reducer.ts deleted file mode 100644 index db1cbe5d..00000000 --- a/src/store/side-panel/side-panel-reducer.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { sidePanelActions, SidePanelAction } from './side-panel-action'; -import { SidePanelItem } from '~/components/side-panel/side-panel'; -import { ProjectsIcon, ShareMeIcon, WorkflowIcon, RecentIcon, FavoriteIcon, TrashIcon } from "~/components/icon/icon"; -import { Dispatch } from "redux"; -import { push } from "react-router-redux"; -import { favoritePanelActions } from "../favorite-panel/favorite-panel-action"; -import { projectPanelActions } from "../project-panel/project-panel-action"; -import { projectActions } from "../project/project-action"; -import { getProjectUrl } from "../../models/project"; -import { columns as projectPanelColumns } from "../../views/project-panel/project-panel"; -import { columns as favoritePanelColumns } from "../../views/favorite-panel/favorite-panel"; - -export type SidePanelState = SidePanelItem[]; - -export const sidePanelReducer = (state: SidePanelState = sidePanelItems, action: SidePanelAction) => { - return sidePanelActions.match(action, { - TOGGLE_SIDE_PANEL_ITEM_OPEN: itemId => - state.map(it => ({...it, open: itemId === it.id && it.open === false})), - default: () => state - }); -}; - -export enum SidePanelId { - PROJECTS = "Projects", - SHARED_WITH_ME = "SharedWithMe", - WORKFLOWS = "Workflows", - RECENT_OPEN = "RecentOpen", - FAVORITES = "Favourites", - TRASH = "Trash" -} - -export const sidePanelItems = [ - { - id: SidePanelId.PROJECTS, - name: "Projects", - url: "/projects", - icon: ProjectsIcon, - open: false, - active: false, - margin: true, - openAble: true, - activeAction: (dispatch: Dispatch, uuid: string) => { - dispatch(push(getProjectUrl(uuid))); - dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(uuid)); - dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns })); - dispatch(projectPanelActions.RESET_PAGINATION()); - dispatch(projectPanelActions.REQUEST_ITEMS()); - } - }, - { - id: SidePanelId.SHARED_WITH_ME, - name: "Shared with me", - url: "/shared", - icon: ShareMeIcon, - active: false, - activeAction: (dispatch: Dispatch) => { - dispatch(push("/shared")); - } - }, - { - id: SidePanelId.WORKFLOWS, - name: "Workflows", - url: "/workflows", - icon: WorkflowIcon, - active: false, - activeAction: (dispatch: Dispatch) => { - dispatch(push("/workflows")); - } - }, - { - id: SidePanelId.RECENT_OPEN, - name: "Recent open", - url: "/recent", - icon: RecentIcon, - active: false, - activeAction: (dispatch: Dispatch) => { - dispatch(push("/recent")); - } - }, - { - id: SidePanelId.FAVORITES, - name: "Favorites", - url: "/favorites", - icon: FavoriteIcon, - active: false, - activeAction: (dispatch: Dispatch) => { - dispatch(push("/favorites")); - dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns })); - dispatch(favoritePanelActions.RESET_PAGINATION()); - dispatch(favoritePanelActions.REQUEST_ITEMS()); - } - }, - { - id: SidePanelId.TRASH, - name: "Trash", - url: "/trash", - icon: TrashIcon, - active: false, - activeAction: (dispatch: Dispatch) => { - dispatch(push("/trash")); - } - } -]; diff --git a/src/store/snackbar/snackbar-actions.ts b/src/store/snackbar/snackbar-actions.ts index 2f6175ad..55d9f3a8 100644 --- a/src/store/snackbar/snackbar-actions.ts +++ b/src/store/snackbar/snackbar-actions.ts @@ -2,11 +2,11 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; export const snackbarActions = unionize({ OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number}>(), CLOSE_SNACKBAR: ofType<{}>() -}, { tag: 'type', value: 'payload' }); +}); export type SnackbarAction = UnionOf; diff --git a/src/store/snackbar/snackbar-reducer.ts b/src/store/snackbar/snackbar-reducer.ts index 403c19f0..fc2f4a19 100644 --- a/src/store/snackbar/snackbar-reducer.ts +++ b/src/store/snackbar/snackbar-reducer.ts @@ -20,7 +20,7 @@ const initialState: SnackbarState = { export const snackbarReducer = (state = initialState, action: SnackbarAction) => { return snackbarActions.match(action, { - OPEN_SNACKBAR: data => ({ ...data, open: true }), + OPEN_SNACKBAR: data => ({ ...initialState, ...data, open: true }), CLOSE_SNACKBAR: () => initialState, default: () => state, }); diff --git a/src/store/store.ts b/src/store/store.ts index a4bf9d6e..abfe187c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -3,75 +3,43 @@ // SPDX-License-Identifier: AGPL-3.0 import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux'; -import { routerMiddleware, routerReducer, RouterState } from "react-router-redux"; +import { routerMiddleware, routerReducer } from "react-router-redux"; import thunkMiddleware from 'redux-thunk'; import { History } from "history"; -import { projectsReducer, ProjectState } from "./project/project-reducer"; -import { sidePanelReducer, SidePanelState } from './side-panel/side-panel-reducer'; -import { authReducer, AuthState } from "./auth/auth-reducer"; -import { dataExplorerReducer, DataExplorerState } from './data-explorer/data-explorer-reducer'; -import { detailsPanelReducer, DetailsPanelState } from './details-panel/details-panel-reducer'; -import { contextMenuReducer, ContextMenuState } from './context-menu/context-menu-reducer'; +import { authReducer } from "./auth/auth-reducer"; +import { dataExplorerReducer } from './data-explorer/data-explorer-reducer'; +import { detailsPanelReducer } from './details-panel/details-panel-reducer'; +import { contextMenuReducer } from './context-menu/context-menu-reducer'; import { reducer as formReducer } from 'redux-form'; -import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer'; -import { snackbarReducer, SnackbarState } from './snackbar/snackbar-reducer'; -import { CollectionPanelFilesState } from './collection-panel/collection-panel-files/collection-panel-files-state'; +import { favoritesReducer } from './favorites/favorites-reducer'; +import { snackbarReducer } from './snackbar/snackbar-reducer'; import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer'; import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware"; import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action"; import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action"; import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service"; import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service"; -import { CollectionPanelState, collectionPanelReducer } from './collection-panel/collection-panel-reducer'; -import { DialogState, dialogReducer } from './dialog/dialog-reducer'; -import { CollectionsState, collectionsReducer } from './collections/collections-reducer'; +import { collectionPanelReducer } from './collection-panel/collection-panel-reducer'; +import { dialogReducer } from './dialog/dialog-reducer'; +import { 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'; +import { resourcesReducer } from '~/store/resources/resources-reducer'; +import { propertiesReducer } from './properties/properties-reducer'; +import { RootState } from './store'; const composeEnhancers = (process.env.NODE_ENV === 'development' && window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose; -export interface RootState { - auth: AuthState; - projects: ProjectState; - collections: CollectionsState; - router: RouterState; - dataExplorer: DataExplorerState; - sidePanel: SidePanelState; - collectionPanel: CollectionPanelState; - detailsPanel: DetailsPanelState; - contextMenu: ContextMenuState; - favorites: FavoritesState; - snackbar: SnackbarState; - collectionPanelFiles: CollectionPanelFilesState; - dialog: DialogState; - treePicker: TreePicker; -} +export type RootState = ReturnType>; export type RootStore = Store & { dispatch: Dispatch }; export function configureStore(history: History, services: ServiceRepository): RootStore { - const rootReducer = combineReducers({ - auth: authReducer(services), - projects: projectsReducer, - collections: collectionsReducer, - router: routerReducer, - dataExplorer: dataExplorerReducer, - sidePanel: sidePanelReducer, - collectionPanel: collectionPanelReducer, - detailsPanel: detailsPanelReducer, - contextMenu: contextMenuReducer, - form: formReducer, - favorites: favoritesReducer, - snackbar: snackbarReducer, - collectionPanelFiles: collectionPanelFilesReducer, - dialog: dialogReducer, - treePicker: treePickerReducer, - }); + const rootReducer = createRootReducer(services); const projectPanelMiddleware = dataExplorerMiddleware( new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID) @@ -89,3 +57,21 @@ export function configureStore(history: History, services: ServiceRepository): R const enhancer = composeEnhancers(applyMiddleware(...middlewares)); return createStore(rootReducer, enhancer); } + +const createRootReducer = (services: ServiceRepository) => combineReducers({ + auth: authReducer(services), + collections: collectionsReducer, + router: routerReducer, + dataExplorer: dataExplorerReducer, + collectionPanel: collectionPanelReducer, + detailsPanel: detailsPanelReducer, + contextMenu: contextMenuReducer, + form: formReducer, + favorites: favoritesReducer, + snackbar: snackbarReducer, + collectionPanelFiles: collectionPanelFilesReducer, + dialog: dialogReducer, + treePicker: treePickerReducer, + resources: resourcesReducer, + properties: propertiesReducer, +}); diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts index 34f13037..5b04389a 100644 --- a/src/store/tree-picker/tree-picker-actions.ts +++ b/src/store/tree-picker/tree-picker-actions.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { default as unionize, ofType, UnionOf } from "unionize"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; import { TreePickerNode } from "./tree-picker"; @@ -11,10 +11,8 @@ export const treePickerActions = unionize({ LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ nodeId: string, nodes: Array, pickerId: string }>(), TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ nodeId: string, pickerId: string }>(), TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ nodeId: string, pickerId: string }>(), + EXPAND_TREE_PICKER_NODES: ofType<{ nodeIds: string[], pickerId: string }>(), RESET_TREE_PICKER: ofType<{ pickerId: string }>() -}, { - tag: 'type', - value: 'payload' - }); +}); export type TreePickerAction = UnionOf; diff --git a/src/store/tree-picker/tree-picker-reducer.ts b/src/store/tree-picker/tree-picker-reducer.ts index e7173d22..b0d9bc94 100644 --- a/src/store/tree-picker/tree-picker-reducer.ts +++ b/src/store/tree-picker/tree-picker-reducer.ts @@ -7,6 +7,7 @@ import { TreePicker, TreePickerNode } from "./tree-picker"; import { treePickerActions, TreePickerAction } from "./tree-picker-actions"; import { TreeItemStatus } from "~/components/tree/tree"; import { compose } from "redux"; +import { getNode } from '../../models/tree'; export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) => treePickerActions.match(action, { @@ -18,8 +19,10 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi updateOrCreatePicker(state, pickerId, setNodeValueWith(toggleCollapse)(nodeId)), TOGGLE_TREE_PICKER_NODE_SELECT: ({ nodeId, pickerId }) => updateOrCreatePicker(state, pickerId, mapTreeValues(toggleSelect(nodeId))), - RESET_TREE_PICKER: ({ pickerId }) => + RESET_TREE_PICKER: ({ pickerId }) => updateOrCreatePicker(state, pickerId, createTree), + EXPAND_TREE_PICKER_NODES: ({ pickerId, nodeIds }) => + updateOrCreatePicker(state, pickerId, mapTreeValues(expand(nodeIds))), default: () => state }); @@ -29,6 +32,11 @@ const updateOrCreatePicker = (state: TreePicker, pickerId: string, func: (value: return { ...state, [pickerId]: updatedPicker }; }; +const expand = (ids: string[]) => (node: TreePickerNode): TreePickerNode => + ids.some(id => id === node.nodeId) + ? { ...node, collapsed: false } + : node; + const setPending = (value: TreePickerNode): TreePickerNode => ({ ...value, status: TreeItemStatus.PENDING }); @@ -43,11 +51,19 @@ const toggleSelect = (nodeId: string) => (value: TreePickerNode): TreePickerNode ? ({ ...value, selected: !value.selected }) : ({ ...value, selected: false }); -const receiveNodes = (nodes: Array) => (parent: string) => (state: Tree) => - nodes.reduce((tree, node) => - setNode( - createTreeNode(parent)(node) - )(tree), state); +const receiveNodes = (nodes: Array) => (parent: string) => (state: Tree) => { + const parentNode = getNode(parent)(state); + let newState = state; + if (parentNode) { + newState = setNode({ ...parentNode, children: [] })(state); + } + return nodes.reduce((tree, node) => { + const oldNode = getNode(node.nodeId)(state) || { value: {} }; + const newNode = createTreeNode(parent)(node); + const value = { ...oldNode.value, ...newNode.value }; + return setNode({ ...newNode, value })(tree); + }, newState); +}; const createTreeNode = (parent: string) => (node: TreePickerNode): TreeNode => ({ children: [], diff --git a/src/store/tree-picker/tree-picker.ts b/src/store/tree-picker/tree-picker.ts index c815ad4f..259a4b8d 100644 --- a/src/store/tree-picker/tree-picker.ts +++ b/src/store/tree-picker/tree-picker.ts @@ -7,9 +7,9 @@ import { TreeItemStatus } from "~/components/tree/tree"; export type TreePicker = { [key: string]: Tree }; -export interface TreePickerNode { +export interface TreePickerNode { nodeId: string; - value: any; + value: Value; selected: boolean; collapsed: boolean; status: TreeItemStatus; @@ -21,3 +21,5 @@ export const createTreePickerNode = (data: { nodeId: string, value: any }) => ({ collapsed: true, status: TreeItemStatus.INITIAL }); + +export const getTreePicker = (id: string) => (state: TreePicker): Tree> | undefined => state[id]; \ No newline at end of file diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts new file mode 100644 index 00000000..8dc64a84 --- /dev/null +++ b/src/store/workbench/workbench-actions.ts @@ -0,0 +1,194 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from 'redux'; +import { RootState } from "../store"; +import { loadDetailsPanel } from '~/store/details-panel/details-panel-action'; +import { loadCollectionPanel } from '~/store/collection-panel/collection-panel-action'; +import { snackbarActions } from '../snackbar/snackbar-actions'; +import { loadFavoritePanel } from '../favorite-panel/favorite-panel-action'; +import { openProjectPanel, projectPanelActions } from '~/store/project-panel/project-panel-action'; +import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory, loadSidePanelTreeProjects } from '../side-panel-tree/side-panel-tree-actions'; +import { loadResource, updateResources } from '../resources/resources-actions'; +import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-action'; +import { projectPanelColumns } from '~/views/project-panel/project-panel'; +import { favoritePanelColumns } from '~/views/favorite-panel/favorite-panel'; +import { matchRootRoute } from '~/routes/routes'; +import { setCollectionBreadcrumbs, setProjectBreadcrumbs, setSidePanelBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions'; +import { navigateToProject } from '../navigation/navigation-action'; +import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog'; +import { ServiceRepository } from '~/services/services'; +import { getResource } from '../resources/resources'; +import { getProjectPanelCurrentUuid } from '../project-panel/project-panel-action'; +import * as projectCreateActions from '~/store/projects/project-create-actions'; +import * as projectMoveActions from '~/store/projects/project-move-actions'; +import * as projectUpdateActions from '~/store/projects/project-update-actions'; +import * as collectionCreateActions from '~/store/collections/collection-create-actions'; +import * as collectionCopyActions from '~/store/collections/collection-copy-actions'; +import * as collectionUpdateActions from '~/store/collections/collection-update-actions'; +import * as collectionMoveActions from '~/store/collections/collection-move-actions'; + + +export const loadWorkbench = () => + async (dispatch: Dispatch, getState: () => RootState) => { + const { auth, router } = getState(); + const { user } = auth; + if (user) { + const userResource = await dispatch(loadResource(user.uuid)); + if (userResource) { + dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns })); + dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns })); + dispatch(initSidePanelTree()); + if (router.location) { + const match = matchRootRoute(router.location.pathname); + if (match) { + dispatch(navigateToProject(userResource.uuid)); + } + } + } else { + dispatch(userIsNotAuthenticated); + } + } else { + dispatch(userIsNotAuthenticated); + } + }; + +export const loadFavorites = () => + (dispatch: Dispatch) => { + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES)); + dispatch(loadFavoritePanel()); + dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES)); + }; + + +export const loadProject = (uuid: string) => + async (dispatch: Dispatch) => { + await dispatch(activateSidePanelTreeItem(uuid)); + dispatch(setProjectBreadcrumbs(uuid)); + dispatch(openProjectPanel(uuid)); + dispatch(loadDetailsPanel(uuid)); + }; + +export const createProject = (data: projectCreateActions.ProjectCreateFormDialogData) => + async (dispatch: Dispatch) => { + const newProject = await dispatch(projectCreateActions.createProject(data)); + if (newProject) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Project has been successfully created.", + hideDuration: 2000 + })); + await dispatch(loadSidePanelTreeProjects(newProject.ownerUuid)); + dispatch(reloadProjectMatchingUuid([newProject.ownerUuid])); + } + }; + +export const moveProject = (data: MoveToFormDialogData) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const oldProject = getResource(data.uuid)(getState().resources); + const oldOwnerUuid = oldProject ? oldProject.ownerUuid : ''; + const movedProject = await dispatch(projectMoveActions.moveProject(data)); + if (movedProject) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Project has been moved', hideDuration: 2000 })); + if (oldProject) { + await dispatch(loadSidePanelTreeProjects(oldProject.ownerUuid)); + } + dispatch(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid])); + } + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000 })); + } + }; + +export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const updatedProject = await dispatch(projectUpdateActions.updateProject(data)); + if (updatedProject) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Project has been successfully updated.", + hideDuration: 2000 + })); + await dispatch(loadSidePanelTreeProjects(updatedProject.ownerUuid)); + dispatch(reloadProjectMatchingUuid([updatedProject.ownerUuid, updatedProject.uuid])); + } + }; + +export const loadCollection = (uuid: string) => + async (dispatch: Dispatch) => { + const collection = await dispatch(loadCollectionPanel(uuid)); + await dispatch(activateSidePanelTreeItem(collection.ownerUuid)); + dispatch(setCollectionBreadcrumbs(collection.uuid)); + dispatch(loadDetailsPanel(uuid)); + }; + +export const createCollection = (data: collectionCreateActions.CollectionCreateFormDialogData) => + async (dispatch: Dispatch) => { + const collection = await dispatch(collectionCreateActions.createCollection(data)); + if (collection) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Collection has been successfully created.", + hideDuration: 2000 + })); + dispatch(updateResources([collection])); + dispatch(reloadProjectMatchingUuid([collection.ownerUuid])); + } + }; + +export const updateCollection = (data: collectionUpdateActions.CollectionUpdateFormDialogData) => + async (dispatch: Dispatch) => { + const collection = await dispatch(collectionUpdateActions.updateCollection(data)); + if (collection) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Collection has been successfully updated.", + hideDuration: 2000 + })); + dispatch(updateResources([collection])); + dispatch(reloadProjectMatchingUuid([collection.ownerUuid])); + } + }; + +export const copyCollection = (data: collectionCopyActions.CollectionCopyFormDialogData) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const collection = await dispatch(collectionCopyActions.copyCollection(data)); + dispatch(updateResources([collection])); + dispatch(reloadProjectMatchingUuid([collection.ownerUuid])); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied.', hideDuration: 2000 })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000 })); + } + }; + +export const moveCollection = (data: MoveToFormDialogData) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const collection = await dispatch(collectionMoveActions.moveCollection(data)); + dispatch(updateResources([collection])); + dispatch(reloadProjectMatchingUuid([collection.ownerUuid])); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been moved.', hideDuration: 2000 })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000 })); + } + }; + +export const resourceIsNotLoaded = (uuid: string) => + snackbarActions.OPEN_SNACKBAR({ + message: `Resource identified by ${uuid} is not loaded.` + }); + +export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({ + message: 'User is not authenticated' +}); + +export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({ + message: 'Could not load user' +}); + +const reloadProjectMatchingUuid = (matchingUuids: string[]) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState()); + if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) { + dispatch(loadProject(currentProjectPanelUuid)); + } + }; \ No newline at end of file diff --git a/src/views-components/api-token/api-token.tsx b/src/views-components/api-token/api-token.tsx index 3dc6d1a1..41fce727 100644 --- a/src/views-components/api-token/api-token.tsx +++ b/src/views-components/api-token/api-token.tsx @@ -6,7 +6,6 @@ import { Redirect, RouteProps } from "react-router"; import * as React from "react"; import { connect, DispatchProp } from "react-redux"; import { getUserDetails, saveApiToken } from "~/store/auth/auth-action"; -import { getProjectList } from "~/store/project/project-action"; import { getUrlParameter } from "~/common/url"; import { AuthService } from "~/services/auth-service/auth-service"; @@ -20,10 +19,7 @@ export const ApiToken = connect()( const search = this.props.location ? this.props.location.search : ""; const apiToken = getUrlParameter(search, 'api_token'); this.props.dispatch(saveApiToken(apiToken)); - this.props.dispatch(getUserDetails()).then(() => { - const rootUuid = this.props.authService.getRootUuid(); - this.props.dispatch(getProjectList(rootUuid)); - }); + this.props.dispatch(getUserDetails()); } render() { return ; diff --git a/src/views-components/breadcrumbs/breadcrumbs.ts b/src/views-components/breadcrumbs/breadcrumbs.ts new file mode 100644 index 00000000..c2f33894 --- /dev/null +++ b/src/views-components/breadcrumbs/breadcrumbs.ts @@ -0,0 +1,30 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { connect } from "react-redux"; +import { Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from '~/components/breadcrumbs/breadcrumbs'; +import { RootState } from '~/store/store'; +import { Dispatch } from 'redux'; +import { navigateTo } from '~/store/navigation/navigation-action'; +import { getProperty } from '../../store/properties/properties'; +import { ResourceBreadcrumb, BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions'; +import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions'; + +type BreadcrumbsDataProps = Pick; +type BreadcrumbsActionProps = Pick; + +const mapStateToProps = () => ({ properties }: RootState): BreadcrumbsDataProps => ({ + items: getProperty(BREADCRUMBS)(properties) || [] +}); + +const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({ + onClick: ({ uuid }: ResourceBreadcrumb) => { + dispatch(navigateTo(uuid)); + }, + onContextMenu: (event, breadcrumb: ResourceBreadcrumb) => { + dispatch(openSidePanelContextMenu(event, breadcrumb.uuid)); + } +}); + +export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent); \ No newline at end of file diff --git a/src/views-components/collection-panel-files/collection-panel-files.ts b/src/views-components/collection-panel-files/collection-panel-files.ts index 3e99e10a..10469957 100644 --- a/src/views-components/collection-panel-files/collection-panel-files.ts +++ b/src/views-components/collection-panel-files/collection-panel-files.ts @@ -15,6 +15,7 @@ import { ContextMenuKind } from "../context-menu/context-menu"; import { Tree, getNodeChildrenIds, getNode } from "~/models/tree"; import { CollectionFileType } from "~/models/collection-file"; import { openUploadCollectionFilesDialog } from '~/store/collections/uploader/collection-uploader-actions'; +import { openContextMenu } from '../../store/context-menu/context-menu-actions'; const memoizedMapStateToProps = () => { let prevState: CollectionPanelFilesState; @@ -43,17 +44,11 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick { - event.preventDefault(); - dispatch(contextMenuActions.OPEN_CONTEXT_MENU({ - position: { x: event.clientX, y: event.clientY }, - resource: { kind: ContextMenuKind.COLLECTION_FILES_ITEM, name: item.data.name, uuid: item.id } - })); + dispatch(openContextMenu(event, { kind: ContextMenuKind.COLLECTION_FILES_ITEM, name: item.data.name, uuid: item.id })); + }, + onOptionsMenuOpen: (event) => { + dispatch(openContextMenu(event, { kind: ContextMenuKind.COLLECTION_FILES, name: '', uuid: '' })); }, - onOptionsMenuOpen: (event) => - dispatch(contextMenuActions.OPEN_CONTEXT_MENU({ - position: { x: event.clientX, y: event.clientY }, - resource: { kind: ContextMenuKind.COLLECTION_FILES, name: '', uuid: '' } - })) }); diff --git a/src/views-components/context-menu/action-sets/project-action-set.ts b/src/views-components/context-menu/action-sets/project-action-set.ts index bc5d739a..af10aedf 100644 --- a/src/views-components/context-menu/action-sets/project-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-action-set.ts @@ -2,15 +2,13 @@ // // 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 { 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 { PROJECT_CREATE_FORM_NAME, openProjectCreateDialog } from '~/store/projects/project-create-actions'; +import { openProjectCreateDialog } from '~/store/projects/project-create-actions'; import { openProjectUpdateDialog } from '~/store/projects/project-update-actions'; export const projectActionSet: ContextMenuActionSet = [[ @@ -18,7 +16,6 @@ export const projectActionSet: ContextMenuActionSet = [[ icon: NewProjectIcon, name: "New project", execute: (dispatch, resource) => { - dispatch(reset(PROJECT_CREATE_FORM_NAME)); dispatch(openProjectCreateDialog(resource.uuid)); } }, diff --git a/src/views-components/context-menu/action-sets/root-project-action-set.ts b/src/views-components/context-menu/action-sets/root-project-action-set.ts index 2c71abc4..386c5162 100644 --- a/src/views-components/context-menu/action-sets/root-project-action-set.ts +++ b/src/views-components/context-menu/action-sets/root-project-action-set.ts @@ -2,20 +2,16 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { reset } from "redux-form"; - import { ContextMenuActionSet } from "../context-menu-action-set"; -import { projectActions } from "~/store/project/project-action"; -import { COLLECTION_CREATE_FORM_NAME, openCollectionCreateDialog } from '~/store/collections/collection-create-actions'; +import { openCollectionCreateDialog } from '~/store/collections/collection-create-actions'; import { NewProjectIcon, CollectionIcon } from "~/components/icon/icon"; -import { PROJECT_CREATE_FORM_NAME, openProjectCreateDialog } from '~/store/projects/project-create-actions'; +import { openProjectCreateDialog } from '~/store/projects/project-create-actions'; export const rootProjectActionSet: ContextMenuActionSet = [[ { icon: NewProjectIcon, name: "New project", execute: (dispatch, resource) => { - dispatch(reset(PROJECT_CREATE_FORM_NAME)); dispatch(openProjectCreateDialog(resource.uuid)); } }, @@ -23,7 +19,6 @@ export const rootProjectActionSet: ContextMenuActionSet = [[ icon: CollectionIcon, name: "New Collection", execute: (dispatch, resource) => { - dispatch(reset(COLLECTION_CREATE_FORM_NAME)); dispatch(openCollectionCreateDialog(resource.uuid)); } } diff --git a/src/views-components/data-explorer/data-explorer.tsx b/src/views-components/data-explorer/data-explorer.tsx index d548f607..16dd5993 100644 --- a/src/views-components/data-explorer/data-explorer.tsx +++ b/src/views-components/data-explorer/data-explorer.tsx @@ -14,23 +14,18 @@ import { DataColumns } from "~/components/data-table/data-table"; interface Props { id: string; - columns: DataColumns; onRowClick: (item: any) => void; onContextMenu: (event: React.MouseEvent, item: any) => void; onRowDoubleClick: (item: any) => void; extractKey?: (item: any) => React.Key; } -const mapStateToProps = (state: RootState, { id, columns }: Props) => { - const s = getDataExplorer(state.dataExplorer, id); - if (s.columns.length === 0) { - s.columns = columns; - } - return s; +const mapStateToProps = (state: RootState, { id }: Props) => { + return getDataExplorer(state.dataExplorer, id); }; const mapDispatchToProps = () => { - return (dispatch: Dispatch, { id, columns, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({ + return (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({ onSetColumns: (columns: DataColumns) => { dispatch(dataExplorerActions.SET_COLUMNS({ id, columns })); }, diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index 1b07642a..abf18392 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -9,9 +9,14 @@ import { ResourceKind } from '~/models/resource'; import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon } from '~/components/icon/icon'; import { formatDate, formatFileSize } from '~/common/formatters'; import { resourceLabel } from '~/common/labels'; +import { connect } from 'react-redux'; +import { RootState } from '~/store/store'; +import { getResource } from '../../store/resources/resources'; +import { GroupContentsResource } from '~/services/groups-service/groups-service'; +import { ProcessResource } from '~/models/process'; -export const renderName = (item: {name: string; uuid: string, kind: string}) => +export const renderName = (item: { name: string; uuid: string, kind: string }) => {renderIcon(item)} @@ -28,8 +33,13 @@ export const renderName = (item: {name: string; uuid: string, kind: string}) => ; +export const ResourceName = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined; + return resource || { name: '', uuid: '', kind: '' }; + })(renderName); -export const renderIcon = (item: {kind: string}) => { +export const renderIcon = (item: { kind: string }) => { switch (item.kind) { case ResourceKind.PROJECT: return ; @@ -46,22 +56,52 @@ export const renderDate = (date: string) => { return {formatDate(date)}; }; +export const ResourceLastModifiedDate = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined; + return { date: resource ? resource.modifiedAt : '' }; + })((props: { date: string }) => renderDate(props.date)); + export const renderFileSize = (fileSize?: number) => {formatFileSize(fileSize)} ; +export const ResourceFileSize = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined; + return {}; + })((props: { fileSize?: number }) => renderFileSize(props.fileSize)); + export const renderOwner = (owner: string) => {owner} ; +export const ResourceOwner = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined; + return { owner: resource ? resource.ownerUuid : '' }; + })((props: { owner: string }) => renderOwner(props.owner)); + export const renderType = (type: string) => {resourceLabel(type)} ; -export const renderStatus = (item: {status?: string}) => +export const ResourceType = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources) as GroupContentsResource | undefined; + return { type: resource ? resource.kind : '' }; + })((props: { type: string }) => renderType(props.type)); + +export const renderStatus = (item: { status?: string }) => {item.status || "-"} ; + +export const ProcessStatus = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources) as ProcessResource | undefined; + return { status: resource ? resource.state : '-' }; + })((props: { status: string }) => renderType(props.status)); diff --git a/src/views-components/details-panel/details-panel.tsx b/src/views-components/details-panel/details-panel.tsx index 21d57aea..7aae7860 100644 --- a/src/views-components/details-panel/details-panel.tsx +++ b/src/views-components/details-panel/details-panel.tsx @@ -20,6 +20,7 @@ import { ProcessDetails } from "./process-details"; import { EmptyDetails } from "./empty-details"; import { DetailsData } from "./details-data"; import { DetailsResource } from "~/models/details"; +import { getResource } from '../../store/resources/resources'; type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'headerTitle' | 'tabContainer'; @@ -70,10 +71,13 @@ const getItem = (resource: DetailsResource): DetailsData => { } }; -const mapStateToProps = ({ detailsPanel }: RootState) => ({ - isOpened: detailsPanel.isOpened, - item: getItem(detailsPanel.item as DetailsResource) -}); +const mapStateToProps = ({ detailsPanel, resources }: RootState) => { + const resource = getResource(detailsPanel.resourceUuid)(resources) as DetailsResource; + return { + isOpened: detailsPanel.isOpened, + item: getItem(resource) + }; +}; const mapDispatchToProps = (dispatch: Dispatch) => ({ onCloseDrawer: () => { @@ -110,7 +114,7 @@ export const DetailsPanel = withStyles(styles)( const { tabsValue } = this.state; return ( + className={classnames([classes.container, { [classes.opened]: isOpened }])}> @@ -124,14 +128,14 @@ export const DetailsPanel = withStyles(styles)( - {} + {} - - + + {tabsValue === 0 && this.renderTabContainer( @@ -139,7 +143,7 @@ export const DetailsPanel = withStyles(styles)( )} {tabsValue === 1 && this.renderTabContainer( - + )} diff --git a/src/views-components/dialog-forms/copy-collection-dialog.ts b/src/views-components/dialog-forms/copy-collection-dialog.ts index ee6293ab..245465fa 100644 --- a/src/views-components/dialog-forms/copy-collection-dialog.ts +++ b/src/views-components/dialog-forms/copy-collection-dialog.ts @@ -5,8 +5,9 @@ import { compose } from "redux"; import { withDialog } from "~/store/dialog/with-dialog"; import { reduxForm } from 'redux-form'; -import { COLLECTION_COPY_FORM_NAME, CollectionCopyFormDialogData, copyCollection } from '~/store/collections/collection-copy-actions'; +import { COLLECTION_COPY_FORM_NAME, CollectionCopyFormDialogData } from '~/store/collections/collection-copy-actions'; import { DialogCollectionCopy } from "~/views-components/dialog-copy/dialog-collection-copy"; +import { copyCollection } from '~/store/workbench/workbench-actions'; export const CopyCollectionDialog = compose( withDialog(COLLECTION_COPY_FORM_NAME), diff --git a/src/views-components/dialog-forms/create-collection-dialog.ts b/src/views-components/dialog-forms/create-collection-dialog.ts index d2699d83..581743e0 100644 --- a/src/views-components/dialog-forms/create-collection-dialog.ts +++ b/src/views-components/dialog-forms/create-collection-dialog.ts @@ -5,16 +5,16 @@ import { compose } from "redux"; import { reduxForm } from 'redux-form'; import { withDialog } from "~/store/dialog/with-dialog"; -import { addCollection, COLLECTION_CREATE_FORM_NAME, CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions'; -import { UploadFile } from "~/store/collections/uploader/collection-uploader-actions"; +import { COLLECTION_CREATE_FORM_NAME, CollectionCreateFormDialogData } from '~/store/collections/collection-create-actions'; import { DialogCollectionCreate } from "~/views-components/dialog-create/dialog-collection-create"; +import { createCollection } from "~/store/workbench/workbench-actions"; export const CreateCollectionDialog = compose( withDialog(COLLECTION_CREATE_FORM_NAME), reduxForm({ form: COLLECTION_CREATE_FORM_NAME, onSubmit: (data, dispatch) => { - dispatch(addCollection(data)); + dispatch(createCollection(data)); } }) )(DialogCollectionCreate); diff --git a/src/views-components/dialog-forms/create-project-dialog.ts b/src/views-components/dialog-forms/create-project-dialog.ts index 2e87517c..fc9fa2bc 100644 --- a/src/views-components/dialog-forms/create-project-dialog.ts +++ b/src/views-components/dialog-forms/create-project-dialog.ts @@ -5,15 +5,16 @@ import { compose } from "redux"; import { reduxForm } from 'redux-form'; import { withDialog } from "~/store/dialog/with-dialog"; -import { addProject, PROJECT_CREATE_FORM_NAME, ProjectCreateFormDialogData } from '~/store/projects/project-create-actions'; +import { PROJECT_CREATE_FORM_NAME, ProjectCreateFormDialogData } from '~/store/projects/project-create-actions'; import { DialogProjectCreate } from '~/views-components/dialog-create/dialog-project-create'; +import { createProject } from "~/store/workbench/workbench-actions"; export const CreateProjectDialog = compose( withDialog(PROJECT_CREATE_FORM_NAME), reduxForm({ form: PROJECT_CREATE_FORM_NAME, onSubmit: (data, dispatch) => { - dispatch(addProject(data)); + dispatch(createProject(data)); } }) )(DialogProjectCreate); \ No newline at end of file diff --git a/src/views-components/dialog-forms/move-collection-dialog.ts b/src/views-components/dialog-forms/move-collection-dialog.ts index 38d6d033..fcdd9993 100644 --- a/src/views-components/dialog-forms/move-collection-dialog.ts +++ b/src/views-components/dialog-forms/move-collection-dialog.ts @@ -6,8 +6,9 @@ import { compose } from "redux"; import { withDialog } from "~/store/dialog/with-dialog"; import { reduxForm } from 'redux-form'; import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to'; -import { COLLECTION_MOVE_FORM_NAME, moveCollection } from '~/store/collections/collection-move-actions'; +import { COLLECTION_MOVE_FORM_NAME } from '~/store/collections/collection-move-actions'; import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog'; +import { moveCollection } from '~/store/workbench/workbench-actions'; export const MoveCollectionDialog = compose( withDialog(COLLECTION_MOVE_FORM_NAME), diff --git a/src/views-components/dialog-forms/move-project-dialog.ts b/src/views-components/dialog-forms/move-project-dialog.ts index dd102b14..c1fbb76e 100644 --- a/src/views-components/dialog-forms/move-project-dialog.ts +++ b/src/views-components/dialog-forms/move-project-dialog.ts @@ -6,9 +6,9 @@ import { compose } from "redux"; import { withDialog } from "~/store/dialog/with-dialog"; import { reduxForm } from 'redux-form'; import { PROJECT_MOVE_FORM_NAME } from '~/store/projects/project-move-actions'; -import { moveProject } from '~/store/projects/project-move-actions'; import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog'; import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to'; +import { moveProject } from '~/store/workbench/workbench-actions'; export const MoveProjectDialog = compose( withDialog(PROJECT_MOVE_FORM_NAME), diff --git a/src/views-components/dialog-forms/update-collection-dialog.ts b/src/views-components/dialog-forms/update-collection-dialog.ts index 2c4296d8..cfa52639 100644 --- a/src/views-components/dialog-forms/update-collection-dialog.ts +++ b/src/views-components/dialog-forms/update-collection-dialog.ts @@ -6,14 +6,15 @@ import { compose } from "redux"; import { reduxForm } from 'redux-form'; import { withDialog } from "~/store/dialog/with-dialog"; import { DialogCollectionUpdate } from '~/views-components/dialog-update/dialog-collection-update'; -import { editCollection, COLLECTION_UPDATE_FORM_NAME, CollectionUpdateFormDialogData } from '~/store/collections/collection-update-actions'; +import { COLLECTION_UPDATE_FORM_NAME, CollectionUpdateFormDialogData } from '~/store/collections/collection-update-actions'; +import { updateCollection } from "~/store/workbench/workbench-actions"; export const UpdateCollectionDialog = compose( withDialog(COLLECTION_UPDATE_FORM_NAME), reduxForm({ form: COLLECTION_UPDATE_FORM_NAME, onSubmit: (data, dispatch) => { - dispatch(editCollection(data)); + dispatch(updateCollection(data)); } }) )(DialogCollectionUpdate); \ No newline at end of file diff --git a/src/views-components/dialog-forms/update-project-dialog.ts b/src/views-components/dialog-forms/update-project-dialog.ts index 598d0b19..36d5106b 100644 --- a/src/views-components/dialog-forms/update-project-dialog.ts +++ b/src/views-components/dialog-forms/update-project-dialog.ts @@ -6,14 +6,15 @@ import { compose } from "redux"; import { reduxForm } from 'redux-form'; import { withDialog } from "~/store/dialog/with-dialog"; import { DialogProjectUpdate } from '~/views-components/dialog-update/dialog-project-update'; -import { editProject, PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from '~/store/projects/project-update-actions'; +import { PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from '~/store/projects/project-update-actions'; +import { updateProject } from '~/store/workbench/workbench-actions'; export const UpdateProjectDialog = compose( withDialog(PROJECT_UPDATE_FORM_NAME), reduxForm({ form: PROJECT_UPDATE_FORM_NAME, onSubmit: (data, dispatch) => { - dispatch(editProject(data)); + dispatch(updateProject(data)); } }) )(DialogProjectUpdate); \ No newline at end of file diff --git a/src/views-components/main-app-bar/main-app-bar.test.tsx b/src/views-components/main-app-bar/main-app-bar.test.tsx index 75a39fd5..030fb353 100644 --- a/src/views-components/main-app-bar/main-app-bar.test.tsx +++ b/src/views-components/main-app-bar/main-app-bar.test.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { mount, configure } from "enzyme"; import * as Adapter from "enzyme-adapter-react-16"; -import { MainAppBar } from "./main-app-bar"; +import { MainAppBar, MainAppBarProps } from './main-app-bar'; import { SearchBar } from "~/components/search-bar/search-bar"; import { Breadcrumbs } from "~/components/breadcrumbs/breadcrumbs"; import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu"; @@ -27,10 +27,7 @@ describe("", () => { it("renders all components and the menu for authenticated user if user prop has value", () => { const mainAppBar = mount( ); expect(mainAppBar.find(SearchBar)).toHaveLength(1); @@ -42,10 +39,7 @@ describe("", () => { const menuItems = { accountMenu: [], helpMenu: [], anonymousMenu: [{ label: 'Sign in' }] }; const mainAppBar = mount( ); expect(mainAppBar.find(SearchBar)).toHaveLength(0); @@ -58,12 +52,7 @@ describe("", () => { const onSearch = jest.fn(); const mainAppBar = mount( ); const searchBar = mainAppBar.find(SearchBar); @@ -73,34 +62,12 @@ describe("", () => { expect(onSearch).toBeCalledWith("new search text"); }); - it("communicates with ", () => { - const items = [{ label: "breadcrumb 1" }]; - const onBreadcrumbClick = jest.fn(); - const mainAppBar = mount( - - ); - const breadcrumbs = mainAppBar.find(Breadcrumbs); - expect(breadcrumbs.prop("items")).toBe(items); - breadcrumbs.prop("onClick")(items[0]); - expect(onBreadcrumbClick).toBeCalledWith(items[0]); - }); - it("communicates with menu", () => { const onMenuItemClick = jest.fn(); - const menuItems = { accountMenu: [{label: "log out"}], helpMenu: [], anonymousMenu: [] }; + const menuItems = { accountMenu: [{ label: "log out" }], helpMenu: [], anonymousMenu: [] }; const mainAppBar = mount( ); @@ -109,3 +76,20 @@ describe("", () => { expect(onMenuItemClick).toBeCalledWith(menuItems.accountMenu[0]); }); }); + +const Breadcrumbs = () => Breadcrumbs; + +const mockMainAppBarProps = (props: Partial): MainAppBarProps => ({ + searchText: '', + breadcrumbs: Breadcrumbs, + menuItems: { + accountMenu: [], + helpMenu: [], + anonymousMenu: [], + }, + buildInfo: '', + onSearch: jest.fn(), + onMenuItemClick: jest.fn(), + onDetailsPanelToggle: jest.fn(), + ...props, +}); diff --git a/src/views-components/main-app-bar/main-app-bar.tsx b/src/views-components/main-app-bar/main-app-bar.tsx index 54d6a5da..de6be7e7 100644 --- a/src/views-components/main-app-bar/main-app-bar.tsx +++ b/src/views-components/main-app-bar/main-app-bar.tsx @@ -6,7 +6,6 @@ import * as React from "react"; import { AppBar, Toolbar, Typography, Grid, IconButton, Badge, Button, MenuItem } from "@material-ui/core"; import { User, getUserFullname } from "~/models/user"; import { SearchBar } from "~/components/search-bar/search-bar"; -import { Breadcrumbs, Breadcrumb } from "~/components/breadcrumbs/breadcrumbs"; import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu"; import { DetailsIcon, NotificationIcon, UserPanelIcon, HelpIcon } from "~/components/icon/icon"; @@ -23,7 +22,7 @@ export interface MainAppBarMenuItems { interface MainAppBarDataProps { searchText: string; searchDebounce?: number; - breadcrumbs: Breadcrumb[]; + breadcrumbs: React.ComponentType; user?: User; menuItems: MainAppBarMenuItems; buildInfo: string; @@ -31,13 +30,11 @@ interface MainAppBarDataProps { export interface MainAppBarActionProps { onSearch: (searchText: string) => void; - onBreadcrumbClick: (breadcrumb: Breadcrumb) => void; onMenuItemClick: (menuItem: MainAppBarMenuItem) => void; - onContextMenu: (event: React.MouseEvent, breadcrumb: Breadcrumb) => void; onDetailsPanelToggle: () => void; } -type MainAppBarProps = MainAppBarDataProps & MainAppBarActionProps; +export type MainAppBarProps = MainAppBarDataProps & MainAppBarActionProps; export const MainAppBar: React.SFC = (props) => { return @@ -68,15 +65,10 @@ export const MainAppBar: React.SFC = (props) => { - { - props.user && - } - { props.user && - - + {props.user && } + {props.user && + + } ; diff --git a/src/views-components/project-tree-picker/project-tree-picker.tsx b/src/views-components/project-tree-picker/project-tree-picker.tsx index 92c54515..3859180f 100644 --- a/src/views-components/project-tree-picker/project-tree-picker.tsx +++ b/src/views-components/project-tree-picker/project-tree-picker.tsx @@ -18,9 +18,10 @@ import { ServiceRepository } from "~/services/services"; import { FilterBuilder } from "~/common/api/filter-builder"; import { WrappedFieldProps } from 'redux-form'; -type ProjectTreePickerProps = Pick; +type ProjectTreePickerProps = Pick; const mapDispatchToProps = (dispatch: Dispatch, props: { onChange: (projectUuid: string) => void }): ProjectTreePickerProps => ({ + onContextMenu: () => { return; }, toggleItemActive: (nodeId, status, pickerId) => { getNotSelectedTreePickerKind(pickerId) .forEach(pickerId => dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '', pickerId }))); diff --git a/src/views-components/side-panel-tree/side-panel-tree.tsx b/src/views-components/side-panel-tree/side-panel-tree.tsx new file mode 100644 index 00000000..d0b00d6f --- /dev/null +++ b/src/views-components/side-panel-tree/side-panel-tree.tsx @@ -0,0 +1,69 @@ +// 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 { TreePicker, TreePickerProps } from "../tree-picker/tree-picker"; +import { TreeItem } from "~/components/tree/tree"; +import { ProjectResource } from "~/models/project"; +import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon"; +import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon } from '~/components/icon/icon'; +import { RecentIcon, WorkflowIcon } from '~/components/icon/icon'; +import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions'; +import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions'; + +export interface SidePanelTreeProps { + onItemActivation: (id: string) => void; +} + +type SidePanelTreeActionProps = Pick; + +const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): SidePanelTreeActionProps => ({ + onContextMenu: (event, id) => { + dispatch(openSidePanelContextMenu(event, id)); + }, + toggleItemActive: (nodeId) => { + dispatch(activateSidePanelTreeItem(nodeId)); + props.onItemActivation(nodeId); + }, + toggleItemOpen: (nodeId) => { + dispatch(toggleSidePanelTreeItemCollapse(nodeId)); + } +}); + +export const SidePanelTree = connect(undefined, mapDispatchToProps)( + (props: SidePanelTreeActionProps) => + ); + +const renderSidePanelItem = (item: TreeItem) => + ; + +const getProjectPickerIcon = (item: TreeItem) => + typeof item.data === 'string' + ? getSidePanelIcon(item.data) + : ProjectIcon; + +const getSidePanelIcon = (category: string) => { + switch (category) { + case SidePanelTreeCategory.FAVORITES: + return FavoriteIcon; + case SidePanelTreeCategory.PROJECTS: + return ProjectsIcon; + case SidePanelTreeCategory.RECENT_OPEN: + return RecentIcon; + case SidePanelTreeCategory.SHARED_WITH_ME: + return ShareMeIcon; + case SidePanelTreeCategory.TRASH: + return TrashIcon; + case SidePanelTreeCategory.WORKFLOWS: + return WorkflowIcon; + default: + return ProjectIcon; + } +}; diff --git a/src/views-components/side-panel/side-panel.tsx b/src/views-components/side-panel/side-panel.tsx new file mode 100644 index 00000000..b81f39ef --- /dev/null +++ b/src/views-components/side-panel/side-panel.tsx @@ -0,0 +1,45 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; +import Drawer from '@material-ui/core/Drawer'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { SidePanelTree, SidePanelTreeProps } from '~/views-components/side-panel-tree/side-panel-tree'; +import { compose, Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { navigateFromSidePanel } from '../../store/side-panel/side-panel-action'; + +const DRAWER_WITDH = 240; + +type CssRules = 'drawerPaper' | 'toolbar'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + drawerPaper: { + position: 'relative', + width: DRAWER_WITDH, + display: 'flex', + flexDirection: 'column', + paddingTop: 58, + overflow: 'auto', + }, + toolbar: theme.mixins.toolbar +}); + +const mapDispatchToProps = (dispatch: Dispatch): SidePanelTreeProps => ({ + onItemActivation: id => { + dispatch(navigateFromSidePanel(id)); + } +}); + +export const SidePanel = compose( + withStyles(styles), + connect(undefined, mapDispatchToProps) +)(({ classes, ...props }: WithStyles & SidePanelTreeProps) => + +
+ + ); diff --git a/src/views-components/tree-picker/tree-picker.ts b/src/views-components/tree-picker/tree-picker.ts index b90f2e42..8b7630ab 100644 --- a/src/views-components/tree-picker/tree-picker.ts +++ b/src/views-components/tree-picker/tree-picker.ts @@ -11,25 +11,34 @@ import { Dispatch } from "redux"; export interface TreePickerProps { pickerId: string; + onContextMenu: (event: React.MouseEvent, nodeId: string, pickerId: string) => void; toggleItemOpen: (nodeId: string, status: TreeItemStatus, pickerId: string) => void; toggleItemActive: (nodeId: string, status: TreeItemStatus, pickerId: string) => void; } -const mapStateToProps = (state: RootState, props: TreePickerProps): Pick, 'items'> => { - const tree = state.treePicker[props.pickerId] || createTree(); - return { - items: getNodeChildrenIds('')(tree) - .map(treePickerToTreeItems(tree)) +const memoizedMapStateToProps = () => { + let prevTree: Ttree; + let mappedProps: Pick, 'items'>; + return (state: RootState, props: TreePickerProps): Pick, 'items'> => { + const tree = state.treePicker[props.pickerId] || createTree(); + if(tree !== prevTree){ + prevTree = tree; + mappedProps = { + items: getNodeChildrenIds('')(tree) + .map(treePickerToTreeItems(tree)) + }; + } + return mappedProps; }; }; const mapDispatchToProps = (dispatch: Dispatch, props: TreePickerProps): Pick, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive'> => ({ - onContextMenu: () => { return; }, + onContextMenu: (event, item) => props.onContextMenu(event, item.id, props.pickerId), toggleItemActive: (id, status) => props.toggleItemActive(id, status, props.pickerId), toggleItemOpen: (id, status) => props.toggleItemOpen(id, status, props.pickerId) }); -export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree); +export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree); const treePickerToTreeItems = (tree: Ttree) => (id: string): TreeItem => { diff --git a/src/views/collection-panel/collection-panel.tsx b/src/views/collection-panel/collection-panel.tsx index 559d4a9a..348b548b 100644 --- a/src/views/collection-panel/collection-panel.tsx +++ b/src/views/collection-panel/collection-panel.tsx @@ -20,6 +20,9 @@ import { TagResource } from '~/models/tag'; import { CollectionTagForm } from './collection-tag-form'; import { deleteCollectionTag } from '~/store/collection-panel/collection-panel-action'; import { snackbarActions } from '~/store/snackbar/snackbar-actions'; +import { getResource } from '~/store/resources/resources'; +import { contextMenuActions, openContextMenu } from '~/store/context-menu/context-menu-actions'; +import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'label' | 'value'; @@ -55,81 +58,90 @@ interface CollectionPanelDataProps { tags: TagResource[]; } -interface CollectionPanelActionProps { - onItemRouteChange: (collectionId: string) => void; - onContextMenu: (event: React.MouseEvent, item: CollectionResource) => void; -} - -type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps & DispatchProp - & WithStyles & RouteComponentProps<{ id: string }>; +type CollectionPanelProps = CollectionPanelDataProps & DispatchProp + & WithStyles & RouteComponentProps<{ id: string }>; export const CollectionPanel = withStyles(styles)( - connect((state: RootState) => ({ - item: state.collectionPanel.item, - tags: state.collectionPanel.tags - }))( + connect((state: RootState, props: RouteComponentProps<{ id: string }>) => { + const collection = getResource(props.match.params.id)(state.resources); + return { + item: collection, + tags: state.collectionPanel.tags + }; + })( class extends React.Component { render() { - const { classes, item, tags, onContextMenu } = this.props; + const { classes, item, tags } = this.props; return
- - } - action={ - onContextMenu(event, item)}> - - - } - title={item && item.name } - subheader={item && item.description} /> - - - - - - this.onCopy() }> - - - - - - - - + + } + action={ + + + + } + title={item && item.name} + subheader={item && item.description} /> + + + + + + this.onCopy()}> + + + + + + + - - + + + - - - - - - - { - tags.map(tag => { - return ; - }) - } - + + + + + + + { + tags.map(tag => { + return ; + }) + } - - -
- -
-
; + + + +
+ +
+
; + } + + handleContextMenu = (event: React.MouseEvent) => { + const { uuid, name, description } = this.props.item; + const resource = { + uuid, + name, + description, + kind: ContextMenuKind.COLLECTION + }; + this.props.dispatch(openContextMenu(event, resource)); } handleDelete = (uuid: string) => () => { @@ -143,12 +155,6 @@ export const CollectionPanel = withStyles(styles)( })); } - componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) { - if (!item || match.params.id !== item.uuid) { - onItemRouteChange(match.params.id); - } - } - } ) ); diff --git a/src/views/favorite-panel/favorite-panel-item.ts b/src/views/favorite-panel/favorite-panel-item.ts deleted file mode 100644 index 842b6d6b..00000000 --- a/src/views/favorite-panel/favorite-panel-item.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { GroupContentsResource } from "~/services/groups-service/groups-service"; -import { ResourceKind } from "~/models/resource"; - -export interface FavoritePanelItem { - uuid: string; - name: string; - kind: string; - url: string; - owner: string; - lastModified: string; - fileSize?: number; - status?: string; -} - -export function resourceToDataItem(r: GroupContentsResource): FavoritePanelItem { - return { - uuid: r.uuid, - name: r.name, - kind: r.kind, - url: "", - owner: r.ownerUuid, - lastModified: r.modifiedAt, - status: r.kind === ResourceKind.PROCESS ? r.state : undefined - }; -} diff --git a/src/views/favorite-panel/favorite-panel.tsx b/src/views/favorite-panel/favorite-panel.tsx index 125ea27d..9fbae5ce 100644 --- a/src/views/favorite-panel/favorite-panel.tsx +++ b/src/views/favorite-panel/favorite-panel.tsx @@ -3,22 +3,25 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; -import { FavoritePanelItem } from './favorite-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 { ContainerRequestState } from '~/models/container-request'; 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, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '~/views-components/data-explorer/renderers'; import { FAVORITE_PANEL_ID } from "~/store/favorite-panel/favorite-panel-action"; +import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner, ResourceName } from '~/views-components/data-explorer/renderers'; import { FavoriteIcon } from '~/components/icon/icon'; +import { Dispatch } from 'redux'; +import { contextMenuActions, openContextMenu, resourceKindToContextMenuKind } from '~/store/context-menu/context-menu-actions'; +import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; +import { loadDetailsPanel } from '../../store/details-panel/details-panel-action'; +import { navigateTo } from '~/store/navigation/navigation-action'; type CssRules = "toolbar" | "button"; @@ -45,14 +48,14 @@ export interface FavoritePanelFilter extends DataTableFilterItem { type: ResourceKind | ContainerRequestState; } -export const columns: DataColumns = [ +export const favoritePanelColumns: DataColumns = [ { name: FavoritePanelColumnNames.NAME, selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: [], - render: renderName, + render: uuid => , width: "450px" }, { @@ -77,7 +80,7 @@ export const columns: DataColumns = [ type: ContainerRequestState.UNCOMMITTED } ], - render: renderStatus, + render: uuid => , width: "75px" }, { @@ -102,7 +105,7 @@ export const columns: DataColumns = [ type: ResourceKind.PROJECT } ], - render: item => renderType(item.kind), + render: uuid => , width: "125px" }, { @@ -111,7 +114,7 @@ export const columns: DataColumns = [ configurable: true, sortDirection: SortDirection.NONE, filters: [], - render: item => renderOwner(item.owner), + render: uuid => , width: "200px" }, { @@ -120,7 +123,7 @@ export const columns: DataColumns = [ configurable: true, sortDirection: SortDirection.NONE, filters: [], - render: item => renderFileSize(item.fileSize), + render: uuid => , width: "50px" }, { @@ -129,7 +132,7 @@ export const columns: DataColumns = [ configurable: true, sortDirection: SortDirection.NONE, filters: [], - render: item => renderDate(item.lastModified), + render: uuid => , width: "150px" } ]; @@ -139,36 +142,42 @@ interface FavoritePanelDataProps { } interface FavoritePanelActionProps { - onItemClick: (item: FavoritePanelItem) => void; - onContextMenu: (event: React.MouseEvent, item: FavoritePanelItem) => void; + onItemClick: (item: string) => void; + onContextMenu: (event: React.MouseEvent, item: string) => void; onDialogOpen: (ownerUuid: string) => void; - onItemDoubleClick: (item: FavoritePanelItem) => void; - onItemRouteChange: (itemId: string) => void; + onItemDoubleClick: (item: string) => void; } +const mapDispatchToProps = (dispatch: Dispatch): FavoritePanelActionProps => ({ + onContextMenu: (event, resourceUuid) => { + const kind = resourceKindToContextMenuKind(resourceUuid); + if (kind) { + dispatch(openContextMenu(event, { name: '', uuid: resourceUuid, kind })); + } + }, + onDialogOpen: (ownerUuid: string) => { return; }, + onItemClick: (resourceUuid: string) => { + dispatch(loadDetailsPanel(resourceUuid)); + }, + onItemDoubleClick: uuid => { + dispatch(navigateTo(uuid)); + } +}); + type FavoritePanelProps = FavoritePanelDataProps & FavoritePanelActionProps & DispatchProp - & WithStyles & RouteComponentProps<{ id: string }>; + & WithStyles & RouteComponentProps<{ id: string }>; export const FavoritePanel = withStyles(styles)( - connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))( + connect(undefined, mapDispatchToProps)( class extends React.Component { render() { return item.uuid} defaultIcon={FavoriteIcon} - defaultMessages={['Your favorites list is empty.']}/> - ; - } - - componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: FavoritePanelProps) { - if (match.params.id !== currentItemId) { - onItemRouteChange(match.params.id); - } + defaultMessages={['Your favorites list is empty.']} />; } } ) diff --git a/src/views/process-panel/process-panel.tsx b/src/views/process-panel/process-panel.tsx index 08bd3701..70a7a405 100644 --- a/src/views/process-panel/process-panel.tsx +++ b/src/views/process-panel/process-panel.tsx @@ -14,6 +14,8 @@ import { RouteComponentProps } from 'react-router'; import { MoreOptionsIcon, ProcessIcon } from '~/components/icon/icon'; import { DetailsAttribute } from '~/components/details-attribute/details-attribute'; import { RootState } from '~/store/store'; +import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; +import { openContextMenu } from '~/store/context-menu/context-menu-actions'; type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'content' | 'chip' | 'headerText' | 'link'; @@ -83,7 +85,7 @@ export const ProcessPanel = withStyles(styles)( }))( class extends React.Component { render() { - const { classes, onContextMenu, item } = this.props; + const { classes } = this.props; return
@@ -92,12 +94,11 @@ export const ProcessPanel = withStyles(styles)( action={ onContextMenu(event, item)}> + onClick={this.handleContextMenu}> } - title="Pipeline template that generates a config file from a template" - /> + title="Pipeline template that generates a config file from a template" /> @@ -124,6 +125,17 @@ export const ProcessPanel = withStyles(styles)(
; } + + handleContextMenu = (event: React.MouseEvent) => { + // const { uuid, name, description } = this.props.item; + const resource = { + uuid: '', + name: '', + description: '', + kind: ContextMenuKind.PROCESS + }; + this.props.dispatch(openContextMenu(event, resource)); + } } ) ); \ No newline at end of file diff --git a/src/views/project-panel/project-panel-item.ts b/src/views/project-panel/project-panel-item.ts deleted file mode 100644 index f0318591..00000000 --- a/src/views/project-panel/project-panel-item.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { GroupContentsResource } from "~/services/groups-service/groups-service"; -import { ResourceKind } from "~/models/resource"; - -export interface ProjectPanelItem { - uuid: string; - name: string; - description?: string; - kind: string; - url: string; - owner: string; - lastModified: string; - fileSize?: number; - status?: string; -} - -export function resourceToDataItem(r: GroupContentsResource): ProjectPanelItem { - return { - uuid: r.uuid, - name: r.name, - description: r.description, - kind: r.kind, - url: "", - owner: r.ownerUuid, - lastModified: r.modifiedAt, - status: r.kind === ResourceKind.PROCESS ? r.state : undefined - }; -} diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx index 0f958d2c..06946430 100644 --- a/src/views/project-panel/project-panel.tsx +++ b/src/views/project-panel/project-panel.tsx @@ -3,7 +3,6 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; -import { ProjectPanelItem } from './project-panel-item'; import { Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core'; import { DataExplorer } from "~/views-components/data-explorer/data-explorer"; import { DispatchProp, connect } from 'react-redux'; @@ -16,9 +15,20 @@ 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, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '~/views-components/data-explorer/renderers'; -import { restoreBranch } from '~/store/navigation/navigation-action'; +import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner } from '~/views-components/data-explorer/renderers'; import { ProjectIcon } from '~/components/icon/icon'; +import { ResourceName } from '~/views-components/data-explorer/renderers'; +import { ResourcesState, getResource } from '~/store/resources/resources'; +import { loadDetailsPanel } from '~/store/details-panel/details-panel-action'; +import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; +import { contextMenuActions, resourceKindToContextMenuKind, openContextMenu } from '~/store/context-menu/context-menu-actions'; +import { CollectionResource } from '~/models/collection'; +import { ProjectResource } from '~/models/project'; +import { navigateTo } from '~/store/navigation/navigation-action'; +import { getProperty } from '~/store/properties/properties'; +import { PROJECT_PANEL_CURRENT_UUID } from '~/store/project-panel/project-panel-action'; +import { openCollectionCreateDialog } from '../../store/collections/collection-create-actions'; +import { openProjectCreateDialog } from '~/store/projects/project-create-actions'; type CssRules = 'root' | "toolbar" | "button"; @@ -50,14 +60,14 @@ export interface ProjectPanelFilter extends DataTableFilterItem { type: ResourceKind | ContainerRequestState; } -export const columns: DataColumns = [ +export const projectPanelColumns: DataColumns = [ { name: ProjectPanelColumnNames.NAME, selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: [], - render: renderName, + render: uuid => , width: "450px" }, { @@ -82,7 +92,7 @@ export const columns: DataColumns = [ type: ContainerRequestState.UNCOMMITTED } ], - render: renderStatus, + render: uuid => , width: "75px" }, { @@ -107,7 +117,7 @@ export const columns: DataColumns = [ type: ResourceKind.PROJECT } ], - render: item => renderType(item.kind), + render: uuid => , width: "125px" }, { @@ -116,7 +126,7 @@ export const columns: DataColumns = [ configurable: true, sortDirection: SortDirection.NONE, filters: [], - render: item => renderOwner(item.owner), + render: uuid => , width: "200px" }, { @@ -125,7 +135,7 @@ export const columns: DataColumns = [ configurable: true, sortDirection: SortDirection.NONE, filters: [], - render: item => renderFileSize(item.fileSize), + render: uuid => , width: "50px" }, { @@ -134,7 +144,7 @@ export const columns: DataColumns = [ configurable: true, sortDirection: SortDirection.NONE, filters: [], - render: item => renderDate(item.lastModified), + render: uuid => , width: "150px" } ]; @@ -143,22 +153,17 @@ export const PROJECT_PANEL_ID = "projectPanel"; interface ProjectPanelDataProps { currentItemId: string; + resources: ResourcesState; } -interface ProjectPanelActionProps { - onItemClick: (item: ProjectPanelItem) => void; - onContextMenu: (event: React.MouseEvent, item: ProjectPanelItem) => void; - onProjectCreationDialogOpen: (ownerUuid: string) => void; - onCollectionCreationDialogOpen: (ownerUuid: string) => void; - onItemDoubleClick: (item: ProjectPanelItem) => void; - onItemRouteChange: (itemId: string) => void; -} - -type ProjectPanelProps = ProjectPanelDataProps & ProjectPanelActionProps & DispatchProp +type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles & RouteComponentProps<{ id: string }>; export const ProjectPanel = withStyles(styles)( - connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))( + connect((state: RootState) => ({ + currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties), + resources: state.resources + }))( class extends React.Component { render() { const { classes } = this.props; @@ -176,35 +181,37 @@ export const ProjectPanel = withStyles(styles)( item.uuid} + onRowClick={this.handleRowClick} + onRowDoubleClick={this.handleRowDoubleClick} + onContextMenu={this.handleContextMenu} defaultIcon={ProjectIcon} defaultMessages={['Your project is empty.', 'Please create a project or create a collection and upload a data.']} /> ; } handleNewProjectClick = () => { - this.props.onProjectCreationDialogOpen(this.props.currentItemId); + this.props.dispatch(openProjectCreateDialog(this.props.currentItemId)); } handleNewCollectionClick = () => { - this.props.onCollectionCreationDialogOpen(this.props.currentItemId); + this.props.dispatch(openCollectionCreateDialog(this.props.currentItemId)); } - componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: ProjectPanelProps) { - if (match.params.id !== currentItemId) { - onItemRouteChange(match.params.id); + handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { + const kind = resourceKindToContextMenuKind(resourceUuid); + if (kind) { + this.props.dispatch(openContextMenu(event, { name: '', uuid: resourceUuid, kind })); } } - componentDidMount() { - if (this.props.match.params.id && this.props.currentItemId === '') { - this.props.dispatch(restoreBranch(this.props.match.params.id)); - } + handleRowDoubleClick = (uuid: string) => { + this.props.dispatch(navigateTo(uuid)); } + + handleRowClick = (uuid: string) => { + this.props.dispatch(loadDetailsPanel(uuid)); + } + } ) ); diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index d8daed58..27470fa4 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -4,62 +4,43 @@ import * as React from 'react'; import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; -import Drawer from '@material-ui/core/Drawer'; import { connect, DispatchProp } from "react-redux"; -import { Route, RouteComponentProps, Switch, Redirect } from "react-router"; +import { Route, Switch } from "react-router"; import { login, logout } from "~/store/auth/auth-action"; import { User } from "~/models/user"; import { RootState } from "~/store/store"; import { MainAppBar, MainAppBarActionProps, MainAppBarMenuItem } from '~/views-components/main-app-bar/main-app-bar'; -import { Breadcrumb } from '~/components/breadcrumbs/breadcrumbs'; import { push } from 'react-router-redux'; -import { reset } from 'redux-form'; -import { ProjectTree } from '~/views-components/project-tree/project-tree'; -import { TreeItem } from "~/components/tree/tree"; -import { getTreePath } from '~/store/project/project-reducer'; -import { sidePanelActions } from '~/store/side-panel/side-panel-action'; -import { SidePanel, SidePanelItem } from '~/components/side-panel/side-panel'; -import { ItemMode, setProjectItem } from "~/store/navigation/navigation-action"; -import { projectActions } from "~/store/project/project-action"; +import { ProjectPanel } from "~/views/project-panel/project-panel"; import { DetailsPanel } from '~/views-components/details-panel/details-panel'; import { ArvadosTheme } from '~/common/custom-theme'; - -import { detailsPanelActions, loadDetails } from "~/store/details-panel/details-panel-action"; -import { contextMenuActions } from "~/store/context-menu/context-menu-actions"; -import { ProjectResource } from '~/models/project'; -import { ResourceKind } from '~/models/resource'; -import { ProcessPanel } from '~/views/process-panel/process-panel'; -import { ContextMenu, ContextMenuKind } from "~/views-components/context-menu/context-menu"; +import { detailsPanelActions } from "~/store/details-panel/details-panel-action"; +import { ContextMenu } from "~/views-components/context-menu/context-menu"; import { FavoritePanel } from "../favorite-panel/favorite-panel"; import { CurrentTokenDialog } from '~/views-components/current-token-dialog/current-token-dialog'; import { Snackbar } from '~/views-components/snackbar/snackbar'; -import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-action'; import { CollectionPanel } from '../collection-panel/collection-panel'; -import { loadCollection, loadCollectionTags } from '~/store/collection-panel/collection-panel-action'; -import { getCollectionUrl } from '~/models/collection'; - -import { PROJECT_CREATE_FORM_NAME, openProjectCreateDialog } from '~/store/projects/project-create-actions'; -import { COLLECTION_CREATE_FORM_NAME, openCollectionCreateDialog } from '~/store/collections/collection-create-actions'; -import { CreateCollectionDialog } from '~/views-components/dialog-forms/create-collection-dialog'; -import { UpdateCollectionDialog } from '~/views-components/dialog-forms/update-collection-dialog'; -import { CreateProjectDialog } from '~/views-components/dialog-forms/create-project-dialog'; -import { UpdateProjectDialog } from '~/views-components/dialog-forms/update-project-dialog'; - -import { ProjectPanel } from "~/views/project-panel/project-panel"; import { AuthService } from "~/services/auth-service/auth-service"; import { RenameFileDialog } from '~/views-components/rename-file-dialog/rename-file-dialog'; import { FileRemoveDialog } from '~/views-components/file-remove-dialog/file-remove-dialog'; import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog/multiple-files-remove-dialog'; import { UploadCollectionFilesDialog } from '~/views-components/upload-collection-files-dialog/upload-collection-files-dialog'; import { CollectionPartialCopyDialog } from '~/views-components/collection-partial-copy-dialog/collection-partial-copy-dialog'; +import { SidePanel } from '~/views-components/side-panel/side-panel'; +import { Routes } from '~/routes/routes'; +import { Breadcrumbs } from '~/views-components/breadcrumbs/breadcrumbs'; +import { CreateProjectDialog } from '~/views-components/dialog-forms/create-project-dialog'; +import { CreateCollectionDialog } from '~/views-components/dialog-forms/create-collection-dialog'; +import { CopyCollectionDialog } from '~/views-components/dialog-forms/copy-collection-dialog'; +import { UpdateCollectionDialog } from '~/views-components/dialog-forms/update-collection-dialog'; +import { UpdateProjectDialog } from '~/views-components/dialog-forms/update-project-dialog'; import { MoveProjectDialog } from '~/views-components/dialog-forms/move-project-dialog'; import { MoveCollectionDialog } from '~/views-components/dialog-forms/move-collection-dialog'; -import { CopyCollectionDialog } from '~/views-components/dialog-forms/copy-collection-dialog'; +import { ProcessPanel } from '~/views/process-panel/process-panel'; -const DRAWER_WITDH = 240; const APP_BAR_HEIGHT = 100; -type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'contentWrapper' | 'toolbar'; +type CssRules = 'root' | 'appBar' | 'content' | 'contentWrapper'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ root: { @@ -76,12 +57,6 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ position: "absolute", width: "100%" }, - drawerPaper: { - position: 'relative', - width: DRAWER_WITDH, - display: 'flex', - flexDirection: 'column', - }, contentWrapper: { backgroundColor: theme.palette.background.default, display: "flex", @@ -95,15 +70,11 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ flexGrow: 1, position: 'relative' }, - toolbar: theme.mixins.toolbar }); interface WorkbenchDataProps { - projects: Array>; - currentProjectId: string; user?: User; currentToken?: string; - sidePanelItems: SidePanelItem[]; } interface WorkbenchGeneralProps { @@ -116,10 +87,6 @@ interface WorkbenchActionProps { type WorkbenchProps = WorkbenchDataProps & WorkbenchGeneralProps & WorkbenchActionProps & DispatchProp & WithStyles; -interface NavBreadcrumb extends Breadcrumb { - itemId: string; -} - interface NavMenuItem extends MainAppBarMenuItem { action: () => void; } @@ -138,16 +105,12 @@ interface WorkbenchState { export const Workbench = withStyles(styles)( connect( (state: RootState) => ({ - projects: state.projects.items, - currentProjectId: state.projects.currentItemId, user: state.auth.user, currentToken: state.auth.apiToken, - sidePanelItems: state.sidePanel }) )( class extends React.Component { state = { - isCreationDialogOpen: false, isCurrentTokenDialogOpen: false, anchorEl: null, searchText: "", @@ -183,63 +146,26 @@ export const Workbench = withStyles(styles)( }; render() { - const path = getTreePath(this.props.projects, this.props.currentProjectId); - const breadcrumbs = path.map(item => ({ - label: item.data.name, - itemId: item.data.uuid, - status: item.status - })); - const { classes, user } = this.props; return (
- {user && - -
- this.openContextMenu(event, { - uuid: this.props.authService.getUuid() || "", - name: "", - kind: ContextMenuKind.ROOT_PROJECT - })}> - this.props.dispatch(setProjectItem(itemId, ItemMode.OPEN))} - onContextMenu={(event, item) => this.openContextMenu(event, { - uuid: item.data.uuid, - name: item.data.name, - kind: ContextMenuKind.PROJECT - })} - toggleActive={itemId => { - this.props.dispatch(setProjectItem(itemId, ItemMode.ACTIVE)); - this.props.dispatch(loadDetails(itemId, ResourceKind.PROJECT)); - }} /> - - } + {user && }
- } /> - - - - + + + +
{user && } @@ -266,102 +192,7 @@ export const Workbench = withStyles(styles)( ); } - renderProcessPanel = (props: RouteComponentProps<{ id: string }>) => { - this.openContextMenu(event, { - uuid: 'item.uuid', - name: 'item.name', - description: 'item.description', - kind: ContextMenuKind.PROCESS - }); - }} - {...props} /> - - renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => { - this.props.dispatch(loadCollection(collectionId)); - this.props.dispatch(loadCollectionTags(collectionId)); - }} - onContextMenu={(event, item) => { - this.openContextMenu(event, { - uuid: item.uuid, - name: item.name, - description: item.description, - kind: ContextMenuKind.COLLECTION - }); - }} - {...props} /> - - renderProjectPanel = (props: RouteComponentProps<{ id: string }>) => this.props.dispatch(setProjectItem(itemId, ItemMode.ACTIVE))} - onContextMenu={(event, item) => { - let kind: ContextMenuKind; - - if (item.kind === ResourceKind.PROJECT) { - kind = ContextMenuKind.PROJECT; - } else if (item.kind === ResourceKind.COLLECTION) { - kind = ContextMenuKind.COLLECTION_RESOURCE; - } else { - kind = ContextMenuKind.RESOURCE; - } - - this.openContextMenu(event, { - uuid: item.uuid, - name: item.name, - description: item.description, - kind - }); - }} - onProjectCreationDialogOpen={this.handleProjectCreationDialogOpen} - onCollectionCreationDialogOpen={this.handleCollectionCreationDialogOpen} - onItemClick={item => { - this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind)); - }} - onItemDoubleClick={item => { - switch (item.kind) { - case ResourceKind.COLLECTION: - this.props.dispatch(loadCollection(item.uuid)); - this.props.dispatch(push(getCollectionUrl(item.uuid))); - default: - this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE)); - this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind)); - } - - }} - {...props} /> - - renderFavoritePanel = (props: RouteComponentProps<{ id: string }>) => this.props.dispatch(favoritePanelActions.REQUEST_ITEMS())} - onContextMenu={(event, item) => { - const kind = item.kind === ResourceKind.PROJECT ? ContextMenuKind.PROJECT : ContextMenuKind.RESOURCE; - this.openContextMenu(event, { - uuid: item.uuid, - name: item.name, - kind, - }); - }} - onDialogOpen={this.handleProjectCreationDialogOpen} - onItemClick={item => { - this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind)); - }} - onItemDoubleClick={item => { - switch (item.kind) { - case ResourceKind.COLLECTION: - this.props.dispatch(loadCollection(item.uuid)); - this.props.dispatch(push(getCollectionUrl(item.uuid))); - default: - this.props.dispatch(loadDetails(item.uuid, ResourceKind.PROJECT)); - this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE)); - } - - }} - {...props} /> - mainAppBarActions: MainAppBarActionProps = { - onBreadcrumbClick: ({ itemId }: NavBreadcrumb) => { - this.props.dispatch(setProjectItem(itemId, ItemMode.BOTH)); - this.props.dispatch(loadDetails(itemId, ResourceKind.PROJECT)); - }, onSearch: searchText => { this.setState({ searchText }); this.props.dispatch(push(`/search?q=${searchText}`)); @@ -370,48 +201,8 @@ export const Workbench = withStyles(styles)( onDetailsPanelToggle: () => { this.props.dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL()); }, - onContextMenu: (event: React.MouseEvent, breadcrumb: NavBreadcrumb) => { - this.openContextMenu(event, { - uuid: breadcrumb.itemId, - name: breadcrumb.label, - kind: ContextMenuKind.PROJECT - }); - } }; - toggleSidePanelOpen = (itemId: string) => { - this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(itemId)); - } - - toggleSidePanelActive = (itemId: string) => { - this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId)); - - const panelItem = this.props.sidePanelItems.find(it => it.id === itemId); - if (panelItem && panelItem.activeAction) { - panelItem.activeAction(this.props.dispatch, this.props.authService.getUuid()); - } - } - - handleProjectCreationDialogOpen = (itemUuid: string) => { - this.props.dispatch(reset(PROJECT_CREATE_FORM_NAME)); - this.props.dispatch(openProjectCreateDialog(itemUuid)); - } - - handleCollectionCreationDialogOpen = (itemUuid: string) => { - this.props.dispatch(reset(COLLECTION_CREATE_FORM_NAME)); - this.props.dispatch(openCollectionCreateDialog(itemUuid)); - } - - openContextMenu = (event: React.MouseEvent, resource: { name: string; uuid: string; description?: string; kind: ContextMenuKind; }) => { - event.preventDefault(); - this.props.dispatch( - contextMenuActions.OPEN_CONTEXT_MENU({ - position: { x: event.clientX, y: event.clientY }, - resource - }) - ); - } - toggleCurrentTokenModal = () => { this.setState({ isCurrentTokenDialogOpen: !this.state.isCurrentTokenDialogOpen }); }