MAINTAINER=Arvados Package Maintainers <packaging@arvados.org>
# 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
[comment]: # ()
[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
-## Arvados Workbench 2
+# Arvados Workbench 2
-### Setup
-<pre>
-brew install yarn
+## Setup
+```
+npm install yarn
yarn install
-</pre>
+```
+
Install [redux-devtools-extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd)
-### Start project
-<code>yarn start</code>
+## Start project for development
+```
+yarn start
+```
-### Run unit tests
-<pre>
+## Run unit tests
+```
make unit-tests
-</pre>
+```
-### Run end-to-end tests
+## Run end-to-end tests
-<pre>
+```
make integration-tests
-</pre>
+```
-### Run end-to-end tests in a Docker container
+## Run end-to-end tests in a Docker container
-<pre>
+```
make integration-tests-in-docker
-</pre>
+```
-### Run tests interactively in container
+## Run tests interactively in container
-<pre>
+```
$ 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
-</pre>
+```
-### Production build
-<pre>
-yarn install
+## Production build
+```
yarn build
-</pre>
+```
-### Package build
-<pre>
-make workbench2-build-image
-docker run -v$PWD:$PWD -w $PWD arvados/fpm make packages
-</pre>
+## 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:
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:
```
}
```
-#### 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)
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.
--- /dev/null
+// 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[];
+}
dialogTitle: string;
formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
submitLabel?: string;
+ enableWhenPristine?: boolean;
}
type DialogProjectProps = DialogProjectDataProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>;
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 && <CircularProgress size={20} className={props.classes.progressIndicator} />}
</form>
</Dialog>
);
-
-
? error.errors[0]
: error.message}`,
kind: SnackbarKind.ERROR,
- hideDuration: 8000})
+ hideDuration: 8000
+ })
);
}
}
//
// SPDX-License-Identifier: AGPL-3.0
-import { TagProperty } from "~/models/tag";
import { Resource, ResourceKind } from '~/models/resource';
export interface LinkResource extends Resource {
tailKind: string;
linkClass: string;
name: string;
- properties: TagProperty;
+ properties: any;
kind: ResourceKind.LINK;
}
TAG = 'tag',
PERMISSION = 'permission',
PRESET = 'preset',
-}
\ No newline at end of file
+}
--- /dev/null
+// 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);
--- /dev/null
+[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).
--- /dev/null
+// 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 = <span />;
+ pluginConfig.appBarMiddle = <span />;
+ pluginConfig.appBarRight = <span />;
+};
--- /dev/null
+// 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 = () => <span>
+ <Field
+ name='pressedCount'
+ component={TextField}
+ type="number"
+ />
+</span>;
+
+// 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<ExampleFormDialogData>;
+
+// This is the component that renders the dialog.
+const DialogExample = (props: DialogExampleProps) =>
+ <FormDialog
+ dialogTitle="Edit pressed count"
+ formFields={ExampleEditFields}
+ submitLabel="Update pressed count"
+ {...props}
+ />;
+
+// 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<ExampleFormDialogData>({
+ 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<any>) =>
+ <MenuItem className={className} onClick={() => incrementPressedCount(dispatch, pressedCount)}>Example menu item</MenuItem >
+);
+
+// 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<any>) =>
+ <MenuItem className={className} onClick={() => dispatch(openExampleDialog(pressedCount))}>Open example dialog</MenuItem >
+);
+
+// The central panel. Displays the "pressed" count.
+export const ExamplePluginMainPanel = connect(exampleMapStateToProps)(
+ ({ pressedCount }: ExampleProps) =>
+ <Card>
+ <CardContent>
+ <Typography>
+ This is a example main panel plugin. The example menu item has been pressed {pressedCount} times.
+ </Typography>
+ </CardContent>
+ </Card>);
--- /dev/null
+// 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(<Route path={routePath} component={ExamplePluginMainPanel} />);
+ return elms;
+ });
+
+ // Add ExampleDialogMenuComponent to the upper-right user account menu
+ pluginConfig.accountMenuList.push((elms, menuItemClass) => {
+ elms.push(<ExampleDialogMenuComponent className={menuItemClass} />);
+ return elms;
+ });
+
+ // Add ExampleMenuComponent to the "New" button dropdown.
+ pluginConfig.newButtonMenuList.push((elms, menuItemClass) => {
+ elms.push(<ExampleMenuComponent className={menuItemClass} />);
+ 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<any>(activateSidePanelTreeItem(categoryName));
+ dispatch<any>(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(<ExampleDialog />);
+};
--- /dev/null
+// 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;
+ });
+};
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);
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) {
} else if (allProcessesMatch) {
store.dispatch(WorkbenchActions.loadAllProcesses());
}
-};
\ No newline at end of file
+};
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));
ownerUuid: string;
description?: string;
kind: ResourceKind,
- menuKind: ContextMenuKind;
+ menuKind: ContextMenuKind | string;
isTrashed?: boolean;
isEditable?: boolean;
outputUuid?: string;
kind: res.kind,
menuKind,
ownerUuid: res.ownerUuid,
- isTrashed: ('isTrashed' in res) ? res.isTrashed: false,
+ isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
}));
}
};
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:
}
switch (uuid) {
+ case SidePanelTreeCategory.PROJECTS:
+ const usr = getState().auth.user;
+ if (usr) {
+ dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
+ }
+ return;
case SidePanelTreeCategory.FAVORITES:
dispatch<any>(navigateToFavorites);
return;
dispatch(navigateToAllProcesses);
return;
}
+
+ dispatch(navigationNotAvailable(uuid));
};
+
export const navigateToNotFound = push(Routes.NO_MATCH);
export const navigateToRoot = push(Routes.ROOT);
export const navigateToProcessLogs = compose(push, getProcessLogUrl);
export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
- const usr = getState().auth.user;
- if (usr) {
- dispatch<any>(navigateTo(usr.uuid));
- }
+ navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
};
export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME);
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',
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: []
+ }));
+ }
});
};
// 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<any>(getSidePanelTreeCategoryAction(id));
- } else {
- dispatch<any>(navigateTo(id));
- }
+ dispatch<any>(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
- });
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' &&
return next(action);
};
- const middlewares: Middleware[] = [
+ let middlewares: Middleware[] = [
routerMiddleware(history),
thunkMiddleware.withExtraArgument(services),
authMiddleware(services),
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);
}
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';
return progress ? progress.working : false;
};
-const handleFirstTimeLoad = (action: any) =>
+export const handleFirstTimeLoad = (action: any) =>
async (dispatch: Dispatch<any>, getState: () => RootState) => {
try {
await dispatch(action);
if (router.location) {
const match = matchRootRoute(router.location.pathname);
if (match) {
- dispatch<any>(navigateTo(user.uuid));
+ dispatch<any>(navigateToRootProject);
}
}
} else {
interface ProjectNameFieldProps {
validate: Validator[];
+ label?: string;
}
// Validation behavior depends on the value of ForwardSlashNameSubstitution.
name='name'
component={TextField}
validate={props.validate}
- label="Project Name"
+ label={props.label || "Project Name"}
autoFocus={true} /></span>
);
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;
});
export const AccountMenuComponent =
- ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) =>
- user
- ? <DropdownMenu
- icon={<UserPanelIcon />}
- id="account-menu"
- title="Account Management"
- key={currentRoute}>
- <MenuItem disabled>
- {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
- </MenuItem>
- {user.isActive ? <>
- <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
- {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
- <MenuItem onClick={() => {
- dispatch<any>(getNewExtraToken(true));
- dispatch(openTokenDialog);
- }}>Get API token</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
- </> : null}
+ ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) => {
+ let accountMenuItems = <>
+ <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
+ <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+ <MenuItem onClick={() => {
+ dispatch<any>(getNewExtraToken(true));
+ dispatch(openTokenDialog);
+ }}>Get API token</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
<MenuItem>
<a href={`${workbenchURL.replace(/\/$/, "")}/${wb1URL(currentRoute)}?api_token=${apiToken}`}
className={classes.link}>
Switch to Workbench v1</a></MenuItem>
- <Divider />
- <MenuItem data-cy="logout-menuitem"
- onClick={() => dispatch(authActions.LOGOUT({deleteLinkData: true}))}>
- Logout
- </MenuItem>
- </DropdownMenu>
- : 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
+ ? <DropdownMenu
+ icon={<UserPanelIcon />}
+ id="account-menu"
+ title="Account Management"
+ key={currentRoute}>
+ <MenuItem disabled>
+ {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
+ </MenuItem>
+ {user.isActive && accountMenuItems}
+ <Divider />
+ <MenuItem data-cy="logout-menuitem"
+ onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true }))}>
+ Logout
+ </MenuItem>
+ </DropdownMenu>
+ : null;
+ };
+
+export const AccountMenu = withStyles(styles)(connect(mapStateToProps)(AccountMenuComponent));
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';
return <AppBar position="absolute">
<Toolbar className={props.classes.toolbar}>
<Grid container justify="space-between">
- <Grid container item xs={3} direction="column" justify="center">
+ {pluginConfig.appBarLeft || <Grid container item xs={3} direction="column" justify="center">
<Typography variant='h6' color="inherit" noWrap>
<Link to={Routes.ROOT} className={props.classes.link}>
<span dangerouslySetInnerHTML={{ __html: props.siteBanner }} /> ({props.uuidPrefix})
- </Link>
+ </Link>
</Typography>
<Typography variant="caption" color="inherit">{props.buildInfo}</Typography>
- </Grid>
+ </Grid>}
<Grid
item
xs={6}
container
alignItems="center">
- {props.user && props.user.isActive && <SearchBar />}
+ {pluginConfig.appBarMiddle || (props.user && props.user.isActive && <SearchBar />)}
</Grid>
<Grid
item
alignItems="center"
justify="flex-end"
wrap="nowrap">
- {props.user
- ? <>
- <NotificationsMenu />
- <AccountMenu />
- {props.user.isAdmin && <AdminMenu />}
- <HelpMenu />
- </>
- : <HelpMenu />}
+ {props.user ? <>
+ <NotificationsMenu />
+ <AccountMenu />
+ {pluginConfig.appBarRight ||
+ <>
+ {props.user.isAdmin && <AdminMenu />}
+ <HelpMenu />
+ </>}
+ </> :
+ pluginConfig.appBarRight || <HelpMenu />
+ }
</Grid>
</Grid>
</Toolbar>
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 =
handleDelete: (key: string, value: string) => () => dispatch<any>(deleteProjectProperty(key, value)),
});
-type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
+type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToProps)(
withStyles(styles)(
- withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
- ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
- <Dialog open={open}
- onClose={closeDialog}
- fullWidth
- maxWidth='sm'>
- <DialogTitle>Properties</DialogTitle>
- <DialogContent>
- <ProjectPropertiesForm />
- {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)
- )
- }
- </DialogContent>
- <DialogActions>
- <Button
- variant='text'
- color='primary'
- onClick={closeDialog}>
- Close
+ withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
+ ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
+ <Dialog open={open}
+ onClose={closeDialog}
+ fullWidth
+ maxWidth='sm'>
+ <DialogTitle>Properties</DialogTitle>
+ <DialogContent>
+ <ProjectPropertiesForm />
+ {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)
+ )
+ }
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='text'
+ color='primary'
+ onClick={closeDialog}>
+ Close
</Button>
- </DialogActions>
- </Dialog>
- )
-));
\ No newline at end of file
+ </DialogActions>
+ </Dialog>
+ )
+ ));
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';
});
interface SidePanelDataProps {
- location: any;
+ location: Location;
currentItemId: string;
resources: ResourcesState;
currentUserUUID: string | undefined;
enabled = true;
}
}
+
+ for (const enableFn of pluginConfig.enableNewButtonMatchers) {
+ if (enableFn(location, currentItemId, currentUserUUID, resources)) {
+ enabled = true;
+ }
+ }
+
+ let menuItems = <>
+ <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
+ <CollectionIcon className={classes.icon} /> New collection
+ </MenuItem>
+ <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
+ <ProcessIcon className={classes.icon} /> Run a process
+ </MenuItem>
+ <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
+ <ProjectIcon className={classes.icon} /> New project
+ </MenuItem>
+ </>;
+
+ 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 <Toolbar>
<Grid container>
<Grid container item xs alignItems="center" justify="flex-start">
onClose={this.handleClose}
onClick={this.handleClose}
transformOrigin={transformOrigin}>
- <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
- <CollectionIcon className={classes.icon} /> New collection
- </MenuItem>
- <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
- <ProcessIcon className={classes.icon} /> Run a process
- </MenuItem>
- <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
- <ProjectIcon className={classes.icon} /> New project
- </MenuItem>
+ {menuItems}
</Menu>
</Grid>
</Grid>
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';
const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString());
+let routes = <>
+ <Route path={Routes.PROJECTS} component={ProjectPanel} />
+ <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
+ <Route path={Routes.FAVORITES} component={FavoritePanel} />
+ <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
+ <Route path={Routes.PROCESSES} component={ProcessPanel} />
+ <Route path={Routes.TRASH} component={TrashPanel} />
+ <Route path={Routes.PROCESS_LOGS} component={ProcessLogPanel} />
+ <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
+ <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
+ <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
+ <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
+ <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
+ <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
+ <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
+ <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
+ <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyPanel} />
+ <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
+ <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
+ <Route path={Routes.USERS} component={UserPanel} />
+ <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
+ <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
+ <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
+ <Route path={Routes.GROUPS} component={GroupsPanel} />
+ <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
+ <Route path={Routes.LINKS} component={LinkPanel} />
+ <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
+ <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
+ <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
+</>;
+
+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) =>
<Grid container item xs className={props.classes.root}>
</Grid>
<Grid item xs className={props.classes.content}>
<Switch>
- <Route path={Routes.PROJECTS} component={ProjectPanel} />
- <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
- <Route path={Routes.FAVORITES} component={FavoritePanel} />
- <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
- <Route path={Routes.PROCESSES} component={ProcessPanel} />
- <Route path={Routes.TRASH} component={TrashPanel} />
- <Route path={Routes.PROCESS_LOGS} component={ProcessLogPanel} />
- <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
- <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
- <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
- <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
- <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
- <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
- <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
- <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
- <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyPanel} />
- <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
- <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
- <Route path={Routes.USERS} component={UserPanel} />
- <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
- <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
- <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
- <Route path={Routes.GROUPS} component={GroupsPanel} />
- <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
- <Route path={Routes.LINKS} component={LinkPanel} />
- <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
- <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
- <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
+ {routes}
<Route path={Routes.NO_MATCH} component={NotFoundPanel} />
</Switch>
</Grid>
<VirtualMachineAttributesDialog />
<FedLogin />
<WebDavS3InfoDialog />
+ {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
</Grid>
);