From: Peter Amstutz Date: Mon, 29 Mar 2021 20:42:08 +0000 (-0400) Subject: 17426: Add dialog box with form to example plugin. X-Git-Tag: 2.1.2.1~3^2~2 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/b05f087c3e2bbaaa829d602af6d90fa565a9900e?hp=160378cd58ef72daacfc5577e3c23ddbc35a5f7f 17426: Add dialog box with form to example plugin. Add comments explaining the example plugin Arvados-DCO-1.1-Signed-off-by: Peter Amstutz --- 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 index 2ce0bb12..a87b0a86 100644 --- a/src/common/plugintypes.ts +++ b/src/common/plugintypes.ts @@ -16,34 +16,163 @@ export type LocationChangeMatcher = (store: RootStore, pathname: string) => bool 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[]; } diff --git a/src/plugins.tsx b/src/plugins.tsx index 03175494..9962caef 100644 --- a/src/plugins.tsx +++ b/src/plugins.tsx @@ -22,11 +22,9 @@ export const pluginConfig: PluginConfig = { // 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); diff --git a/src/plugins/README.md b/src/plugins/README.md new file mode 100644 index 00000000..f95317c3 --- /dev/null +++ b/src/plugins/README.md @@ -0,0 +1,63 @@ +[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). 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 index 36647fac..f4bb27ba 100644 --- a/src/plugins/example/index.tsx +++ b/src/plugins/example/index.tsx @@ -2,71 +2,53 @@ // // 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) => - incrementPressedCount(dispatch, pressedCount)}>Example menu item -); - -const ExamplePluginMainPanel = connect(exampleMapStateToProps)( - ({ pressedCount }: ExampleProps) => - - - - This is a example main panel plugin. The example menu item has been pressed {pressedCount} times. - - - ); 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(); + 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)); @@ -75,8 +57,12 @@ export const register = (pluginConfig: PluginConfig) => { 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( @@ -89,5 +75,11 @@ export const register = (pluginConfig: PluginConfig) => { 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(); };