From 623f5deee7b203090fb71f028e01ed55e0f8a38c Mon Sep 17 00:00:00 2001 From: Peter Amstutz Date: Fri, 26 Feb 2021 17:29:55 -0500 Subject: [PATCH] 17426: Plugins can replace some of main UI Add example and utility plugins. Arvados-DCO-1.1-Signed-off-by: Peter Amstutz --- Makefile | 2 +- src/common/plugintypes.ts | 29 +++++++++++ src/plugins.tsx | 23 ++++++++ src/plugins/blank/index.tsx | 14 +++++ src/plugins/example/index.tsx | 52 +++++++++++++++++++ src/plugins/root-redirect/index.tsx | 20 +++++++ src/routes/route-change-handlers.ts | 9 +++- src/store/breadcrumbs/breadcrumbs-actions.ts | 2 +- src/store/navigation/navigation-action.ts | 30 +++++++++-- .../side-panel-tree-actions.ts | 13 +++-- src/store/side-panel/side-panel-action.ts | 36 +------------ src/store/workbench/workbench-actions.ts | 4 +- src/views/workbench/workbench.tsx | 9 ++-- 13 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 src/common/plugintypes.ts create mode 100644 src/plugins.tsx create mode 100644 src/plugins/blank/index.tsx create mode 100644 src/plugins/example/index.tsx create mode 100644 src/plugins/root-redirect/index.tsx diff --git a/Makefile b/Makefile index de88cd35..6cf9c29d 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ DESCRIPTION=Arvados Workbench2 - Arvados is a free and open source platform for MAINTAINER=Arvados Package Maintainers # DEST_DIR will have the build package copied. -DEST_DIR=/var/www/arvados-workbench2/workbench2/ +DEST_DIR=/var/www/$(APP_NAME)/workbench2/ # Debian package file DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb diff --git a/src/common/plugintypes.ts b/src/common/plugintypes.ts new file mode 100644 index 00000000..489568ea --- /dev/null +++ b/src/common/plugintypes.ts @@ -0,0 +1,29 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { Dispatch } from 'redux'; +import { RootStore, RootState } from '~/store/store'; + +export type RouteListReducer = (startingList: React.ReactElement[]) => React.ReactElement[]; +export type CategoriesListReducer = (startingList: string[]) => string[]; +export type NavigateMatcher = (dispatch: Dispatch, getState: () => RootState, uuid: string) => boolean; +export type LocationChangeMatcher = (store: RootStore, pathname: string) => boolean; + +export interface PluginConfig { + // Customize the list of possible center panels by adding or removing Route components. + centerPanelList: RouteListReducer[]; + + // Customize the list of side panel categories + sidePanelCategories: CategoriesListReducer[]; + + // Add to the list of possible dialogs by adding dialog components. + dialogs: React.ReactElement[]; + + // Add navigation actions for identifiers + navigateToHandlers: NavigateMatcher[]; + + // Add handlers for navigation actions + locationChangeHandlers: LocationChangeMatcher[]; +} diff --git a/src/plugins.tsx b/src/plugins.tsx new file mode 100644 index 00000000..fb52aade --- /dev/null +++ b/src/plugins.tsx @@ -0,0 +1,23 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { PluginConfig } from '~/common/plugintypes'; + +export const pluginConfig: PluginConfig = { + centerPanelList: [], + sidePanelCategories: [], + dialogs: [], + navigateToHandlers: [], + locationChangeHandlers: [] +}; + +// Starting here, import and register your Workbench 2 plugins. // + +// import { register as blankUIPluginRegister } from '~/plugins/blank/index'; +import { register as examplePluginRegister, routePath as exampleRoutePath } from '~/plugins/example/index'; +import { register as rootRedirectRegister } from '~/plugins/root-redirect/index'; + +blankUIPluginRegister(pluginConfig); +examplePluginRegister(pluginConfig); +rootRedirectRegister(pluginConfig, exampleRoutePath); diff --git a/src/plugins/blank/index.tsx b/src/plugins/blank/index.tsx new file mode 100644 index 00000000..416de42d --- /dev/null +++ b/src/plugins/blank/index.tsx @@ -0,0 +1,14 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +// Example plugin. + +import { PluginConfig } from '~/common/plugintypes'; + +export const register = (pluginConfig: PluginConfig) => { + + pluginConfig.centerPanelList.push((elms) => []); + + pluginConfig.sidePanelCategories.push((cats: string[]): string[] => []); +}; diff --git a/src/plugins/example/index.tsx b/src/plugins/example/index.tsx new file mode 100644 index 00000000..4fa98966 --- /dev/null +++ b/src/plugins/example/index.tsx @@ -0,0 +1,52 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +// Example plugin. + +import { PluginConfig } from '~/common/plugintypes'; +import * as React from 'react'; +import { Dispatch } from 'redux'; +import { RootState } from '~/store/store'; +import { push } from "react-router-redux"; +import { Typography } from "@material-ui/core"; +import { Route, matchPath } from "react-router"; +import { RootStore } from '~/store/store'; +import { activateSidePanelTreeItem } from '~/store/side-panel-tree/side-panel-tree-actions'; +import { setSidePanelBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions'; + +const categoryName = "Plugin Example"; +export const routePath = "/examplePlugin"; + +const ExamplePluginMainPanel = (props: {}) => { + return + This is a example main panel plugin. + ; +}; + +export const register = (pluginConfig: PluginConfig) => { + + pluginConfig.centerPanelList.push((elms) => { + elms.push(); + return elms; + }); + + pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => { + if (uuid === categoryName) { + dispatch(push(routePath)); + return true; + } + return false; + }); + + pluginConfig.sidePanelCategories.push((cats: string[]): string[] => { cats.push(categoryName); return cats; }); + + pluginConfig.locationChangeHandlers.push((store: RootStore, pathname: string): boolean => { + if (matchPath(pathname, { path: routePath, exact: true })) { + store.dispatch(activateSidePanelTreeItem(categoryName)); + store.dispatch(setSidePanelBreadcrumbs(categoryName)); + return true; + } + return false; + }); +}; diff --git a/src/plugins/root-redirect/index.tsx b/src/plugins/root-redirect/index.tsx new file mode 100644 index 00000000..13eeb942 --- /dev/null +++ b/src/plugins/root-redirect/index.tsx @@ -0,0 +1,20 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { PluginConfig } from '~/common/plugintypes'; +import { Dispatch } from 'redux'; +import { RootState } from '~/store/store'; +import { SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions'; +import { push } from "react-router-redux"; + +export const register = (pluginConfig: PluginConfig, redirect: string) => { + + pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => { + if (uuid === SidePanelTreeCategory.PROJECTS) { + dispatch(push(redirect)); + return true; + } + return false; + }); +}; diff --git a/src/routes/route-change-handlers.ts b/src/routes/route-change-handlers.ts index 400ddc88..8a66e420 100644 --- a/src/routes/route-change-handlers.ts +++ b/src/routes/route-change-handlers.ts @@ -10,6 +10,7 @@ import { navigateToRootProject } from '~/store/navigation/navigation-action'; import { dialogActions } from '~/store/dialog/dialog-actions'; import { contextMenuActions } from '~/store/context-menu/context-menu-actions'; import { searchBarActions } from '~/store/search-bar/search-bar-actions'; +import { pluginConfig } from '~/plugins'; export const addRouteChangeHandlers = (history: History, store: RootStore) => { const handler = handleLocationChange(store); @@ -53,6 +54,12 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { store.dispatch(contextMenuActions.CLOSE_CONTEXT_MENU()); store.dispatch(searchBarActions.CLOSE_SEARCH_VIEW()); + for (const locChangeFn of pluginConfig.locationChangeHandlers) { + if (locChangeFn(store, pathname)) { + return; + } + } + if (projectMatch) { store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id)); } else if (collectionMatch) { @@ -112,4 +119,4 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { } else if (allProcessesMatch) { store.dispatch(WorkbenchActions.loadAllProcesses()); } -}; \ No newline at end of file +}; diff --git a/src/store/breadcrumbs/breadcrumbs-actions.ts b/src/store/breadcrumbs/breadcrumbs-actions.ts index 8803cfba..b2857b69 100644 --- a/src/store/breadcrumbs/breadcrumbs-actions.ts +++ b/src/store/breadcrumbs/breadcrumbs-actions.ts @@ -65,7 +65,7 @@ export const setSharedWithMeBreadcrumbs = (uuid: string) => export const setTrashBreadcrumbs = (uuid: string) => setCategoryBreadcrumbs(uuid, SidePanelTreeCategory.TRASH); -export const setCategoryBreadcrumbs = (uuid: string, category: SidePanelTreeCategory) => +export const setCategoryBreadcrumbs = (uuid: string, category: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const ancestors = await services.ancestorsService.ancestors(uuid, ''); dispatch(updateResources(ancestors)); diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index d663ae37..7b55f897 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -10,9 +10,25 @@ import { Routes, getProcessLogUrl, getGroupUrl, getNavUrl } from '~/routes/route import { RootState } from '~/store/store'; import { ServiceRepository } from '~/services/services'; import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions'; +import { pluginConfig } from '~/plugins'; +import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions'; + +const navigationNotAvailable = (id: string) => + snackbarActions.OPEN_SNACKBAR({ + message: `${id} not available`, + hideDuration: 3000, + kind: SnackbarKind.ERROR + }); export const navigateTo = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => { + + for (const navToFn of pluginConfig.navigateToHandlers) { + if (navToFn(dispatch, getState, uuid)) { + return; + } + } + const kind = extractUuidKind(uuid); switch (kind) { case ResourceKind.PROJECT: @@ -27,6 +43,12 @@ export const navigateTo = (uuid: string) => } switch (uuid) { + case SidePanelTreeCategory.PROJECTS: + const usr = getState().auth.user; + if (usr) { + dispatch(pushOrGoto(getNavUrl(usr.uuid, getState().auth))); + } + return; case SidePanelTreeCategory.FAVORITES: dispatch(navigateToFavorites); return; @@ -49,8 +71,11 @@ export const navigateTo = (uuid: string) => dispatch(navigateToAllProcesses); return; } + + dispatch(navigationNotAvailable(uuid)); }; + export const navigateToNotFound = push(Routes.NO_MATCH); export const navigateToRoot = push(Routes.ROOT); @@ -78,10 +103,7 @@ export const pushOrGoto = (url: string): AnyAction => { export const navigateToProcessLogs = compose(push, getProcessLogUrl); export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const usr = getState().auth.user; - if (usr) { - dispatch(navigateTo(usr.uuid)); - } + navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState); }; export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME); diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts index d0043da2..dd0f5e68 100644 --- a/src/store/side-panel-tree/side-panel-tree-actions.ts +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -16,6 +16,8 @@ import { OrderBuilder } from '~/services/api/order-builder'; import { ResourceKind } from '~/models/resource'; import { GroupContentsResourcePrefix } from '~/services/groups-service/groups-service'; import { GroupClass } from '~/models/group'; +import { CategoriesListReducer } from '~/common/plugintypes'; +import { pluginConfig } from '~/plugins'; export enum SidePanelTreeCategory { PROJECTS = 'Projects', @@ -44,19 +46,24 @@ export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker) return []; }; -const SIDE_PANEL_CATEGORIES: string[] = [ +let SIDE_PANEL_CATEGORIES: string[] = [ SidePanelTreeCategory.PROJECTS, SidePanelTreeCategory.SHARED_WITH_ME, SidePanelTreeCategory.PUBLIC_FAVORITES, SidePanelTreeCategory.FAVORITES, SidePanelTreeCategory.WORKFLOWS, SidePanelTreeCategory.ALL_PROCESSES, - SidePanelTreeCategory.TRASH, - "Blibber blubber" + SidePanelTreeCategory.TRASH ]; +const reduceCatsFn: (a: string[], + b: CategoriesListReducer) => string[] = (a, b) => b(a); + +SIDE_PANEL_CATEGORIES = pluginConfig.sidePanelCategories.reduce(reduceCatsFn, SIDE_PANEL_CATEGORIES); + export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id); + export const initSidePanelTree = () => (dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => { const rootProjectUuid = getUserUuid(getState()); diff --git a/src/store/side-panel/side-panel-action.ts b/src/store/side-panel/side-panel-action.ts index 6279aaea..28320f96 100644 --- a/src/store/side-panel/side-panel-action.ts +++ b/src/store/side-panel/side-panel-action.ts @@ -3,41 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from 'redux'; -import { isSidePanelTreeCategory, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions'; -import { navigateToFavorites, navigateTo, navigateToTrash, navigateToSharedWithMe, navigateToWorkflows, navigateToPublicFavorites, navigateToAllProcesses } from '~/store/navigation/navigation-action'; -import {snackbarActions, SnackbarKind} from '~/store/snackbar/snackbar-actions'; +import { navigateTo } from '~/store/navigation/navigation-action'; export const navigateFromSidePanel = (id: string) => (dispatch: Dispatch) => { - if (isSidePanelTreeCategory(id)) { - dispatch(getSidePanelTreeCategoryAction(id)); - } else { - dispatch(navigateTo(id)); - } + dispatch(navigateTo(id)); }; - -const getSidePanelTreeCategoryAction = (id: string) => { - switch (id) { - case SidePanelTreeCategory.FAVORITES: - return navigateToFavorites; - case SidePanelTreeCategory.PUBLIC_FAVORITES: - return navigateToPublicFavorites; - case SidePanelTreeCategory.TRASH: - return navigateToTrash; - case SidePanelTreeCategory.SHARED_WITH_ME: - return navigateToSharedWithMe; - case SidePanelTreeCategory.WORKFLOWS: - return navigateToWorkflows; - case SidePanelTreeCategory.ALL_PROCESSES: - return navigateToAllProcesses; - default: - return sidePanelTreeCategoryNotAvailable(id); - } -}; - -const sidePanelTreeCategoryNotAvailable = (id: string) => - snackbarActions.OPEN_SNACKBAR({ - message: `${id} not available`, - hideDuration: 3000, - kind: SnackbarKind.ERROR - }); diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 09aad2bd..217c8524 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -33,7 +33,7 @@ import { setSidePanelBreadcrumbs, setTrashBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions'; -import { navigateTo } from '~/store/navigation/navigation-action'; +import { navigateTo, navigateToRootProject } from '~/store/navigation/navigation-action'; import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog'; import { ServiceRepository } from '~/services/services'; import { getResource } from '~/store/resources/resources'; @@ -153,7 +153,7 @@ export const loadWorkbench = () => if (router.location) { const match = matchRootRoute(router.location.pathname); if (match) { - dispatch(navigateTo(user.uuid)); + dispatch(navigateToRootProject); } } } else { diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index b1b071f1..113cbd67 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -101,7 +101,8 @@ import { NotFoundPanel } from '../not-found-panel/not-found-panel'; import { AutoLogout } from '~/views-components/auto-logout/auto-logout'; import { RestoreCollectionVersionDialog } from '~/views-components/collections-dialog/restore-version-dialog'; import { WebDavS3InfoDialog } from '~/views-components/webdav-s3-dialog/webdav-s3-dialog'; -import { pluginCenterPanelRoutes, RouteReducer, pluginDialogs } from '~/plugins'; +import { pluginConfig } from '~/plugins'; +import { RouteListReducer } from '~/common/plugintypes'; type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; @@ -182,9 +183,9 @@ let routes = <> ; const reduceRoutesFn: (a: React.ReactElement[], - b: RouteReducer) => React.ReactElement[] = (a, b) => b(a); + b: RouteListReducer) => React.ReactElement[] = (a, b) => b(a); -routes = React.createElement(React.Fragment, null, pluginCenterPanelRoutes.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))); +routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))); export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => @@ -274,6 +275,6 @@ export const WorkbenchPanel = - {pluginDialogs} + {React.createElement(React.Fragment, null, pluginConfig.dialogs)} ); -- 2.30.2