From f1913ae08aec7bcd85c2496a7c2218b6d78849ff Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sun, 9 May 2021 13:36:38 -0400 Subject: [PATCH] Add user tours in Advanced Settings (#17) * add user tours * fix settings package name * unify types, split steps/options * refactor into mutiple plugins * revert some signature changes * update tests for multiple plugins * update react-joyride to 2.3, fix some typing issues * refactor new user stuff into separate manager, expose token * typos * rename ExtendedPlacement to StepPlacement * Update src/userTourManager.ts * add cli docs * fix rejection --- .gitignore | 3 + README.md | 116 ++++- package.json | 8 +- schema/user-tours.json | 856 +++++++++++++++++++++++++++++++++++ src/__tests__/plugin.spec.ts | 159 +++++-- src/constants.ts | 10 +- src/plugin.tsx | 81 +++- src/tokens.ts | 65 ++- src/tour.ts | 5 +- src/tourManager.ts | 19 +- src/userTourManager.ts | 105 +++++ yarn.lock | 11 +- 12 files changed, 1346 insertions(+), 92 deletions(-) create mode 100644 schema/user-tours.json create mode 100644 src/userTourManager.ts diff --git a/.gitignore b/.gitignore index 671a060..7afcd26 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,6 @@ dmypy.json # OSX files .DS_Store coverage/ + +# logs +*.log diff --git a/README.md b/README.md index 4511582..5cbbe04 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,26 @@ A JupyterLab UI Tour based on [react-joyride](https://docs.react-joyride.com). ![demo](https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-tour/master/doc/tourDemo.gif) +## Install + +```bash +pip install jupyterlab-tour +``` + +or + +```bash +conda install -c conda-forge jupyterlab-tour +``` + +## Features + This extension has the following features: - Default tours: - Welcome tour - Notebook tour + - User-defined features in Settings - Toast proposing to start a tour - to experienced users the need to exit each time the tour. - If a tour has already be seen by the user, this is saved in the state database. So you can start tour on event only if the user have not seen it; e.g. the welcome tour is launched at JupyterLab start except if the user have seen it. @@ -36,9 +51,70 @@ For JupyterLab 2.x, have look [there](https://github.com/jupyterlab-contrib/jupy > For developers, the API has changed between v3 (for JupyterLab 3) and v2 (for JupyterLab 2). +## How to add a tour with Advanced Settings + +As a user of JupyterLab, after you've installed `jupyterlab-tour`, you can create +your own _Tours_ as data. + +- Open the JupyterLab _Advanced Settings_ panel Ctrl+, +- Select _Tours_ from list of settings groups +- In the editor, create JSON(5) compatible with the + [react-joyride data model](https://docs.react-joyride.com/props) +- The _Tour_ will be available from the _Help Menu_, as well as the _Command Palette_ + +### A simple Tour + +For example, to show a glowing button on the Jupyter logo, which reveals an orange +overlay when pressed: + +```json5 +// json5 can have comments +{ + "tours": [ + { + "id": "my-tour", + "label": "My First Tour", + // steps are required, and have many, many options + "steps": [ + {"target": "#jp-MainLogo", "content": "Look at this!"} + ], + // below here not required! + "options": { + "styles": { + "options": { + // you can use jupyterlab theme variables + "backgroundColor": "var(--jp-warn-color0)" + } + } + } + } + ] +} +``` + +### Shipping a Tour to Binder + +On Binder, and elsewhere, you can store the above (_without_ comments) in +an [overrides.json] file and put it in the _right place_, +e.g. `{sys.prefix}/share/jupyter/lab/settings/overrides.json`. When JupyterLab is +next opened, those overrides will become the defaults, and your tour will be available. + +An example `overrides.json` might look like: +```json5 +{ + "jupyterlab-tour:user-tours": { + "tours": [ + // that tour up there! + ] + } +} +``` + +[overrides.json]: https://jupyterlab.readthedocs.io/en/stable/user/directories.html#overrides-json + ## How to add tour for my JupyterLab extension -There are two methods to add a tour: the easiest is to use JupyterLab commands and the advanced version is to request this +As an extension developer, there are two methods to add a tour: the easiest is to use JupyterLab commands and the advanced version is to request this extension token `ITourManager`. ### Add easily a tour @@ -49,7 +125,7 @@ const { commands } = app; const tour = (await app.commands.execute('jupyterlab-tour:add', { tour: { // Tour must be of type ITour - see src/tokens.ts id: 'test-jupyterlab-tour:welcome', - label: 'Welcome Tour', + label: 'Welcome Tour', hasHelpEntry: true, steps: [ // Step must be of type IStep - see src/tokens.ts { @@ -66,7 +142,8 @@ const tour = (await app.commands.execute('jupyterlab-tour:add', { target: '#jp-main-dock-panel', title: 'Main Content' } - ] + ], + // can also define `options` } })) as ITour; if ( tour ) { @@ -80,18 +157,41 @@ if ( tour ) { > One example is available on [Mamba navigator](https://github.com/mamba-org/gator/blob/master/packages/labextension/src/index.ts#L76). > Test it on [binder](https://mybinder.org/v2/gh/mamba-org/gator/master?urlpath=lab). -## Install +## Disabling the User and Default Tours + +If you _only_ wish to see the default _Welcome_ and _Notebook_ tours, or ones +defined by users, they can be disabled via the command line or a well-known file. + +The examples below disable all tours not provided by third-party extensions. +Adding `jupyterlab-tour:plugin` to either of these will disable tours altogether! + +### Disabling Tours from the Command Line + +From the command line, run: ```bash -pip install jupyterlab-tour +jupyter labextension disable "jupyterlab-tour:user-tours" +jupyter labextension disable "jupyterlab-tour:default-tours" ``` -or +### Disabling Tours with `pageConfig.json` -```bash -conda install -c conda-forge jupyterlab-tour +Create a [pageConfig.json] and put it in _the right place_, e.g. +`{sys.prefix}/etc/jupyter/labconfig/pageconfig.json` and add the plugin IDs to +`disabledExtensions`. + +```json +{ + "disabledExtensions": { + "jupyterlab-tour:user-tours": true, + "jupyterlab-tour:default-tours": true, + } +} ``` +[pageConfig.json]: https://jupyterlab.readthedocs.io/en/stable/user/directories.html#labconfig-directories + + ## Uninstall ```bash diff --git a/package.json b/package.json index a2bad13..c3004cb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "files": [ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", - "style/index.js" + "style/index.js", + "schema/*.json" ], "main": "lib/index.js", "types": "lib/index.d.ts", @@ -49,6 +50,7 @@ "@jupyterlab/apputils": "^3.0.0", "@jupyterlab/mainmenu": "^3.0.0", "@jupyterlab/notebook": "^3.0.0", + "@jupyterlab/settingregistry": "^3.0.0", "@jupyterlab/statedb": "^3.0.0", "@lumino/coreutils": "^1.5.3", "@lumino/disposable": "^1.4.3", @@ -57,7 +59,7 @@ "jupyterlab_toastify": "^4.1.2", "react": "^17.0.1", "react-dom": "^17.0.1", - "react-joyride": "^2.2.1" + "react-joyride": "^2.3.0" }, "devDependencies": { "@babel/core": "^7.11.0", @@ -67,7 +69,6 @@ "@types/jest": "^26.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@types/react-joyride": "^2.0.5", "@typescript-eslint/eslint-plugin": "^2.27.0", "@typescript-eslint/parser": "^2.27.0", "eslint": "^7.5.0", @@ -89,6 +90,7 @@ ], "jupyterlab": { "extension": true, + "schemaDir": "schema", "outputDir": "jupyterlab_tour/labextension", "sharedPackages": { "jupyterlab_toastify": { diff --git a/schema/user-tours.json b/schema/user-tours.json new file mode 100644 index 0000000..cf9ad2b --- /dev/null +++ b/schema/user-tours.json @@ -0,0 +1,856 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "description": "Configuration for user-defined tours. This schema is generated from https://github.com/gilbarbara/react-joyride/blob/master/types/index.d.ts", + "jupyter.lab.setting-icon-label": "Tours", + "title": "Tours", + "type": "object", + "properties": { + "tours": { + "type": "array", + "description": "An array of a tours. Each requires an `id`, `label` and `steps[]`, and may have `options`, see https://docs.react-joyride.com", + "default": [], + "items": { + "type": "object", + "required": ["id", "label", "steps"], + "properties": { + "id": { + "type": "string", + "description": "A machine-readble ID to identify this tour, will be prefixed. Should be unique within this document." + }, + "label": { + "type": "string", + "description": "A human-readable name for the tour" + }, + "hasHelpEntry": { + "type": "boolean", + "description": "Whether to add a Help Menu item with the label to launch the tour", + "default": true + }, + "steps": { + "description": "The definition of the steps of a tour", + "type": "array", + "items": { + "$ref": "#/definitions/Step" + } + }, + "options": { + "description": "Other options for the tour", + "$ref": "#/definitions/Props" + } + } + } + } + }, + "definitions": { + "BeaconRenderProps": { + "additionalProperties": false, + "properties": { + "continuous": { + "type": "boolean" + }, + "index": { + "type": "number" + }, + "isLastStep": { + "type": "boolean" + }, + "size": { + "type": "number" + }, + "step": { + "$ref": "#/definitions/Step" + } + }, + "required": ["continuous", "index", "isLastStep", "size", "step"], + "type": "object" + }, + "CallBackProps": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "controlled": { + "type": "boolean" + }, + "index": { + "type": "number" + }, + "lifecycle": { + "type": "string" + }, + "size": { + "type": "number" + }, + "status": { + "$ref": "#/definitions/valueof%3Cstatus%3E" + }, + "step": { + "$ref": "#/definitions/Step" + }, + "type": { + "type": "string" + } + }, + "required": [ + "action", + "controlled", + "index", + "lifecycle", + "size", + "status", + "step", + "type" + ], + "type": "object" + }, + "CommonProps": { + "additionalProperties": false, + "properties": { + "beaconComponent": { + "type": "string" + }, + "disableCloseOnEsc": { + "type": "boolean" + }, + "disableOverlay": { + "type": "boolean" + }, + "disableOverlayClose": { + "type": "boolean" + }, + "disableScrollParentFix": { + "type": "boolean" + }, + "disableScrolling": { + "type": "boolean" + }, + "floaterProps": { + "$ref": "#/definitions/FloaterProps" + }, + "hideBackButton": { + "type": "boolean" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "showProgress": { + "type": "boolean" + }, + "showSkipButton": { + "type": "boolean" + }, + "spotlightClicks": { + "type": "boolean" + }, + "spotlightPadding": { + "type": "number" + }, + "styles": { + "$ref": "#/definitions/Styles" + }, + "tooltipComponent": { + "type": "string" + } + }, + "type": "object" + }, + "FloaterProps": { + "additionalProperties": false, + "properties": { + "disableAnimation": { + "type": "boolean" + }, + "options": { + "$ref": "#/definitions/GenericObject" + }, + "styles": { + "$ref": "#/definitions/GenericObject" + }, + "wrapperOptions": { + "$ref": "#/definitions/GenericObject" + } + }, + "type": "object" + }, + "GenericObject": { + "type": "object" + }, + "Locale": { + "additionalProperties": false, + "properties": { + "back": { + "type": "string" + }, + "close": { + "type": "string" + }, + "last": { + "type": "string" + }, + "next": { + "type": "string" + }, + "open": { + "type": "string" + }, + "skip": { + "type": "string" + } + }, + "type": "object" + }, + "Placement": { + "enum": [ + "top", + "top-start", + "top-end", + "bottom", + "bottom-start", + "bottom-end", + "left", + "left-start", + "left-end", + "right", + "right-start", + "right-end", + "auto", + "center" + ], + "type": "string" + }, + "PlacementBeacon": { + "enum": ["top", "bottom", "left", "right"], + "type": "string" + }, + "Props": { + "additionalProperties": false, + "properties": { + "beaconComponent": { + "type": "string" + }, + "continuous": { + "type": "boolean" + }, + "debug": { + "type": "boolean" + }, + "disableCloseOnEsc": { + "type": "boolean" + }, + "disableOverlay": { + "type": "boolean" + }, + "disableOverlayClose": { + "type": "boolean" + }, + "disableScrollParentFix": { + "type": "boolean" + }, + "disableScrolling": { + "type": "boolean" + }, + "floaterProps": { + "$ref": "#/definitions/FloaterProps" + }, + "hideBackButton": { + "type": "boolean" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "run": { + "type": "boolean" + }, + "scrollOffset": { + "type": "number" + }, + "scrollToFirstStep": { + "type": "boolean" + }, + "showProgress": { + "type": "boolean" + }, + "showSkipButton": { + "type": "boolean" + }, + "spotlightClicks": { + "type": "boolean" + }, + "spotlightPadding": { + "type": "number" + }, + "stepIndex": { + "type": "number" + }, + "steps": { + "items": { + "$ref": "#/definitions/Step" + }, + "type": "array" + }, + "styles": { + "$ref": "#/definitions/Styles" + }, + "tooltipComponent": { + "type": "string" + } + }, + "type": "object" + }, + "Step": { + "additionalProperties": false, + "properties": { + "beaconComponent": { + "type": "string" + }, + "content": { + "type": "string" + }, + "disableBeacon": { + "type": "boolean" + }, + "disableCloseOnEsc": { + "type": "boolean" + }, + "disableOverlay": { + "type": "boolean" + }, + "disableOverlayClose": { + "type": "boolean" + }, + "disableScrollParentFix": { + "type": "boolean" + }, + "disableScrolling": { + "type": "boolean" + }, + "event": { + "type": "string" + }, + "floaterProps": { + "$ref": "#/definitions/FloaterProps" + }, + "hideBackButton": { + "type": "boolean" + }, + "hideCloseButton": { + "type": "boolean" + }, + "hideFooter": { + "type": "boolean" + }, + "isFixed": { + "type": "boolean" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "offset": { + "type": "number" + }, + "placement": { + "$ref": "#/definitions/Placement" + }, + "placementBeacon": { + "$ref": "#/definitions/PlacementBeacon" + }, + "showProgress": { + "type": "boolean" + }, + "showSkipButton": { + "type": "boolean" + }, + "spotlightClicks": { + "type": "boolean" + }, + "spotlightPadding": { + "type": "number" + }, + "styles": { + "$ref": "#/definitions/Styles" + }, + "target": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tooltipComponent": { + "type": "string" + } + }, + "required": ["content", "target"], + "type": "object" + }, + "StoreHelpers": { + "additionalProperties": false, + "type": "object" + }, + "StoreState": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "controlled": { + "type": "boolean" + }, + "index": { + "type": "number" + }, + "lifecycle": { + "type": "string" + }, + "size": { + "type": "number" + }, + "status": { + "type": "string" + } + }, + "required": [ + "action", + "controlled", + "index", + "lifecycle", + "size", + "status" + ], + "type": "object" + }, + "Styles": { + "additionalProperties": false, + "properties": { + "beacon": { + "type": "object" + }, + "beaconInner": { + "type": "object" + }, + "beaconOuter": { + "type": "object" + }, + "buttonBack": { + "type": "object" + }, + "buttonClose": { + "type": "object" + }, + "buttonNext": { + "type": "object" + }, + "buttonSkip": { + "type": "object" + }, + "options": { + "additionalProperties": false, + "properties": { + "arrowColor": { + "type": "string" + }, + "backgroundColor": { + "type": "string" + }, + "beaconSize": { + "type": "number" + }, + "overlayColor": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "spotlightShadow": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "width": { + "type": ["string", "number"] + }, + "zIndex": { + "type": "number" + } + }, + "type": "object" + }, + "overlay": { + "type": "object" + }, + "overlayLegacy": { + "type": "object" + }, + "overlayLegacyCenter": { + "type": "object" + }, + "spotlight": { + "type": "object" + }, + "spotlightLegacy": { + "type": "object" + }, + "tooltip": { + "type": "object" + }, + "tooltipContainer": { + "type": "object" + }, + "tooltipContent": { + "type": "object" + }, + "tooltipFooter": { + "type": "object" + }, + "tooltipFooterSpacer": { + "type": "object" + }, + "tooltipTitle": { + "type": "object" + } + }, + "type": "object" + }, + "TooltipRenderProps": { + "additionalProperties": false, + "properties": { + "backProps": { + "additionalProperties": false, + "properties": { + "aria-label": { + "type": "string" + }, + "data-action": { + "type": "string" + }, + "role": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["aria-label", "data-action", "role", "title"], + "type": "object" + }, + "closeProps": { + "additionalProperties": false, + "properties": { + "aria-label": { + "type": "string" + }, + "data-action": { + "type": "string" + }, + "role": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["aria-label", "data-action", "role", "title"], + "type": "object" + }, + "continuous": { + "type": "boolean" + }, + "index": { + "type": "number" + }, + "isLastStep": { + "type": "boolean" + }, + "primaryProps": { + "additionalProperties": false, + "properties": { + "aria-label": { + "type": "string" + }, + "data-action": { + "type": "string" + }, + "role": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["aria-label", "data-action", "role", "title"], + "type": "object" + }, + "size": { + "type": "number" + }, + "skipProps": { + "additionalProperties": false, + "properties": { + "aria-label": { + "type": "string" + }, + "data-action": { + "type": "string" + }, + "role": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["aria-label", "data-action", "role", "title"], + "type": "object" + }, + "step": { + "$ref": "#/definitions/Step" + }, + "tooltipProps": { + "additionalProperties": false, + "properties": { + "aria-modal": { + "type": "boolean" + }, + "role": { + "type": "string" + } + }, + "required": ["aria-modal", "role"], + "type": "object" + } + }, + "required": [ + "backProps", + "closeProps", + "continuous", + "index", + "isLastStep", + "primaryProps", + "size", + "skipProps", + "step", + "tooltipProps" + ], + "type": "object" + }, + "actions": { + "additionalProperties": false, + "properties": { + "CLOSE": { + "enum": ["close"], + "type": "string" + }, + "GO": { + "enum": ["go"], + "type": "string" + }, + "INDEX": { + "enum": ["index"], + "type": "string" + }, + "INIT": { + "enum": ["init"], + "type": "string" + }, + "NEXT": { + "enum": ["next"], + "type": "string" + }, + "PREV": { + "enum": ["prev"], + "type": "string" + }, + "RESET": { + "enum": ["reset"], + "type": "string" + }, + "RESTART": { + "enum": ["restart"], + "type": "string" + }, + "SKIP": { + "enum": ["skip"], + "type": "string" + }, + "START": { + "enum": ["start"], + "type": "string" + }, + "STOP": { + "enum": ["stop"], + "type": "string" + }, + "UPDATE": { + "enum": ["update"], + "type": "string" + } + }, + "required": [ + "INIT", + "START", + "STOP", + "RESET", + "RESTART", + "PREV", + "NEXT", + "GO", + "INDEX", + "CLOSE", + "SKIP", + "UPDATE" + ], + "type": "object" + }, + "events": { + "additionalProperties": false, + "properties": { + "BEACON": { + "enum": ["beacon"], + "type": "string" + }, + "ERROR": { + "enum": ["error"], + "type": "string" + }, + "STEP_AFTER": { + "enum": ["step:after"], + "type": "string" + }, + "STEP_BEFORE": { + "enum": ["step:before"], + "type": "string" + }, + "TARGET_NOT_FOUND": { + "enum": ["error:target_not_found"], + "type": "string" + }, + "TOOLTIP": { + "enum": ["tooltip"], + "type": "string" + }, + "TOOLTIP_CLOSE": { + "enum": ["close"], + "type": "string" + }, + "TOUR_END": { + "enum": ["tour:end"], + "type": "string" + }, + "TOUR_START": { + "enum": ["tour:start"], + "type": "string" + }, + "TOUR_STATUS": { + "enum": ["tour:status"], + "type": "string" + } + }, + "required": [ + "TOUR_START", + "STEP_BEFORE", + "BEACON", + "TOOLTIP", + "TOOLTIP_CLOSE", + "STEP_AFTER", + "TOUR_END", + "TOUR_STATUS", + "TARGET_NOT_FOUND", + "ERROR" + ], + "type": "object" + }, + "lifecycle": { + "additionalProperties": false, + "properties": { + "BEACON": { + "enum": ["beacon"], + "type": "string" + }, + "COMPLETE": { + "enum": ["complete"], + "type": "string" + }, + "ERROR": { + "enum": ["error"], + "type": "string" + }, + "INIT": { + "enum": ["init"], + "type": "string" + }, + "READY": { + "enum": ["ready"], + "type": "string" + }, + "TOOLTIP": { + "enum": ["tooltip"], + "type": "string" + } + }, + "required": ["INIT", "READY", "BEACON", "TOOLTIP", "COMPLETE", "ERROR"], + "type": "object" + }, + "status": { + "additionalProperties": false, + "properties": { + "ERROR": { + "enum": ["error"], + "type": "string" + }, + "FINISHED": { + "enum": ["finished"], + "type": "string" + }, + "IDLE": { + "enum": ["idle"], + "type": "string" + }, + "PAUSED": { + "enum": ["paused"], + "type": "string" + }, + "READY": { + "enum": ["ready"], + "type": "string" + }, + "RUNNING": { + "enum": ["running"], + "type": "string" + }, + "SKIPPED": { + "enum": ["skipped"], + "type": "string" + }, + "WAITING": { + "enum": ["waiting"], + "type": "string" + } + }, + "required": [ + "IDLE", + "READY", + "WAITING", + "RUNNING", + "PAUSED", + "SKIPPED", + "FINISHED", + "ERROR" + ], + "type": "object" + }, + "valueof": { + "enum": [ + "idle", + "ready", + "waiting", + "running", + "paused", + "skipped", + "finished", + "error" + ], + "type": "string" + } + } +} diff --git a/src/__tests__/plugin.spec.ts b/src/__tests__/plugin.spec.ts index 168b566..f6d4a90 100644 --- a/src/__tests__/plugin.spec.ts +++ b/src/__tests__/plugin.spec.ts @@ -1,21 +1,72 @@ +import { JupyterFrontEnd } from '@jupyterlab/application'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { StateDB } from '@jupyterlab/statedb'; import { CommandRegistry } from '@lumino/commands'; +import { ReadonlyJSONObject } from '@lumino/coreutils'; +import { Signal } from '@lumino/signaling'; import 'jest'; import { CommandIDs } from '../constants'; import plugin from '../index'; -import { ITourHandler, ITourManager } from '../tokens'; +import { ITour, ITourManager, IUserTourManager } from '../tokens'; const DEFAULT_TOURS_SIZE = 2; -describe('plugin', () => { - describe('#activate', () => { - it('it should create add-tour command', () => { - const app = { - commands: new CommandRegistry(), - restored: Promise.resolve() - }; +const [corePlugin, userPlugin, defaultsPlugin] = plugin; + +function mockApp(): Partial { + return { + commands: new CommandRegistry(), + restored: Promise.resolve() + }; +} + +function aTour(): ITour { + return { + id: 'test-jupyterlab-tour:welcome', + label: 'Welcome Tour', + hasHelpEntry: true, + steps: [ + { + content: 'Tours of Jupyter', + placement: 'auto', + target: '#jp-MainLogo', + title: 'Jupyter' + }, + { + content: + 'The following tour will point out some of the main UI components within JupyterLab.', + placement: 'right', + target: '#jp-main-dock-panel', + title: 'Welcome to Jupyter Lab!' + }, + { + content: + 'This is the main content area where notebooks and other content can be viewed and edited.', + placement: 'center', + target: '#jp-main-dock-panel', + title: 'Main Content' + } + ] + }; +} + +function mockSettingRegistry(): Partial { + const settings: Partial = { + composite: { tours: [(aTour() as any) as ReadonlyJSONObject] } + }; + (settings as any)['changed'] = new Signal(settings); + + return { + load: async (): Promise => settings as any + }; +} + +describe(corePlugin.id, () => { + describe('activation', () => { + it('should create add-tour command', () => { + const app = mockApp(); const stateDB = new StateDB(); - plugin.activate(app as any, stateDB); + corePlugin.activate(app as any, stateDB); expect(app.commands.hasCommand(CommandIDs.addTour)).toEqual(true); }); @@ -23,42 +74,72 @@ describe('plugin', () => { describe('commands', () => { describe(`${CommandIDs.addTour}`, () => { - it('it should add a tour command', async () => { - const app = { - commands: new CommandRegistry(), - restored: Promise.resolve() - }; + it('should add a tour command', async () => { + const app = mockApp(); const stateDB = new StateDB(); - const manager = plugin.activate(app as any, stateDB) as ITourManager; - expect(manager.tours.size).toEqual(DEFAULT_TOURS_SIZE); + const manager = corePlugin.activate( + app as any, + stateDB + ) as ITourManager; + expect(manager.tours.size).toEqual(0); - const tour = (await app.commands.execute(CommandIDs.addTour, { - tour: { - id: 'test-jupyterlab-tour:welcome', - label: 'Welcome Tour', - steps: [ - { - content: - 'The following tour will point out some of the main UI components within JupyterLab.', - placement: 'center', - target: '#jp-main-dock-panel', - title: 'Welcome to Jupyter Lab!' - }, - { - content: - 'This is the main content area where notebooks and other content can be viewed and edited.', - placement: 'left-end', - target: '#jp-main-dock-panel', - title: 'Main Content' - } - ] - } - })) as ITourHandler; + const tour = await app.commands.execute(CommandIDs.addTour, { + tour: (aTour() as any) as ReadonlyJSONObject + }); - expect(manager.tours.size).toEqual(DEFAULT_TOURS_SIZE + 1); + expect(manager.tours.size).toEqual(1); expect(tour).toBeTruthy(); expect(manager.tours.get(tour.id)).toBeTruthy(); }); }); }); }); + +describe(userPlugin.id, () => { + describe('activation', () => { + it('should have userTours', async () => { + const app = mockApp(); + const stateDB = new StateDB(); + const manager = corePlugin.activate(app as any, stateDB) as ITourManager; + const settings = mockSettingRegistry(); + const userManager = userPlugin.activate( + app as any, + settings, + manager + ) as IUserTourManager; + await userManager.ready; + expect(userManager.tourManager.tours.size).toBe(1); + }); + }); + + describe('settings', () => { + it('should react to settings', async () => { + const app = mockApp(); + const stateDB = new StateDB(); + const manager = corePlugin.activate(app as any, stateDB) as ITourManager; + const settingsRegistry = mockSettingRegistry(); + const userManager = userPlugin.activate( + app as any, + settingsRegistry, + manager + ) as IUserTourManager; + await userManager.ready; + const settings = await settingsRegistry.load('whatever'); + (settings as any).composite = { tours: [] }; + (settings as any).changed.emit(void 0); + expect(userManager.tourManager.tours.size).toBe(0); + }); + }); +}); + +describe(defaultsPlugin.id, () => { + describe('activation', () => { + it('should activate', () => { + const app = mockApp(); + const stateDB = new StateDB(); + const manager = corePlugin.activate(app as any, stateDB) as ITourManager; + defaultsPlugin.activate(app as any, manager); + expect(manager.tours.size).toEqual(DEFAULT_TOURS_SIZE); + }); + }); +}); diff --git a/src/constants.ts b/src/constants.ts index f672653..d4c3810 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,19 +1,19 @@ import { Props as JoyrideProps } from 'react-joyride'; -import { PLUGIN_ID } from './tokens'; +import { NS } from './tokens'; /** * Command IDs */ export namespace CommandIDs { - export const addTour = `${PLUGIN_ID}:add`; - export const launch = `${PLUGIN_ID}:launch`; + export const addTour = `${NS}:add`; + export const launch = `${NS}:launch`; } /** * Default tour IDs */ -export const WELCOME_ID = `${PLUGIN_ID}:welcome`; -export const NOTEBOOK_ID = `${PLUGIN_ID}:notebook`; +export const WELCOME_ID = `${NS}:welcome`; +export const NOTEBOOK_ID = `${NS}:notebook`; /** * Default tour options diff --git a/src/plugin.tsx b/src/plugin.tsx index 459d22c..82dbb2f 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -6,25 +6,35 @@ import { ICommandPalette, InputDialog } from '@jupyterlab/apputils'; import { IMainMenu, MainMenu } from '@jupyterlab/mainmenu'; import { INotebookTracker } from '@jupyterlab/notebook'; import { IStateDB } from '@jupyterlab/statedb'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; + import React from 'react'; import ReactDOM from 'react-dom'; import { TourContainer } from './components'; import { CommandIDs, NOTEBOOK_ID, WELCOME_ID } from './constants'; import { addTours } from './defaults'; -import { ITourHandler, ITourManager, PLUGIN_ID } from './tokens'; +import { + DEFAULTS_PLUGIN_ID, + ITourHandler, + ITourManager, + IUserTourManager, + PLUGIN_ID, + USER_PLUGIN_ID +} from './tokens'; import { TourHandler } from './tour'; import { TourManager } from './tourManager'; +import { UserTourManager } from './userTourManager'; import { addJSONTour } from './utils'; /** * Initialization data for the jupyterlab-tour extension. */ -const extension: JupyterFrontEndPlugin = { - id: `${PLUGIN_ID}:plugin`, +const corePlugin: JupyterFrontEndPlugin = { + id: PLUGIN_ID, autoStart: true, activate, requires: [IStateDB], - optional: [ICommandPalette, IMainMenu, INotebookTracker], + optional: [ICommandPalette, IMainMenu], provides: ITourManager }; @@ -32,8 +42,7 @@ function activate( app: JupyterFrontEnd, stateDB: IStateDB, palette?: ICommandPalette, - menu?: MainMenu, - nbTracker?: INotebookTracker + menu?: MainMenu ): ITourManager { const { commands } = app; @@ -90,28 +99,70 @@ function activate( }); } - addTours(manager, app, nbTracker); - const node = document.createElement('div'); + document.body.appendChild(node); ReactDOM.render(, node); + return manager; +} + +/** + * Optional plugin for user-defined tours stored in the settings registry + */ +const userPlugin: JupyterFrontEndPlugin = { + id: USER_PLUGIN_ID, + autoStart: true, + activate: activateUser, + requires: [ISettingRegistry, ITourManager], + provides: IUserTourManager +}; + +function activateUser( + app: JupyterFrontEnd, + settings: ISettingRegistry, + tourManager: ITourManager +): IUserTourManager { + const manager = new UserTourManager({ + tourManager, + getSettings: (): Promise => + settings.load(USER_PLUGIN_ID) + }); + return manager; +} + +/** + * Optional plugin for the curated default tours and default toast behavior + */ +const defaultsPlugin: JupyterFrontEndPlugin = { + id: DEFAULTS_PLUGIN_ID, + autoStart: true, + activate: activateDefaults, + requires: [ITourManager], + optional: [INotebookTracker] +}; + +function activateDefaults( + app: JupyterFrontEnd, + tourManager: ITourManager, + nbTracker?: INotebookTracker +): void { + addTours(tourManager, app, nbTracker); + if (nbTracker) { nbTracker.widgetAdded.connect(() => { - if (manager.tours.has(NOTEBOOK_ID)) { - manager.launch([NOTEBOOK_ID], false); + if (tourManager.tours.has(NOTEBOOK_ID)) { + tourManager.launch([NOTEBOOK_ID], false); } }); } app.restored.then(() => { - if (manager.tours.has(WELCOME_ID)) { + if (tourManager.tours.has(WELCOME_ID)) { // Wait 3s before launching the first tour - to be sure element are loaded - setTimeout(() => manager.launch([WELCOME_ID], false), 3000); + setTimeout(() => tourManager.launch([WELCOME_ID], false), 3000); } }); - - return manager; } -export default extension; +export default [corePlugin, userPlugin, defaultsPlugin]; diff --git a/src/tokens.ts b/src/tokens.ts index a15a605..3df4e2b 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1,6 +1,7 @@ import { Token } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { ISignal } from '@lumino/signaling'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import React from 'react'; import { CallBackProps, @@ -10,17 +11,43 @@ import { } from 'react-joyride'; /** - * Extension ID + * Namespace for everything */ -export const PLUGIN_ID = 'jupyterlab-tour'; +export const NS = 'jupyterlab-tour'; + +/** + * Core Extension ID + */ +export const PLUGIN_ID = `${NS}:plugin`; + +/** + * User-defined tours extension ID + */ +export const USER_PLUGIN_ID = `${NS}:user-tours`; + +/** + * First-party curated tours, like Notebook and Welcomes + */ +export const DEFAULTS_PLUGIN_ID = `${NS}:default-tours`; /** * Token to get a reference to the tours manager */ -export const ITourManager = new Token( - `${PLUGIN_ID}:ITourManager` +export const ITourManager = new Token(`${NS}:ITourManager`); + +/** + * Token to get a reference to the user tours manager + */ +export const IUserTourManager = new Token( + `${NS}:IUserTourManager` ); +/** + * Step placement, as it's mostly used here, can have a few extra values than + * other uses. + */ +export type StepPlacement = Placement | 'center' | 'auto'; + /** * Serialized step interface */ @@ -36,7 +63,7 @@ export interface IStep { /** * Pop-up position */ - placement?: Placement; + placement?: StepPlacement; /** * Pop-up title */ @@ -56,13 +83,19 @@ export interface ITour { */ label: string; /** - * Should this tour be added as entry in the Help menu + * Should this tour be added as entry in the Help menu. User-added tours always are. */ hasHelpEntry: boolean; /** * Tour steps */ steps: Array; + /** + * A full tour description + * + * @see https://docs.react-joyride.com/props + */ + options?: JoyrideProps; } /** @@ -89,7 +122,7 @@ export interface ITourHandler extends IDisposable { createAndAddStep( target: string, content: React.ReactNode, - placement?: Placement, + placement?: StepPlacement, title?: string ): Step; @@ -211,3 +244,21 @@ export interface ITourManager extends IDisposable { */ readonly tours: Map; } + +/** + * User Tours manager interface + */ +export interface IUserTourManager { + readonly ready: Promise; + readonly tourManager: ITourManager; +} + +/** + * Namespace for user tour interfaces + */ +export namespace IUserTourManager { + export interface IOptions { + tourManager: ITourManager; + getSettings: () => Promise; + } +} diff --git a/src/tour.ts b/src/tour.ts index 662a3fd..ec92379 100644 --- a/src/tour.ts +++ b/src/tour.ts @@ -2,7 +2,6 @@ import { JSONExt } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; import { CallBackProps, - Placement, Props as JoyrideProps, status, STATUS, @@ -10,7 +9,7 @@ import { valueof } from 'react-joyride'; import { TutorialDefaultOptions } from './constants'; -import { ITourHandler } from './tokens'; +import { ITourHandler, StepPlacement } from './tokens'; // TODO should be IDisposable !! handling signal connection clearance export class TourHandler implements ITourHandler { @@ -140,7 +139,7 @@ export class TourHandler implements ITourHandler { createAndAddStep( target: string, content: React.ReactNode, - placement?: Placement, + placement?: StepPlacement, title?: string ): Step { const newStep: Step = { diff --git a/src/tourManager.ts b/src/tourManager.ts index 2ffe0c2..37ba517 100644 --- a/src/tourManager.ts +++ b/src/tourManager.ts @@ -1,14 +1,19 @@ +import { Menu } from '@lumino/widgets'; + import { MainMenu } from '@jupyterlab/mainmenu'; import { IStateDB } from '@jupyterlab/statedb'; import { ISignal, Signal } from '@lumino/signaling'; + import { INotification } from 'jupyterlab_toastify'; + import { Props as JoyrideProps } from 'react-joyride'; + import { CommandIDs } from './constants'; -import { ITourHandler, ITourManager, PLUGIN_ID } from './tokens'; +import { ITourHandler, ITourManager, NS } from './tokens'; import { TourHandler } from './tour'; import { version } from './version'; -const STATE_ID = `${PLUGIN_ID}:state`; +const STATE_ID = `${NS}:state`; /** * Manager state saved in the state database @@ -103,12 +108,13 @@ export class TourManager implements ITourManager { // Create tour and add it to help menu if needed const newTutorial: TourHandler = new TourHandler(id, label, options); if (this._menu && addToHelpMenu) { - this._menu.helpMenu.menu.addItem({ + const menuItem = this._menu.helpMenu.menu.addItem({ args: { id: newTutorial.id }, command: CommandIDs.launch }); + this._menuItems.set(newTutorial.id, menuItem); } // Add tour to current set @@ -212,6 +218,12 @@ export class TourManager implements ITourManager { if (!tour) { return; } + // Remove tour from menu + if (this._menu && this._menuItems.has(id)) { + const item = this._menuItems.get(id); + this._menu.helpMenu.menu.removeItem(item); + this._menuItems.delete(id); + } tour.dispose(); // Remove tour from the list this._tours.delete(id); @@ -237,6 +249,7 @@ export class TourManager implements ITourManager { private _activeTours: TourHandler[] = new Array(); private _isDisposed = false; private _menu: MainMenu | undefined; + private _menuItems: Map = new Map(); private _state: IManagerState = { toursDone: new Set(), version diff --git a/src/userTourManager.ts b/src/userTourManager.ts new file mode 100644 index 0000000..b009883 --- /dev/null +++ b/src/userTourManager.ts @@ -0,0 +1,105 @@ +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { PromiseDelegate } from '@lumino/coreutils'; + +import { + IUserTourManager, + ITour, + ITourManager, + USER_PLUGIN_ID +} from './tokens'; + +/** + * The UserTourManager is needed to manage syncing of user settings with the TourManager + */ +export class UserTourManager implements IUserTourManager { + constructor(options: IUserTourManager.IOptions) { + this._tourManager = options.tourManager; + options + .getSettings() + .then(userTours => { + this._userTours = userTours; + this._userTours.changed.connect(this._userToursChanged, this); + this._userToursChanged(); + this._ready.resolve(); + }) + .catch(reason => { + console.warn(reason); + this._ready.reject(reason); + }); + } + + /** + * A promise that resolves when the settings have been loaded. + */ + get ready(): Promise { + return this._ready.promise; + } + + get tourManager(): ITourManager { + return this._tourManager; + } + + /** + * The user changed their tours, remove and re-add all of them. + */ + private _userToursChanged(): void { + const tours: ITour[] = [...(this._userTours?.composite?.tours as any)]; + + if (!tours) { + return; + } + + for (const id of this._tourManager.tours.keys()) { + if (id.startsWith(USER_PLUGIN_ID)) { + this._tourManager.removeTour(id); + } + } + + tours.sort(this._compareTours); + + for (const tour of tours) { + try { + this._addUserTour(tour); + this._tourManager.launch([tour.id], false); + } catch (error) { + console.groupCollapsed( + `Error encountered adding user tour ${tour.label} (${tour.id})`, + error + ); + console.table(tour.steps); + console.log(tour.options || {}); + console.groupEnd(); + } + } + } + + /** + * Actually create a tour from JSON + */ + private _addUserTour(tour: ITour): void { + const handler = this._tourManager.createTour( + `${USER_PLUGIN_ID}:${tour.id}`, + tour.label, + tour.hasHelpEntry === false ? false : true, + tour.options + ); + + for (const step of tour.steps) { + handler.addStep(step); + } + } + + /** + * Helper to sort user tours by label, if possible, falling back to unique id + */ + private _compareTours(a: ITour, b: ITour): number { + return ( + a.label.toLocaleLowerCase().localeCompare(b.label.toLocaleLowerCase()) || + a.id.localeCompare(b.id) + ); + } + + private _tourManager: ITourManager; + private _userTours: ISettingRegistry.ISettings; + private _ready = new PromiseDelegate(); +} diff --git a/yarn.lock b/yarn.lock index 4ccf293..84d245d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1545,7 +1545,7 @@ node-fetch "^2.6.0" ws "^7.2.0" -"@jupyterlab/settingregistry@^3.0.5": +"@jupyterlab/settingregistry@^3.0.0", "@jupyterlab/settingregistry@^3.0.5": version "3.0.5" resolved "https://registry.yarnpkg.com/@jupyterlab/settingregistry/-/settingregistry-3.0.5.tgz#d36efd1d5a67cd4c89d88159ffa21eaa03a9bf1f" integrity sha512-H86y+CKUxObPA/g8kafmoK23rRsKaXnYCkp4qDx5Vl932/rN6wJUezQ1POe4iFW8MHiniUJpKB8WCknVGc/AUg== @@ -1969,13 +1969,6 @@ dependencies: "@types/react" "*" -"@types/react-joyride@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@types/react-joyride/-/react-joyride-2.0.5.tgz#621fb90418a392ac009ee6b75c3d1850a88ea92b" - integrity sha512-N3bd0w3D42gZRpXy/wYsSZ8JvhyuyN0qAFTkg2iNsJ1NueMqfI0TJL+BZhhFkAqNhEETJEpDc8t4bj3DEJyLoQ== - dependencies: - react-joyride "*" - "@types/react@*", "@types/react@^17.0.0": version "17.0.3" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.3.tgz#ba6e215368501ac3826951eef2904574c262cc79" @@ -6325,7 +6318,7 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-joyride@*, react-joyride@^2.2.1: +react-joyride@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.3.0.tgz#d18f7af8f6e7d4c7409426d43e5f6513d70b3e00" integrity sha512-aY7+dgmBKbgGoMjN828qXnMAqeA6QvwvSWxj/fyvxIIIx0iu3wNVT/A5NZ0wHxiVDav+Df9YZuL412Q6C0l7gw==