From: Peter Amstutz Date: Mon, 5 Apr 2021 14:32:46 +0000 (-0400) Subject: Merge branch '17426-plug-ins' refs #17426 X-Git-Tag: 2.1.2.1~3 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/31e84a9315728c2f58a26bf0e9e1d2b38326fb86?hp=5385afcada8666051658c6889c83848702497759 Merge branch '17426-plug-ins' refs #17426 Arvados-DCO-1.1-Signed-off-by: Peter Amstutz --- 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/README.md b/README.md index 55e96af3..f6a7e485 100644 --- a/README.md +++ b/README.md @@ -2,59 +2,60 @@ [comment]: # () [comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0) -## Arvados Workbench 2 +# Arvados Workbench 2 -### Setup -
-brew install yarn
+## Setup
+```
+npm install yarn
 yarn install
-
+``` + Install [redux-devtools-extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) -### Start project -yarn start +## Start project for development +``` +yarn start +``` -### Run unit tests -
+## Run unit tests
+```
 make unit-tests
-
+``` -### Run end-to-end tests +## Run end-to-end tests -
+```
 make integration-tests
-
+``` -### Run end-to-end tests in a Docker container +## Run end-to-end tests in a Docker container -
+```
 make integration-tests-in-docker
-
+``` -### Run tests interactively in container +## Run tests interactively in container -
+```
 $ xhost +local:root
 $ ARVADOS_DIR=/path/to/arvados
 $ docker run -ti -v$PWD:$PWD -v$ARVADOS_DIR:/usr/src/arvados -w$PWD --env="DISPLAY" --volume="/tmp/.X11-unix:/tmp/.X11-unix:rw" workbench2-build /bin/bash
 (inside container)
 # yarn run cypress install
 # tools/run-integration-tests.sh -i -a /usr/src/arvados
-
+``` -### Production build -
-yarn install
+## Production build
+```
 yarn build
-
+``` -### Package build -
-make workbench2-build-image
-docker run -v$PWD:$PWD -w $PWD arvados/fpm make packages
-
+## Package build +``` +make packages +``` -### Build time configuration +## Build time configuration You can customize project global variables using env variables. Default values are placed in the `.env` file. Example: @@ -62,8 +63,9 @@ Example: REACT_APP_ARVADOS_CONFIG_URL=config.json yarn build ``` -### Run time configuration -The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`. You can customize this url using build time configuration. +## Run time configuration +The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`. In development mode, this can be found in the `public` directory. +You can customize this url using build time configuration. Currently this configuration schema is supported: ``` @@ -74,19 +76,19 @@ Currently this configuration schema is supported: } ``` -#### API_HOST +### API_HOST The Arvados base URL. The `REACT_APP_ARVADOS_API_HOST` environment variable can be used to set the default URL if the run time configuration is unreachable. -#### VOCABULARY_URL +### VOCABULARY_URL Local path, or any URL that allows cross-origin requests. See [Vocabulary JSON file example](public/vocabulary-example.json). To use the URL defined in the Arvados cluster configuration, remove the entire `VOCABULARY_URL` entry from the runtime configuration. Found in `/config.json` by default. -### FILE_VIEWERS_CONFIG_URL +## FILE_VIEWERS_CONFIG_URL Local path, or any URL that allows cross-origin requests. See: [File viewers config file example](public/file-viewers-example.json) @@ -95,7 +97,14 @@ Local path, or any URL that allows cross-origin requests. See: To use the URL defined in the Arvados cluster configuration, remove the entire `FILE_VIEWERS_CONFIG_URL` entry from the runtime configuration. Found in `/config.json` by default. -### Licensing +## Plugin support + +Workbench supports plugins to add new functionality to the user +interface. For information about installing plugins, the provided +example plugins, see [src/plugins/README.md](src/plugins/README.md). + + +## Licensing Arvados is Free Software. See COPYING for information about Arvados Free Software licenses. diff --git a/src/common/plugintypes.ts b/src/common/plugintypes.ts new file mode 100644 index 00000000..a87b0a86 --- /dev/null +++ b/src/common/plugintypes.ts @@ -0,0 +1,178 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { Dispatch, Middleware } from 'redux'; +import { RootStore, RootState } from '~/store/store'; +import { ResourcesState } from '~/store/resources/resources'; +import { Location } from 'history'; +import { ServiceRepository } from "~/services/services"; + +export type ElementListReducer = (startingList: React.ReactElement[], itemClass?: string) => 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 type EnableNew = (location: Location, currentItemId: string, currentUserUUID: string | undefined, resources: ResourcesState) => boolean; +export type MiddlewareListReducer = (startingList: Middleware[], services: ServiceRepository) => Middleware[]; + +/* Workbench Plugin API + + Code to your plugin should go into a subdirectory of '~/plugins'. + + Your plugin should implement a "register" function, which will be + called with an object with the PluginConfig interface described + below. The register function may make in-place modifications to + the pluginConfig object, but to preserve composability, it is + strongly advised this should be limited to push()ing new values + onto the various lists of hooks. + + To enable a plugin, edit 'plugins.tsx', import the register + function exported by the plugin, and add a call to the register + function following the examples in the comments. Then, build a new + Workbench package that includes the plugin. + + Be aware that because plugins heavily leverage workbench, and in + fact must be compiled together, they are considered "derived works" + and so _must_ be license-compatible with AGPL-3.0. + + */ + +export interface PluginConfig { + + /* During initialization, each + * function in the callback list will be called with the list of + * react - router "Route" components that will be used select what should + * be displayed in the central panel based on the navigation bar. + * + * The callback function may add, edit, or remove items from this list, + * and return a new list of components, which will be passed to the next + * function in `centerPanelList`. + * + * The hooks are applied in `views/workbench/workbench.tsx`. + * */ + centerPanelList: ElementListReducer[]; + + /* During initialization, each + * function in the callback list will be called with the list of strings + * that are the top-level categories in the left hand navigation tree. + * + * The callback function may add, edit, or remove items from this list, + * and return a new list of strings, which will be passed to the next + * function in `sidePanelCategories`. + * + * The hooks are applied in `store/side-panel-tree/side-panel-tree-actions.ts`. + * */ + sidePanelCategories: CategoriesListReducer[]; + + /* This is a list of additional dialog box components. + * Dialogs are components that are wrapped using the "withDialog()" method. + * + * These are added to the list in `views/workbench/workbench.tsx`. + * */ + dialogs: React.ReactElement[]; + + /* This is a list of additional navigation matchers. + * These are callbacks that are called by the navigateTo(uuid) method to + * set the path in the navigation bar to display the desired resource. + * Each handler should return "true" if the uuid was handled and "false or "undefined" if not. + * + * These are used in `store/navigation/navigation-action.tsx`. + * */ + navigateToHandlers: NavigateMatcher[]; + + /* This is a list of additional location change matchers. + * These are callbacks called when the URL in the navigation bar changes + * (this could be in response to "navigateTo()" or due to the user + * entering/changing the URL directly). + * + * The Route components in centerPanelList should + * automatically change in response to navigation. The + * purpose of these handlers is trigger additional loading, + * such as fetching the object contents that will be + * displayed. + * + * Each handler should return "true" if the path was handled and "false or "undefined" if not. + * + * These are used in `routes/route-change-handlers.ts`. + */ + locationChangeHandlers: LocationChangeMatcher[]; + + /* Replace the left side of the app bar. Normally, this displays + * the site banner. + * + * Note: unlike most of the other hooks, this is not composable. + * This completely replaces that section of the app bar. Multiple + * plugins setting this value will conflict. + * + * Used in 'views-components/main-app-bar/main-app-bar.tsx' + */ + appBarLeft?: React.ReactElement; + + /* Replace the middle part of the app bar. Normally, this displays + * the search bar. + * + * Note: unlike most of the other hooks, this is not composable. + * This completely replaces that section of the app bar. Multiple + * plugins setting this value will conflict. + * + * Used in 'views-components/main-app-bar/main-app-bar.tsx' + */ + appBarMiddle?: React.ReactElement; + + /* Replace the right part of the app bar. Normally, this displays + * the admin menu and help menu. + * (Note: the user menu can be customized separately using accountMenuList) + * + * Note: unlike most of the other hooks, this is not composable. + * This completely replaces that section of the app bar. Multiple + * plugins setting this value will conflict. + * + * Used in 'views-components/main-app-bar/main-app-bar.tsx' + */ + appBarRight?: React.ReactElement; + + /* During initialization, each + * function in the callback list will be called with the menu items that + * will appear in the "user account" menu. + * + * The callback function may add, edit, or remove items from this list, + * and return a new list of menu items, which will be passed to the next + * function in `accountMenuList`. + * + * The hooks are applied in 'views-components/main-app-bar/account-menu.tsx'. + * */ + accountMenuList: ElementListReducer[]; + + /* Each function in this list is called to determine if the the "NEW" button + * should be enabled or disabled. If any function returns "true", the button + * (and corresponding drop-down menu) will be enabled. + * + * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'. + * */ + enableNewButtonMatchers: EnableNew[]; + + /* During initialization, each + * function in the callback list will be called with the menu items that + * will appear in the "NEW" dropdown menu. + * + * The callback function may add, edit, or remove items from this list, + * and return a new list of menu items, which will be passed to the next + * function in `newButtonMenuList`. + * + * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'. + * */ + newButtonMenuList: ElementListReducer[]; + + /* Add Middlewares to the Redux store. + * + * Middlewares intercept redux actions before they get to the reducer, and + * may produce side effects. For example, the REQUEST_ITEMS action is intercepted by a middleware to + * trigger a load of data table contents. + * + * https://redux.js.org/tutorials/fundamentals/part-4-store#middleware + * + * Used in 'store/store.ts' + * */ + middlewares: MiddlewareListReducer[]; +} diff --git a/src/components/form-dialog/form-dialog.tsx b/src/components/form-dialog/form-dialog.tsx index 8c847ca4..6970a38a 100644 --- a/src/components/form-dialog/form-dialog.tsx +++ b/src/components/form-dialog/form-dialog.tsx @@ -42,6 +42,7 @@ interface DialogProjectDataProps { dialogTitle: string; formFields: React.ComponentType & WithDialogProps>; submitLabel?: string; + enableWhenPristine?: boolean; } type DialogProjectProps = DialogProjectDataProps & WithDialogProps<{}> & InjectedFormProps & WithStyles; @@ -76,7 +77,7 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) => onClick={props.handleSubmit} className={props.classes.lastButton} color="primary" - disabled={props.invalid || props.submitting || props.pristine} + disabled={props.invalid || props.submitting || (props.pristine && !props.enableWhenPristine)} variant="contained"> {props.submitLabel || 'Submit'} {props.submitting && } @@ -85,5 +86,3 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) => ); - - diff --git a/src/index.tsx b/src/index.tsx index 522d8dc1..43cfb5fb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -122,7 +122,8 @@ fetchConfig() ? error.errors[0] : error.message}`, kind: SnackbarKind.ERROR, - hideDuration: 8000}) + hideDuration: 8000 + }) ); } } diff --git a/src/models/link.ts b/src/models/link.ts index 785d531c..1c82fe58 100644 --- a/src/models/link.ts +++ b/src/models/link.ts @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { TagProperty } from "~/models/tag"; import { Resource, ResourceKind } from '~/models/resource'; export interface LinkResource extends Resource { @@ -12,7 +11,7 @@ export interface LinkResource extends Resource { tailKind: string; linkClass: string; name: string; - properties: TagProperty; + properties: any; kind: ResourceKind.LINK; } @@ -21,4 +20,4 @@ export enum LinkClass { TAG = 'tag', PERMISSION = 'permission', PRESET = 'preset', -} \ No newline at end of file +} diff --git a/src/plugins.tsx b/src/plugins.tsx new file mode 100644 index 00000000..73811e5a --- /dev/null +++ b/src/plugins.tsx @@ -0,0 +1,30 @@ +// 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: [], + appBarLeft: undefined, + appBarMiddle: undefined, + appBarRight: undefined, + accountMenuList: [], + enableNewButtonMatchers: [], + newButtonMenuList: [], + middlewares: [] +}; + +// Starting here, import and register your Workbench 2 plugins. // + +// import { register as blankUIPluginRegister } from '~/plugins/blank/index'; +// import { register as examplePluginRegister } 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/README.md b/src/plugins/README.md new file mode 100644 index 00000000..931590cb --- /dev/null +++ b/src/plugins/README.md @@ -0,0 +1,65 @@ +[comment]: # (Copyright © The Arvados Authors. All rights reserved.) +[comment]: # () +[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0) + +# Plugin support + +Workbench supports plugins to add new functionality to the user +interface. It is also possible to remove the majority of standard UI +elements and replace them with your own, enabling you to use workbench +as a basis for developing essentially new applications for Arvados. + +## Installing plugins + +1. Check out the source of your plugin into a directory under `arvados-workbench2/src/plugins` + +2. Register the plugin by editing `arvados-workbench2/src/plugins/plugins.tsx`. +It will look something like this: + +``` +import { register as examplePluginRegister } from '~/plugins/example/index'; +examplePluginRegister(pluginConfig); +``` + +3. Rebuild Workbench 2 + +For testing/development: `yarn start` + +For production: `APP_NAME=arvados-workbench2-with-custom-plugins make packages` + +Set `APP_NAME=` to whatever you like, but it is important to name it +differently from the standard `arvados-workbench2` to avoid confusion. + +## Existing plugins + +### example + +This is an example plugin showing how to add a new navigation tree +item, displaying a new center panel, as well as adding account menu +and "New" menu items, and showing how to use SET_PROPERTY and +getProperty() for state. + +### blank + +This deletes all of the existing user interface. If you want the +application to only display your plugin's UI elements and none of the +standard elements, you would load and register this first. + +### root-redirect + +This helper takes a path when registered. It tweaks the navigation +behavior so that the default starting location when the application +loads will be the path you provide, instead of "Projects". + +### sample-tracker + +This is a a new set of user interface screens that assist with +clinical sample tracking and analysis. It is intended as a demo of +how a real-world application can built using the Workbench 2 +plug-in interface. It can be found at +https://github.com/arvados/sample-tracker . + +## Developing plugins + +For information about the plugin API, see +[../common/plugintypes.ts](src/common/plugintypes.ts). diff --git a/src/plugins/blank/index.tsx b/src/plugins/blank/index.tsx new file mode 100644 index 00000000..0074c02a --- /dev/null +++ b/src/plugins/blank/index.tsx @@ -0,0 +1,22 @@ +// 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'; + +export const register = (pluginConfig: PluginConfig) => { + + pluginConfig.centerPanelList.push((elms) => []); + + pluginConfig.sidePanelCategories.push((cats: string[]): string[] => []); + + pluginConfig.accountMenuList.push((elms) => []); + pluginConfig.newButtonMenuList.push((elms) => []); + + pluginConfig.appBarLeft = ; + pluginConfig.appBarMiddle = ; + pluginConfig.appBarRight = ; +}; diff --git a/src/plugins/example/exampleComponents.tsx b/src/plugins/example/exampleComponents.tsx new file mode 100644 index 00000000..de2be4e1 --- /dev/null +++ b/src/plugins/example/exampleComponents.tsx @@ -0,0 +1,131 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { WithDialogProps } from '~/store/dialog/with-dialog'; +import { ServiceRepository } from "~/services/services"; +import { Dispatch } from "redux"; +import { RootState } from '~/store/store'; +import { initialize } from 'redux-form'; +import { dialogActions } from "~/store/dialog/dialog-actions"; +import { reduxForm, InjectedFormProps, Field, reset, startSubmit } from 'redux-form'; +import { TextField } from "~/components/text-field/text-field"; +import { FormDialog } from '~/components/form-dialog/form-dialog'; +import { withDialog } from "~/store/dialog/with-dialog"; +import { compose } from "redux"; +import { propertiesActions } from "~/store/properties/properties-actions"; +import { DispatchProp, connect } from 'react-redux'; +import { MenuItem } from "@material-ui/core"; +import { Card, CardContent, Typography } from "@material-ui/core"; + +// This is the name of the dialog box. It in store actions that +// open/close the dialog box. +export const EXAMPLE_DIALOG_FORM_NAME = "exampleFormName"; + +// This is the name of the property that will be used to store the +// "pressed" count +export const propertyKey = "Example_menu_item_pressed_count"; + +// The model backing the form. +export interface ExampleFormDialogData { + pressedCount: number | string; // Supposed to start as a number but TextField seems to turn this into a string, unfortunately. +} + +// The actual component with the editing fields. Enables editing +// the 'pressedCount' field. +const ExampleEditFields = () => + +; + +// Callback for when the form is submitted. +const submitEditedPressedCount = (data: ExampleFormDialogData) => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(startSubmit(EXAMPLE_DIALOG_FORM_NAME)); + dispatch(propertiesActions.SET_PROPERTY({ + key: propertyKey, value: parseInt(data.pressedCount as string, 10) + })); + dispatch(dialogActions.CLOSE_DIALOG({ id: EXAMPLE_DIALOG_FORM_NAME })); + dispatch(reset(EXAMPLE_DIALOG_FORM_NAME)); + }; + +// Props for the dialog component +type DialogExampleProps = WithDialogProps<{ updating: boolean }> & InjectedFormProps; + +// This is the component that renders the dialog. +const DialogExample = (props: DialogExampleProps) => + ; + +// This ties it all together, withDialog() determines if the dialog is +// visible based on state, and reduxForm manages the values of the +// dialog's fields. +export const ExampleDialog = compose( + withDialog(EXAMPLE_DIALOG_FORM_NAME), + reduxForm({ + form: EXAMPLE_DIALOG_FORM_NAME, + onSubmit: (data, dispatch) => { + dispatch(submitEditedPressedCount(data)); + } + }) +)(DialogExample); + + +// Callback, dispatches an action to set the value of property +// "Example_menu_item_pressed_count" +const incrementPressedCount = (dispatch: Dispatch, pressedCount: number) => { + dispatch(propertiesActions.SET_PROPERTY({ key: propertyKey, value: pressedCount + 1 })); +}; + +// Callback, dispatches actions required to initialize and open the +// dialog box. +export const openExampleDialog = (pressedCount: number) => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(initialize(EXAMPLE_DIALOG_FORM_NAME, { pressedCount })); + dispatch(dialogActions.OPEN_DIALOG({ + id: EXAMPLE_DIALOG_FORM_NAME, data: {} + })); + }; + +// Props definition used for menu items. +interface ExampleProps { + pressedCount: number; + className?: string; +} + +// Called to get the props from the redux state for several of the +// following components. +// Gets the value of the property "Example_menu_item_pressed_count" +// from the state and puts it in 'pressedCount' +const exampleMapStateToProps = (state: RootState) => ({ pressedCount: state.properties[propertyKey] || 0 }); + +// Define component for the menu item that incremens the count each time it is pressed. +export const ExampleMenuComponent = connect(exampleMapStateToProps)( + ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp) => + incrementPressedCount(dispatch, pressedCount)}>Example menu item +); + +// Define component for the menu item that opens the dialog box that lets you edit the count directly. +export const ExampleDialogMenuComponent = connect(exampleMapStateToProps)( + ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp) => + dispatch(openExampleDialog(pressedCount))}>Open example dialog +); + +// The central panel. Displays the "pressed" count. +export const ExamplePluginMainPanel = connect(exampleMapStateToProps)( + ({ pressedCount }: ExampleProps) => + + + + This is a example main panel plugin. The example menu item has been pressed {pressedCount} times. + + + ); diff --git a/src/plugins/example/index.tsx b/src/plugins/example/index.tsx new file mode 100644 index 00000000..f4bb27ba --- /dev/null +++ b/src/plugins/example/index.tsx @@ -0,0 +1,85 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +// Example workbench plugin. The entry point is the "register" method. + +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 { 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'; +import { Location } from 'history'; +import { handleFirstTimeLoad } from '~/store/workbench/workbench-actions'; +import { + ExampleDialog, + ExamplePluginMainPanel, + ExampleMenuComponent, + ExampleDialogMenuComponent +} from './exampleComponents'; + +const categoryName = "Plugin Example"; +export const routePath = "/examplePlugin"; + +export const register = (pluginConfig: PluginConfig) => { + + // Add this component to the main panel. When the app navigates + // to '/examplePlugin' it will render ExamplePluginMainPanel. + pluginConfig.centerPanelList.push((elms) => { + elms.push(); + return elms; + }); + + // Add ExampleDialogMenuComponent to the upper-right user account menu + pluginConfig.accountMenuList.push((elms, menuItemClass) => { + elms.push(); + return elms; + }); + + // Add ExampleMenuComponent to the "New" button dropdown. + pluginConfig.newButtonMenuList.push((elms, menuItemClass) => { + elms.push(); + return elms; + }); + + // Add a hook so that when the 'Plugin Example' entry in the left + // hand tree view is clicked, which calls navigateTo('Plugin Example'), + // it will be implemented by navigating to '/examplePlugin' + pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => { + if (uuid === categoryName) { + dispatch(push(routePath)); + return true; + } + return false; + }); + + // Adds 'Plugin Example' to the left hand tree view. + pluginConfig.sidePanelCategories.push((cats: string[]): string[] => { cats.push(categoryName); return cats; }); + + // When the location changes to '/examplePlugin', make sure + // 'Plugin Example' in the left hand tree view is selected, and + // make sure the breadcrumbs are updated. + pluginConfig.locationChangeHandlers.push((store: RootStore, pathname: string): boolean => { + if (matchPath(pathname, { path: routePath, exact: true })) { + store.dispatch(handleFirstTimeLoad( + (dispatch: Dispatch) => { + dispatch(activateSidePanelTreeItem(categoryName)); + dispatch(setSidePanelBreadcrumbs(categoryName)); + })); + return true; + } + return false; + }); + + // The "New" button can enabled or disabled based on the current + // context or selection. This adds a new callback to that will + // enable the "New" button when the location is '/examplePlugin' + pluginConfig.enableNewButtonMatchers.push((location: Location) => (!!matchPath(location.pathname, { path: routePath, exact: true }))); + + // Add the example dialog box to the list of dialog box controls. + pluginConfig.dialogs.push(); +}; 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/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 83335f83..1997b2a6 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -34,7 +34,7 @@ export type ContextMenuResource = { ownerUuid: string; description?: string; kind: ResourceKind, - menuKind: ContextMenuKind; + menuKind: ContextMenuKind | string; isTrashed?: boolean; isEditable?: boolean; outputUuid?: string; @@ -167,7 +167,7 @@ export const openProjectContextMenu = (event: React.MouseEvent, res kind: res.kind, menuKind, ownerUuid: res.ownerUuid, - isTrashed: ('isTrashed' in res) ? res.isTrashed: false, + isTrashed: ('isTrashed' in res) ? res.isTrashed : false, })); } }; 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 05d61927..6152b99f 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,34 +46,48 @@ export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker) return []; }; -const SIDE_PANEL_CATEGORIES = [ +let SIDE_PANEL_CATEGORIES: string[] = [ + SidePanelTreeCategory.PROJECTS, + SidePanelTreeCategory.SHARED_WITH_ME, SidePanelTreeCategory.PUBLIC_FAVORITES, SidePanelTreeCategory.FAVORITES, SidePanelTreeCategory.WORKFLOWS, SidePanelTreeCategory.ALL_PROCESSES, - SidePanelTreeCategory.TRASH, + 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()); if (!rootProjectUuid) { return; } - const nodes = SIDE_PANEL_CATEGORIES.map(id => initTreeNode({ id, value: id })); - const projectsNode = initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS }); - const sharedNode = initTreeNode({ id: SidePanelTreeCategory.SHARED_WITH_ME, value: SidePanelTreeCategory.SHARED_WITH_ME }); + const nodes = SIDE_PANEL_CATEGORIES.map(id => { + if (id === SidePanelTreeCategory.PROJECTS) { + return initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS }); + } else { + return initTreeNode({ id, value: id }); + } + }); dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', pickerId: SIDE_PANEL_TREE, - nodes: [projectsNode, sharedNode, ...nodes] + nodes })); SIDE_PANEL_CATEGORIES.forEach(category => { - dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ - id: category, - pickerId: SIDE_PANEL_TREE, - nodes: [] - })); + if (category !== SidePanelTreeCategory.PROJECTS && category !== SidePanelTreeCategory.SHARED_WITH_ME) { + dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ + id: category, + pickerId: SIDE_PANEL_TREE, + nodes: [] + })); + } }); }; 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/store.ts b/src/store/store.ts index 517368aa..f236d029 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -70,6 +70,8 @@ import { SubprocessMiddlewareService } from '~/store/subprocess-panel/subprocess import { SUBPROCESS_PANEL_ID } from '~/store/subprocess-panel/subprocess-panel-actions'; import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action'; import { Config } from '~/common/config'; +import { pluginConfig } from '~/plugins'; +import { MiddlewareListReducer } from '~/common/plugintypes'; const composeEnhancers = (process.env.NODE_ENV === 'development' && @@ -142,7 +144,7 @@ export function configureStore(history: History, services: ServiceRepository, co return next(action); }; - const middlewares: Middleware[] = [ + let middlewares: Middleware[] = [ routerMiddleware(history), thunkMiddleware.withExtraArgument(services), authMiddleware(services), @@ -164,6 +166,11 @@ export function configureStore(history: History, services: ServiceRepository, co subprocessMiddleware, ]; + const reduceMiddlewaresFn: (a: Middleware[], + b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services); + + middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares); + const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares)); return createStore(rootReducer, enhancer); } diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 09aad2bd..8ea19a14 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'; @@ -110,7 +110,7 @@ export const isWorkbenchLoading = (state: RootState) => { return progress ? progress.working : false; }; -const handleFirstTimeLoad = (action: any) => +export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch, getState: () => RootState) => { try { await dispatch(action); @@ -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-components/form-fields/project-form-fields.tsx b/src/views-components/form-fields/project-form-fields.tsx index dc1e1612..3f576ab1 100644 --- a/src/views-components/form-fields/project-form-fields.tsx +++ b/src/views-components/form-fields/project-form-fields.tsx @@ -11,6 +11,7 @@ import { RootState } from "~/store/store"; interface ProjectNameFieldProps { validate: Validator[]; + label?: string; } // Validation behavior depends on the value of ForwardSlashNameSubstitution. @@ -32,7 +33,7 @@ export const ProjectNameField = connect( name='name' component={TextField} validate={props.validate} - label="Project Name" + label={props.label || "Project Name"} autoFocus={true} /> ); diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx index ea3a2dd9..7892b8a7 100644 --- a/src/views-components/main-app-bar/account-menu.tsx +++ b/src/views-components/main-app-bar/account-menu.tsx @@ -20,6 +20,8 @@ import { navigateToLinkAccount } from '~/store/navigation/navigation-action'; import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions"; +import { pluginConfig } from '~/plugins'; +import { ElementListReducer } from '~/common/plugintypes'; interface AccountMenuProps { user?: User; @@ -57,38 +59,47 @@ const styles: StyleRulesCallback = () => ({ }); export const AccountMenuComponent = - ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp & WithStyles) => - user - ? } - id="account-menu" - title="Account Management" - key={currentRoute}> - - {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`} - - {user.isActive ? <> - dispatch(openUserVirtualMachines())}>Virtual Machines - {!user.isAdmin && dispatch(openRepositoriesPanel())}>Repositories} - { - dispatch(getNewExtraToken(true)); - dispatch(openTokenDialog); - }}>Get API token - dispatch(navigateToSshKeysUser)}>Ssh Keys - dispatch(navigateToSiteManager)}>Site Manager - dispatch(navigateToMyAccount)}>My account - dispatch(navigateToLinkAccount)}>Link account - : null} + ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp & WithStyles) => { + let accountMenuItems = <> + dispatch(openUserVirtualMachines())}>Virtual Machines + dispatch(openRepositoriesPanel())}>Repositories + { + dispatch(getNewExtraToken(true)); + dispatch(openTokenDialog); + }}>Get API token + dispatch(navigateToSshKeysUser)}>Ssh Keys + dispatch(navigateToSiteManager)}>Site Manager + dispatch(navigateToMyAccount)}>My account + dispatch(navigateToLinkAccount)}>Link account Switch to Workbench v1 - - dispatch(authActions.LOGOUT({deleteLinkData: true}))}> - Logout - - - : null; + ; -export const AccountMenu = withStyles(styles)( connect(mapStateToProps)(AccountMenuComponent) ); + const reduceItemsFn: (a: React.ReactElement[], + b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a); + + accountMenuItems = React.createElement(React.Fragment, null, + pluginConfig.accountMenuList.reduce(reduceItemsFn, React.Children.toArray(accountMenuItems.props.children))); + + return user + ? } + id="account-menu" + title="Account Management" + key={currentRoute}> + + {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`} + + {user.isActive && accountMenuItems} + + dispatch(authActions.LOGOUT({ deleteLinkData: true }))}> + Logout + + + : null; + }; + +export const AccountMenu = withStyles(styles)(connect(mapStateToProps)(AccountMenuComponent)); 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 ce1cab4c..44cbe20d 100644 --- a/src/views-components/main-app-bar/main-app-bar.tsx +++ b/src/views-components/main-app-bar/main-app-bar.tsx @@ -14,6 +14,7 @@ import { AccountMenu } from "~/views-components/main-app-bar/account-menu"; import { HelpMenu } from '~/views-components/main-app-bar/help-menu'; import { ReactNode } from "react"; import { AdminMenu } from "~/views-components/main-app-bar/admin-menu"; +import { pluginConfig } from '~/plugins'; type CssRules = 'toolbar' | 'link'; @@ -42,20 +43,20 @@ export const MainAppBar = withStyles(styles)( return - + {pluginConfig.appBarLeft || ({props.uuidPrefix}) - + {props.buildInfo} - + } - {props.user && props.user.isActive && } + {pluginConfig.appBarMiddle || (props.user && props.user.isActive && )} - {props.user - ? <> - - - {props.user.isAdmin && } - - - : } + {props.user ? <> + + + {pluginConfig.appBarRight || + <> + {props.user.isAdmin && } + + } + : + pluginConfig.appBarRight || + } diff --git a/src/views-components/main-content-bar/main-content-bar.tsx b/src/views-components/main-content-bar/main-content-bar.tsx index cad73a3a..60adab66 100644 --- a/src/views-components/main-content-bar/main-content-bar.tsx +++ b/src/views-components/main-content-bar/main-content-bar.tsx @@ -30,13 +30,27 @@ interface MainContentBarProps { const isButtonVisible = ({ router }: RootState) => { const pathname = router.location ? router.location.pathname : ''; - return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) && - !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) && - !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) && - !Routes.matchSiteManagerRoute(pathname) && - !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) && - !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) && - !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname); + return Routes.matchCollectionsContentAddressRoute(pathname) || + Routes.matchPublicFavoritesRoute(pathname) || + Routes.matchGroupDetailsRoute(pathname) || + Routes.matchGroupsRoute(pathname) || + Routes.matchUsersRoute(pathname) || + Routes.matchSearchResultsRoute(pathname) || + Routes.matchSharedWithMeRoute(pathname) || + Routes.matchProcessRoute(pathname) || + Routes.matchCollectionRoute(pathname) || + Routes.matchProjectRoute(pathname) || + Routes.matchAllProcessesRoute(pathname) || + Routes.matchTrashRoute(pathname) || + Routes.matchFavoritesRoute(pathname); + + /* return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) && + * !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) && + * !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) && + * !Routes.matchSiteManagerRoute(pathname) && + * !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) && + * !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) && + * !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname); */ }; export const MainContentBar = diff --git a/src/views-components/project-properties-dialog/project-properties-dialog.tsx b/src/views-components/project-properties-dialog/project-properties-dialog.tsx index e1874d95..c2982b3d 100644 --- a/src/views-components/project-properties-dialog/project-properties-dialog.tsx +++ b/src/views-components/project-properties-dialog/project-properties-dialog.tsx @@ -40,42 +40,42 @@ const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionPr handleDelete: (key: string, value: string) => () => dispatch(deleteProjectProperty(key, value)), }); -type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles; +type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles; export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToProps)( withStyles(styles)( - withDialog(PROJECT_PROPERTIES_DIALOG_NAME)( - ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) => - - Properties - - - {project && project.properties && - Object.keys(project.properties).map(k => - Array.isArray(project.properties[k]) - ? project.properties[k].map((v: string) => - getPropertyChip( - k, v, - handleDelete(k, v), - classes.tag)) - : getPropertyChip( - k, project.properties[k], - handleDelete(k, project.properties[k]), - classes.tag) - ) - } - - - - - - ) -)); \ No newline at end of file + + + ) + )); diff --git a/src/views-components/side-panel-button/side-panel-button.tsx b/src/views-components/side-panel-button/side-panel-button.tsx index bf03bf6c..fb5ea11f 100644 --- a/src/views-components/side-panel-button/side-panel-button.tsx +++ b/src/views-components/side-panel-button/side-panel-button.tsx @@ -18,6 +18,9 @@ import { matchProjectRoute } from '~/routes/routes'; import { GroupClass, GroupResource } from '~/models/group'; import { ResourcesState, getResource } from '~/store/resources/resources'; import { extractUuidKind, ResourceKind } from '~/models/resource'; +import { pluginConfig } from '~/plugins'; +import { ElementListReducer } from '~/common/plugintypes'; +import { Location } from 'history'; type CssRules = 'button' | 'menuItem' | 'icon'; @@ -37,7 +40,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ }); interface SidePanelDataProps { - location: any; + location: Location; currentItemId: string; resources: ResourcesState; currentUserUUID: string | undefined; @@ -92,6 +95,31 @@ export const SidePanelButton = withStyles(styles)( enabled = true; } } + + for (const enableFn of pluginConfig.enableNewButtonMatchers) { + if (enableFn(location, currentItemId, currentUserUUID, resources)) { + enabled = true; + } + } + + let menuItems = <> + + New collection + + + Run a process + + + New project + + ; + + const reduceItemsFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] = + (a, b) => b(a, classes.menuItem); + + menuItems = React.createElement(React.Fragment, null, + pluginConfig.newButtonMenuList.reduce(reduceItemsFn, React.Children.toArray(menuItems.props.children))); + return @@ -110,15 +138,7 @@ export const SidePanelButton = withStyles(styles)( onClose={this.handleClose} onClick={this.handleClose} transformOrigin={transformOrigin}> - - New collection - - - Run a process - - - New project - + {menuItems} diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 9c2a7df8..78ec3c87 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -101,6 +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 { pluginConfig } from '~/plugins'; +import { ElementListReducer } from '~/common/plugintypes'; type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; @@ -149,6 +151,42 @@ const getSplitterInitialSize = () => { const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString()); +let routes = <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + +; + +const reduceRoutesFn: (a: React.ReactElement[], + b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a); + +routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))); + export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => @@ -167,34 +205,7 @@ export const WorkbenchPanel = - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {routes} @@ -264,5 +275,6 @@ export const WorkbenchPanel = + {React.createElement(React.Fragment, null, pluginConfig.dialogs)} );