From d6d85de50096eb0053d58c5022fd4e949c830929 Mon Sep 17 00:00:00 2001 From: Michal Klobukowski Date: Fri, 24 Aug 2018 16:03:03 +0200 Subject: [PATCH] Refactor to apply global navigation actions Feature #14102 Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski --- src/common/api/common-resource-service.ts | 1 + src/index.tsx | 33 +++- src/models/resource.ts | 12 +- src/routes/routes.ts | 70 +++++++ src/store/auth/auth-action.ts | 29 +-- .../collection-panel-action.ts | 3 +- .../data-explorer-middleware-service.ts | 18 ++ .../favorite-panel-middleware-service.ts | 104 +++++----- src/store/favorites/favorites-actions.ts | 3 +- src/store/navigation/navigation-action.ts | 182 +++++++----------- .../project-panel/project-panel-action.ts | 16 +- .../project-panel-middleware-service.ts | 136 +++++++------ src/store/project/project-action.ts | 4 +- src/store/resources/resources-actions.ts | 22 ++- .../side-panel-tree-actions.ts | 119 ++++++++++++ src/store/side-panel/side-panel-action.ts | 35 +++- .../side-panel/side-panel-reducer.test.ts | 33 ---- src/store/side-panel/side-panel-reducer.ts | 107 ---------- src/store/tree-picker/tree-picker.ts | 3 + src/views-components/api-token/api-token.tsx | 3 +- .../data-explorer/data-explorer.tsx | 11 +- .../navigation-panel/navigation-panel.tsx | 111 ----------- .../side-panel-tree/side-panel-tree.tsx | 65 +++++++ .../side-panel/side-panel.tsx | 45 +++++ .../collection-panel/collection-panel.tsx | 8 - src/views/favorite-panel/favorite-panel.tsx | 13 +- src/views/project-panel/project-panel.tsx | 17 +- src/views/workbench/workbench.tsx | 23 +-- 28 files changed, 676 insertions(+), 550 deletions(-) create mode 100644 src/routes/routes.ts create mode 100644 src/store/side-panel-tree/side-panel-tree-actions.ts delete mode 100644 src/store/side-panel/side-panel-reducer.test.ts delete mode 100644 src/store/side-panel/side-panel-reducer.ts delete mode 100644 src/views-components/navigation-panel/navigation-panel.tsx create mode 100644 src/views-components/side-panel-tree/side-panel-tree.tsx create mode 100644 src/views-components/side-panel/side-panel.tsx diff --git a/src/common/api/common-resource-service.ts b/src/common/api/common-resource-service.ts index 2c9bfb51..fe7494b4 100644 --- a/src/common/api/common-resource-service.ts +++ b/src/common/api/common-resource-service.ts @@ -111,6 +111,7 @@ export class CommonResourceService { .put(this.resourceType + uuid, data && CommonResourceService.mapKeys(_.snakeCase)(data))); } + } export const getCommonResourceServiceError = (errorResponse: any) => { diff --git a/src/index.tsx b/src/index.tsx index bee08c80..ecc61662 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'; @@ -27,6 +27,8 @@ import { collectionFilesActionSet } from './views-components/context-menu/action import { collectionFilesItemActionSet } from './views-components/context-menu/action-sets/collection-files-item-action-set'; 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 { addRouteChangeHandlers } from './routes/routes'; +import { loadWorkbench } from './store/navigation/navigation-action'; const getBuildNumber = () => "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev"); const getGitCommit = () => "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substr(0, 7); @@ -46,24 +48,25 @@ addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_RESOURCE, collectionResourceActionSet); fetchConfig() - .then(config => { + .then(async (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 = () =>
- +
@@ -73,6 +76,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 3b30b088..ff95c1a9 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -30,16 +30,22 @@ export enum ResourceObjectType { 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 = /(.{5})-(.{5})-(.{15})/.exec(uuid); + const match = RESOURCE_UUID_REGEX.exec(uuid); return match - ? match[2] + ? match[0].split('-')[1] : undefined; }; export const extractUuidKind = (uuid: string = '') => { const objectType = extractUuidObjectType(uuid); - switch(objectType){ + switch (objectType) { case ResourceObjectType.USER: return ResourceKind.USER; case ResourceObjectType.GROUP: diff --git a/src/routes/routes.ts b/src/routes/routes.ts new file mode 100644 index 00000000..2bdd17a5 --- /dev/null +++ b/src/routes/routes.ts @@ -0,0 +1,70 @@ +// 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/navigation/navigation-action'; + +export const Routes = { + ROOT: '/', + PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`, + COLLECTIONS: `/collections/: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/store/auth/auth-action.ts b/src/store/auth/auth-action.ts index 00af5ce5..72e2d345 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -8,6 +8,8 @@ import { User } from "~/models/user"; import { RootState } from "../store"; import { ServiceRepository } from "~/services/services"; import { AxiosInstance } from "axios"; +import { initSidePanelTree } from '../side-panel-tree/side-panel-tree-actions'; +import { updateResources } from '../resources/resources-actions'; export const authActions = unionize({ SAVE_API_TOKEN: ofType(), @@ -17,9 +19,9 @@ export const authActions = unionize({ USER_DETAILS_REQUEST: {}, USER_DETAILS_SUCCESS: ofType() }, { - tag: 'type', - value: 'payload' -}); + tag: 'type', + value: 'payload' + }); function setAuthorizationHeader(services: ServiceRepository, token: string) { services.apiClient.defaults.headers.common = { @@ -34,16 +36,17 @@ function removeAuthorizationHeader(client: AxiosInstance) { delete client.defaults.headers.common.Authorization; } -export const initAuth = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const user = services.authService.getUser(); - const token = services.authService.getApiToken(); - if (token) { - setAuthorizationHeader(services, token); - } - if (token && user) { - dispatch(authActions.INIT({ user, token })); - } -}; +export const initAuth = () => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const user = services.authService.getUser(); + const token = services.authService.getApiToken(); + if (token) { + setAuthorizationHeader(services, token); + } + if (token && user) { + dispatch(authActions.INIT({ user, token })); + } + }; export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { services.authService.saveApiToken(token); diff --git a/src/store/collection-panel/collection-panel-action.ts b/src/store/collection-panel/collection-panel-action.ts index d8ad6d0a..5b2690bf 100644 --- a/src/store/collection-panel/collection-panel-action.ts +++ b/src/store/collection-panel/collection-panel-action.ts @@ -29,7 +29,7 @@ export type CollectionPanelAction = UnionOf; export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm'; -export const loadCollection = (uuid: string) => +export const loadCollectionPanel = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid })); dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() })); @@ -37,6 +37,7 @@ export const loadCollection = (uuid: string) => dispatch(resourcesActions.SET_RESOURCES([collection])); dispatch(loadCollectionFiles(collection.uuid)); dispatch(loadCollectionTags(collection.uuid)); + return collection; }; export const loadCollectionTags = (uuid: string) => 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/favorite-panel/favorite-panel-middleware-service.ts b/src/store/favorite-panel/favorite-panel-middleware-service.ts index e4be32d8..e5857dd3 100644 --- a/src/store/favorite-panel/favorite-panel-middleware-service.ts +++ b/src/store/favorite-panel/favorite-panel-middleware-service.ts @@ -9,13 +9,15 @@ import { DataColumns } from "~/components/data-table/data-table"; import { ServiceRepository } from "~/services/services"; import { SortDirection } from "~/components/data-table/data-column"; import { FilterBuilder } from "~/common/api/filter-builder"; -import { checkPresenceInFavorites } from "../favorites/favorites-actions"; +import { 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,54 +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(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(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..57eecf8f 100644 --- a/src/store/favorites/favorites-actions.ts +++ b/src/store/favorites/favorites-actions.ts @@ -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 d440f190..db8efbfd 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -2,149 +2,113 @@ // // 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, Resource } from '~/models/resource'; -import { projectPanelActions } from "../project-panel/project-panel-action"; 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"; +import { getProjectUrl } from "~/models/project"; import { getResource } from '~/store/resources/resources'; import { loadDetailsPanel } from '~/store/details-panel/details-panel-action'; -import { loadCollection } from '~/store/collection-panel/collection-panel-action'; -import { GroupContentsResource } from "~/services/groups-service/groups-service"; +import { loadCollectionPanel } from '~/store/collection-panel/collection-panel-action'; import { snackbarActions } from '../snackbar/snackbar-actions'; import { resourceLabel } from "~/common/labels"; +import { loadFavoritePanel } from '../favorite-panel/favorite-panel-action'; +import { openProjectPanel, projectPanelActions } from '~/store/project-panel/project-panel-action'; +import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions'; +import { Routes } from '~/routes/routes'; +import { loadResource } from '../resources/resources-actions'; +import { ServiceRepository } from '~/services/services'; +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'; -export const getResourceUrl = (resourceKind: ResourceKind, resourceUuid: string): string => { - switch (resourceKind) { - case ResourceKind.PROJECT: return getProjectUrl(resourceUuid); - case ResourceKind.COLLECTION: return getCollectionUrl(resourceUuid); +export const navigateToResource = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState) => { + const resource = getResource(uuid)(getState().resources); + if (resource) { + dispatch(getResourceNavigationAction(resource)); + } + }; + +const getResourceNavigationAction = (resource: Resource) => { + switch (resource.kind) { + case ResourceKind.COLLECTION: + return navigateToCollection(resource.uuid); + case ResourceKind.PROJECT: + case ResourceKind.USER: + return navigateToProject(resource.uuid); default: - return ''; + return cannotNavigateToResource(resource); } }; -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)); +export const loadWorkbench = () => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + 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)); + } } - dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(treeItem.data.uuid)); + } else { + dispatch(userIsNotAuthenticated); } - - 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()); - } + dispatch(userIsNotAuthenticated); } }; -export const restoreBranch = (itemId: string) => +export const navigateToFavorites = push(Routes.FAVORITES); + +export const loadFavorites = () => 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)); - }); + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES)); + dispatch(loadFavoritePanel()); }; -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]; - } -}; -const loadBranch = async (uuids: string[], dispatch: Dispatch): Promise => { - const [uuid, ...rest] = uuids; - if (uuid) { - await dispatch(getProjectList(uuid)); - return loadBranch(rest, dispatch); - } -}; - -export const navigateToResource = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState) => { - const resource = getResource(uuid)(getState().resources); - resource - ? dispatch(getResourceNavigationAction(resource)) - : dispatch(resourceIsNotLoaded(uuid)); - }; +export const navigateToProject = compose(push, getProjectUrl); -const getResourceNavigationAction = (resource: Resource) => { - switch (resource.kind) { - case ResourceKind.COLLECTION: - return navigateToCollection(resource); - case ResourceKind.PROJECT: - return navigateToProject(resource); - default: - return cannotNavigateToResource(resource); - } -}; - -export const navigateToProject = ({ uuid }: Resource) => +export const loadProject = (uuid: string) => (dispatch: Dispatch) => { - dispatch(setProjectItem(uuid, ItemMode.BOTH)); + dispatch(activateSidePanelTreeItem(uuid)); + dispatch(openProjectPanel(uuid)); dispatch(loadDetailsPanel(uuid)); }; -export const navigateToCollection = ({ uuid }: Resource) => - (dispatch: Dispatch) => { - dispatch(loadCollection(uuid)); - dispatch(push(getCollectionUrl(uuid))); +export const navigateToCollection = compose(push, getCollectionUrl); + +export const loadCollection = (uuid: string) => + async (dispatch: Dispatch) => { + const collection = await dispatch(loadCollectionPanel(uuid)); + dispatch(activateSidePanelTreeItem(collection.ownerUuid)); + dispatch(loadDetailsPanel(uuid)); }; export const cannotNavigateToResource = ({ kind, uuid }: Resource) => snackbarActions.OPEN_SNACKBAR({ - message: `${resourceLabel(kind)} identified by ${uuid} cannot be opened.`, - hideDuration: 3000 + message: `${resourceLabel(kind)} identified by ${uuid} cannot be opened.` }); - export const resourceIsNotLoaded = (uuid: string) => snackbarActions.OPEN_SNACKBAR({ - message: `Resource identified by ${uuid} is not loaded.`, - hideDuration: 3000 + 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' +}); \ No newline at end of file diff --git a/src/store/project-panel/project-panel-action.ts b/src/store/project-panel/project-panel-action.ts index 33cedd71..49041032 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 0196ed42..da7f5b33 100644 --- a/src/store/project-panel/project-panel-middleware-service.ts +++ b/src/store/project-panel/project-panel-middleware-service.ts @@ -2,7 +2,7 @@ // // 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"; @@ -10,70 +10,98 @@ import { ServiceRepository } from "~/services/services"; 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 { resourcesActions } from "~/store/resources/resources-actions"; +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(resourcesActions.SET_RESOURCES(response.items)); - api.dispatch(projectPanelActions.SET_ITEMS({ - items: response.items.map(resource => resource.uuid), - 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 index da58ed28..53e09cc6 100644 --- a/src/store/project/project-action.ts +++ b/src/store/project/project-action.ts @@ -7,7 +7,7 @@ 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 { updateFavorites } from "../favorites/favorites-actions"; import { ServiceRepository } from "~/services/services"; import { projectPanelActions } from "~/store/project-panel/project-panel-action"; import { resourcesActions } from "~/store/resources/resources-actions"; @@ -40,7 +40,7 @@ export const getProjectList = (parentUuid: string = '') => .getFilters() }).then(({ items: projects }) => { dispatch(projectActions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid })); - dispatch(checkPresenceInFavorites(projects.map(project => project.uuid))); + dispatch(updateFavorites(projects.map(project => project.uuid))); return projects; }); }; diff --git a/src/store/resources/resources-actions.ts b/src/store/resources/resources-actions.ts index 36f99362..0034e7aa 100644 --- a/src/store/resources/resources-actions.ts +++ b/src/store/resources/resources-actions.ts @@ -3,11 +3,29 @@ // SPDX-License-Identifier: AGPL-3.0 import { unionize, ofType, UnionOf } from '~/common/unionize'; -import { Resource } from '~/models/resource'; +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; \ No newline at end of file +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/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..268f1e6c --- /dev/null +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -0,0 +1,119 @@ +// 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 } 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 { getNodeValue } from '../../models/tree'; +import { getTreePicker } from '../tree-picker/tree-picker'; +import { TreeItemStatus } from "~/components/tree/tree"; + +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'; + +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) => { + 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()); + if (node && !node.selected) { + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId: SIDE_PANEL_TREE })); + } + if (!isSidePanelTreeCategory(nodeId)) { + dispatch(activateSidePanelTreeProject(nodeId)); + } + }; + +export const activateSidePanelTreeProject = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const node = getSidePanelTreeNode(nodeId)(getState()); + if (node && node.status !== TreeItemStatus.LOADED) { + await dispatch(loadSidePanelTreeProjects(nodeId)); + if (node.collapsed) { + dispatch(toggleSidePanelTreeItemCollapse(nodeId)); + } + } else if (node === undefined) { + dispatch(activateSidePanelTreeBranch(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)); + } + for (const ancestor of ancestors) { + dispatch(toggleSidePanelTreeItemCollapse(ancestor.uuid)); + } + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId: SIDE_PANEL_TREE })); + }; + +const getSidePanelTreeNode = (nodeId: string) => (state: RootState) => { + const sidePanelTree = getTreePicker(SIDE_PANEL_TREE)(state.treePicker); + return sidePanelTree + ? getNodeValue(nodeId)(sidePanelTree) + : undefined; +}; + +export const toggleSidePanelTreeItemCollapse = (nodeId: string) => + async (dispatch: Dispatch, getState: () => RootState) => { + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId: SIDE_PANEL_TREE })); + }; diff --git a/src/store/side-panel/side-panel-action.ts b/src/store/side-panel/side-panel-action.ts index ecea3535..4fc745b1 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, navigateToResource } 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(navigateToResource(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/tree-picker/tree-picker.ts b/src/store/tree-picker/tree-picker.ts index c815ad4f..fd104fe4 100644 --- a/src/store/tree-picker/tree-picker.ts +++ b/src/store/tree-picker/tree-picker.ts @@ -4,6 +4,7 @@ import { Tree } from "~/models/tree"; import { TreeItemStatus } from "~/components/tree/tree"; +import { RootState } from '~/store/store'; export type TreePicker = { [key: string]: Tree }; @@ -21,3 +22,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/views-components/api-token/api-token.tsx b/src/views-components/api-token/api-token.tsx index 3dc6d1a1..4fa87a28 100644 --- a/src/views-components/api-token/api-token.tsx +++ b/src/views-components/api-token/api-token.tsx @@ -9,6 +9,7 @@ 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"; +import { loadWorkbench } from '../../store/navigation/navigation-action'; interface ApiTokenProps { authService: AuthService; @@ -22,7 +23,7 @@ export const ApiToken = connect()( this.props.dispatch(saveApiToken(apiToken)); this.props.dispatch(getUserDetails()).then(() => { const rootUuid = this.props.authService.getRootUuid(); - this.props.dispatch(getProjectList(rootUuid)); + this.props.dispatch(loadWorkbench()); }); } render() { 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/navigation-panel/navigation-panel.tsx b/src/views-components/navigation-panel/navigation-panel.tsx deleted file mode 100644 index 283e9be6..00000000 --- a/src/views-components/navigation-panel/navigation-panel.tsx +++ /dev/null @@ -1,111 +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 Drawer from '@material-ui/core/Drawer'; -import { connect } from "react-redux"; -import { ProjectTree } from '~/views-components/project-tree/project-tree'; -import { SidePanel, SidePanelItem } from '~/components/side-panel/side-panel'; -import { ArvadosTheme } from '~/common/custom-theme'; -import { RootState } from '~/store/store'; -import { TreeItem } from '~/components/tree/tree'; -import { ProjectResource } from '~/models/project'; -import { sidePanelActions } from '../../store/side-panel/side-panel-action'; -import { Dispatch } from 'redux'; -import { projectActions } from '~/store/project/project-action'; -import { navigateToResource } from '../../store/navigation/navigation-action'; -import { openContextMenu } from '~/store/context-menu/context-menu-actions'; -import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; - - -const DRAWER_WITDH = 240; - -type CssRules = 'drawerPaper' | 'toolbar'; - -const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ - drawerPaper: { - position: 'relative', - width: DRAWER_WITDH, - display: 'flex', - flexDirection: 'column', - }, - toolbar: theme.mixins.toolbar -}); - -interface NavigationPanelDataProps { - projects: Array>; - sidePanelItems: SidePanelItem[]; -} - -interface NavigationPanelActionProps { - toggleSidePanelOpen: (panelItemId: string) => void; - toggleSidePanelActive: (panelItemId: string) => void; - toggleProjectOpen: (projectUuid: string) => void; - toggleProjectActive: (projectUuid: string) => void; - openRootContextMenu: (event: React.MouseEvent) => void; - openProjectContextMenu: (event: React.MouseEvent, item: TreeItem) => void; -} - -type NavigationPanelProps = NavigationPanelDataProps & NavigationPanelActionProps & WithStyles; - -const mapStateToProps = (state: RootState): NavigationPanelDataProps => ({ - projects: state.projects.items, - sidePanelItems: state.sidePanel -}); - -const mapDispatchToProps = (dispatch: Dispatch): NavigationPanelActionProps => ({ - toggleSidePanelOpen: panelItemId => { - dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(panelItemId)); - }, - toggleSidePanelActive: panelItemId => { - dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(panelItemId)); - - // const panelItem = this.props.sidePanelItems.find(it => it.id === itemId); - // if (panelItem && panelItem.activeAction) { - // panelItem.activeAction(this.props.dispatch, this.props.authService.getUuid()); - // } - }, - toggleProjectOpen: projectUuid => { - dispatch(navigateToResource(projectUuid)); - }, - toggleProjectActive: projectUuid => { - dispatch(navigateToResource(projectUuid)); - }, - openRootContextMenu: event => { - dispatch(openContextMenu(event, { - uuid: "", - name: "", - kind: ContextMenuKind.ROOT_PROJECT - })); - }, - openProjectContextMenu: (event, item) => { - dispatch(openContextMenu(event, { - uuid: item.data.uuid, - name: item.data.name, - kind: ContextMenuKind.PROJECT - })); - } -}); - -export const NavigationPanel = withStyles(styles)( - connect(mapStateToProps, mapDispatchToProps)( - ({ classes, sidePanelItems, projects, ...actions }: NavigationPanelProps) => -
- - - - - ) -); 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..6445515c --- /dev/null +++ b/src/views-components/side-panel-tree/side-panel-tree.tsx @@ -0,0 +1,65 @@ +// 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'; + +export interface SidePanelTreeProps { + onItemActivation: (id: string) => void; +} + +type SidePanelTreeActionProps = Pick; + +const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): SidePanelTreeActionProps => ({ + 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/collection-panel/collection-panel.tsx b/src/views/collection-panel/collection-panel.tsx index 7621d95a..d22dd0de 100644 --- a/src/views/collection-panel/collection-panel.tsx +++ b/src/views/collection-panel/collection-panel.tsx @@ -21,7 +21,6 @@ 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 { loadCollection } from '../../store/collection-panel/collection-panel-action'; import { contextMenuActions } from '~/store/context-menu/context-menu-actions'; import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; @@ -162,13 +161,6 @@ export const CollectionPanel = withStyles(styles)( })); } - componentDidMount() { - const { match, item } = this.props; - if (!item && match.params.id) { - this.props.dispatch(loadCollection(match.params.id)); - } - } - } ) ); diff --git a/src/views/favorite-panel/favorite-panel.tsx b/src/views/favorite-panel/favorite-panel.tsx index dfe107a8..cdfe9705 100644 --- a/src/views/favorite-panel/favorite-panel.tsx +++ b/src/views/favorite-panel/favorite-panel.tsx @@ -49,7 +49,7 @@ export interface FavoritePanelFilter extends DataTableFilterItem { type: ResourceKind | ContainerRequestState; } -export const columns: DataColumns = [ +export const favoritePanelColumns: DataColumns = [ { name: FavoritePanelColumnNames.NAME, selected: true, @@ -147,7 +147,6 @@ interface FavoritePanelActionProps { onContextMenu: (event: React.MouseEvent, item: string) => void; onDialogOpen: (ownerUuid: string) => void; onItemDoubleClick: (item: string) => void; - onMount: () => void; } const mapDispatchToProps = (dispatch: Dispatch): FavoritePanelActionProps => ({ @@ -166,10 +165,7 @@ const mapDispatchToProps = (dispatch: Dispatch): FavoritePanelActionProps => ({ }, onItemDoubleClick: uuid => { dispatch(navigateToResource(uuid)); - }, - onMount: () => { - dispatch(loadFavoritePanel()); - }, + } }); type FavoritePanelProps = FavoritePanelDataProps & FavoritePanelActionProps & DispatchProp @@ -181,17 +177,12 @@ export const FavoritePanel = withStyles(styles)( render() { return ; } - - componentDidMount() { - this.props.onMount(); - } } ) ); diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx index a2ae4cfd..37712c7d 100644 --- a/src/views/project-panel/project-panel.tsx +++ b/src/views/project-panel/project-panel.tsx @@ -16,7 +16,6 @@ import { ResourceKind } from '~/models/resource'; import { resourceLabel } from '~/common/labels'; import { ArvadosTheme } from '~/common/custom-theme'; import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner } from '~/views-components/data-explorer/renderers'; -import { restoreBranch, setProjectItem, ItemMode } from '~/store/navigation/navigation-action'; import { ProjectIcon } from '~/components/icon/icon'; import { ResourceName } from '~/views-components/data-explorer/renderers'; import { ResourcesState, getResource } from '~/store/resources/resources'; @@ -30,6 +29,8 @@ import { reset } from 'redux-form'; import { COLLECTION_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-collection-create'; import { collectionCreateActions } from '~/store/collections/creator/collection-creator-action'; import { navigateToResource } from '~/store/navigation/navigation-action'; +import { getProperty } from '~/store/properties/properties'; +import { PROJECT_PANEL_CURRENT_UUID } from '~/store/project-panel/project-panel-action'; type CssRules = 'root' | "toolbar" | "button"; @@ -61,7 +62,7 @@ export interface ProjectPanelFilter extends DataTableFilterItem { type: ResourceKind | ContainerRequestState; } -export const columns: DataColumns = [ +export const projectPanelColumns: DataColumns = [ { name: ProjectPanelColumnNames.NAME, selected: true, @@ -161,7 +162,10 @@ type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles & RouteComponentProps<{ id: string }>; export const ProjectPanel = withStyles(styles)( - connect((state: RootState) => ({ currentItemId: state.projects.currentItemId, resources: state.resources }))( + connect((state: RootState) => ({ + currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties), + resources: state.resources + }))( class extends React.Component { render() { const { classes } = this.props; @@ -179,7 +183,6 @@ export const ProjectPanel = withStyles(styles)(
(restoreBranch(this.props.match.params.id)); - this.props.dispatch(setProjectItem(this.props.match.params.id, ItemMode.BOTH)); - } - } } ) ); diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 2dda4d23..1c11e06a 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -14,14 +14,13 @@ import { Breadcrumb } from '~/components/breadcrumbs/breadcrumbs'; import { push } from 'react-router-redux'; import { TreeItem } from "~/components/tree/tree"; import { getTreePath } from '~/store/project/project-reducer'; -import { ItemMode, setProjectItem } from "~/store/navigation/navigation-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 { CreateProjectDialog } from "~/views-components/create-project-dialog/create-project-dialog"; import { detailsPanelActions, loadDetailsPanel } from "~/store/details-panel/details-panel-action"; import { openContextMenu } from '~/store/context-menu/context-menu-actions'; -import { ProjectResource } from '~/models/project'; +import { ProjectResource, getProjectUrl } from '~/models/project'; import { ContextMenu, ContextMenuKind } 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'; @@ -37,10 +36,12 @@ import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog import { DialogCollectionCreateWithSelectedFile } from '~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected'; import { UploadCollectionFilesDialog } from '~/views-components/upload-collection-files-dialog/upload-collection-files-dialog'; import { ProjectCopyDialog } from '~/views-components/project-copy-dialog/project-copy-dialog'; -import { CollectionPartialCopyDialog } from '../../views-components/collection-partial-copy-dialog/collection-partial-copy-dialog'; +import { CollectionPartialCopyDialog } from '~/views-components/collection-partial-copy-dialog/collection-partial-copy-dialog'; import { MoveProjectDialog } from '~/views-components/move-project-dialog/move-project-dialog'; import { MoveCollectionDialog } from '~/views-components/move-collection-dialog/move-collection-dialog'; -import { NavigationPanel } from '~/views-components/navigation-panel/navigation-panel'; +import { SidePanel } from '~/views-components/side-panel/side-panel'; +import { Routes } from '~/routes/routes'; +import { navigateToResource } from '../../store/navigation/navigation-action'; const APP_BAR_HEIGHT = 100; @@ -165,6 +166,8 @@ export const Workbench = withStyles(styles)( status: item.status })); + const rootProjectUuid = this.props.authService.getUuid(); + const { classes, user } = this.props; return (
@@ -177,14 +180,13 @@ export const Workbench = withStyles(styles)( buildInfo={this.props.buildInfo} {...this.mainAppBarActions} />
- {user && } + {user && }
- } /> - - - + + +
{user && } @@ -214,8 +216,7 @@ export const Workbench = withStyles(styles)( mainAppBarActions: MainAppBarActionProps = { onBreadcrumbClick: ({ itemId }: NavBreadcrumb) => { - this.props.dispatch(setProjectItem(itemId, ItemMode.BOTH)); - this.props.dispatch(loadDetailsPanel(itemId)); + this.props.dispatch(navigateToResource(itemId)); }, onSearch: searchText => { this.setState({ searchText }); -- 2.30.2