[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.
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 {
- // Customize the list of possible center panels by adding or removing Route components.
+
+ /* 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[];
- // Customize the list of side panel categories
+ /* 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[];
- // Add to the list of possible dialogs by adding dialog components.
+ /* 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[];
- // Add navigation actions for identifiers
+ /* 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[];
- // Add handlers for navigation actions
+ /* 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;
- // Customize the list menu items in the account menu
+ /* 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[];
}
// Starting here, import and register your Workbench 2 plugins. //
// import { register as blankUIPluginRegister } from '~/plugins/blank/index';
-// import { register as sampleTrackerPluginRegister } from '~/plugins/sample-tracker/index';
-// import { studyListRoutePath } from '~/plugins/sample-tracker/studyList';
-// import { register as examplePluginRegister, routePath as exampleRoutePath } from '~/plugins/example/index';
+import { register as examplePluginRegister } from '~/plugins/example/index';
// import { register as rootRedirectRegister } from '~/plugins/root-redirect/index';
// blankUIPluginRegister(pluginConfig);
-// sampleTrackerPluginRegister(pluginConfig);
-// rootRedirectRegister(pluginConfig, studyListRoutePath);
+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 can be found at
+
+
+
+## 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
+
+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>);
//
// SPDX-License-Identifier: AGPL-3.0
-// Example plugin.
+// 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 { Card, CardContent, Typography } from "@material-ui/core";
import { Route, matchPath } from "react-router";
import { RootStore } from '~/store/store';
import { activateSidePanelTreeItem } from '~/store/side-panel-tree/side-panel-tree-actions';
import { setSidePanelBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
-import { DispatchProp, connect } from 'react-redux';
-import { MenuItem } from "@material-ui/core";
-import { propertiesActions } from '~/store/properties/properties-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";
-const propertyKey = "Example_menu_item_pressed_count";
-
-interface ExampleProps {
- pressedCount: number;
- className?: string;
-}
-
-const exampleMapStateToProps = (state: RootState) => ({ pressedCount: state.properties[propertyKey] || 0 });
-
-const incrementPressedCount = (dispatch: Dispatch, pressedCount: number) => {
- dispatch(propertiesActions.SET_PROPERTY({ key: propertyKey, value: pressedCount + 1 }));
-};
-
-const ExampleMenuComponent = connect(exampleMapStateToProps)(
- ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
- <MenuItem className={className} onClick={() => incrementPressedCount(dispatch, pressedCount)}>Example menu item</MenuItem >
-);
-
-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>);
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(<ExampleMenuComponent className={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 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(
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 />);
};