diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 01565a95..a86b5461 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,6 +33,7 @@ jobs: - notifications - react-widget - settings + - shout-button-message - signals - state - toolbar-button diff --git a/README.md b/README.md index ee6e9ac7..b1be3625 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ 1. [Context Menu](#context-menu) 1. [Custom Log Console](#custom-log-console) 1. [Datagrid](#datagrid) + 1. [Dual Compatibility Shout Button](#dual-compatibility-shout-button) 1. [Collaborative Document](#collaborative-document) 1. _[Hello World](#hello-world)_ 1. [Kernel Messaging](#kernel-messaging) @@ -101,6 +102,7 @@ Start with the [Hello World](hello-world) and then jump to the topic you are int - [Context Menu](context-menu) - [Custom Log Console](custom-log-console) - [Datagrid](datagrid) +- [Dual Compatibility Shout Button](shout-button-message) - [Collaborative Document](documents) - [Hello World](hello-world) - [Kernel Messaging](kernel-messaging) @@ -179,6 +181,16 @@ Display a Datagrid as a Lumino Widget. [![Datagrid](datagrid/preview.png)](datagrid) +### [Dual Compatibility Shout Button](shout-button-message) + +This example shows dual compatibility: Make an extension that is compatible +with both JupyterLab and Jupyter Notebook by using optional features. Adds +a shout button to the right sidebar, and if running in JupyterLab, also adds +a status bar widget. This example is part of the [Extension Dual Compatibility Guide](https://jupyterlab.readthedocs.io/en/latest/extension_dual_compatibility.html). +Read more about this example on that page. + +[![Dual compatibility shout button](shout-button-message/preview.jpg)](shout-button-message) + ### [Collaborative Document](documents) Create new documents and make them collaborative. diff --git a/package.json b/package.json index 1380b078..f08722e6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react-widget", "server-extension", "settings", + "shout-button-message", "signals", "state", "toolbar-button", diff --git a/shout-button-message/.copier-answers.yml b/shout-button-message/.copier-answers.yml new file mode 100644 index 00000000..2a16ba22 --- /dev/null +++ b/shout-button-message/.copier-answers.yml @@ -0,0 +1,20 @@ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +_commit: v4.2.4 +_src_path: https://github.com/jupyterlab/extension-template +author_email: '' +author_name: Project Jupyter Contributors +data_format: string +file_extension: '' +has_binder: false +has_settings: false +kind: frontend +labextension_name: '@jupyterlab-examples/shout-button' +mimetype: '' +mimetype_name: '' +project_short_description: An extension that adds a button and message to the right + toolbar, with optional status bar widget in JupyterLab. +python_name: jupyterlab_examples_shout_button +repository: https://github.com/jupyterlab/extension-examples +test: true +viewer_name: '' + diff --git a/shout-button-message/.gitignore b/shout-button-message/.gitignore new file mode 100644 index 00000000..d9a8d1d8 --- /dev/null +++ b/shout-button-message/.gitignore @@ -0,0 +1,125 @@ +*.bundle.* +lib/ +node_modules/ +*.log +.eslintcache +.stylelintcache +*.egg-info/ +.ipynb_checkpoints +*.tsbuildinfo +jupyterlab_examples_shout_button/labextension +# Version file is handled by hatchling +jupyterlab_examples_shout_button/_version.py + +# Integration tests +ui-tests/test-results/ +ui-tests/playwright-report/ + +# Created by https://www.gitignore.io/api/python +# Edit at https://www.gitignore.io/?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage/ +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# End of https://www.gitignore.io/api/python + +# OSX files +.DS_Store + +# Yarn cache +.yarn/ diff --git a/shout-button-message/.prettierignore b/shout-button-message/.prettierignore new file mode 100644 index 00000000..f45e8f0a --- /dev/null +++ b/shout-button-message/.prettierignore @@ -0,0 +1,6 @@ +node_modules +**/node_modules +**/lib +**/package.json +!/package.json +jupyterlab_examples_shout_button diff --git a/shout-button-message/.yarnrc.yml b/shout-button-message/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/shout-button-message/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/shout-button-message/README.md b/shout-button-message/README.md new file mode 100644 index 00000000..af28b6ab --- /dev/null +++ b/shout-button-message/README.md @@ -0,0 +1,106 @@ +# Shout button (cross compatible extension) + +This example defines an extension that adds a button in the right sidebar that +if clicked will display an alert to the user and in JupyterLab will update +a widget in the status bar. + +![preview](./preview.jpg) + +We strongly advice to look to those examples before diving into this one: + +- [signals](../signals/): Communication between JavaScript objects. +- [widgets](../widgets): The basic DOM Jupyter component + +## Jupyter Notebook / JupyterLab compatibility + +As Jupyter Notebook 7+ is built with components from JupyterLab, and since +both use the same building blocks, that means your extension can work +on both (or any other frontend built with JupyterLab components) with +little or no modification depending on its design. + +This example has a part specific to JupyterLab. This translate by having +optional dependency for your extension plugin. + +```ts +// src/index.ts#L120-L120 + +optional: [IStatusBar], +``` + +If your dependency is optional, the object pass to the `activate` method +will be `null` if no other plugin provides it. + +```ts +// src/index.ts#L124-L124 + +activate: (app: JupyterFrontEnd, statusBar: IStatusBar | null) => { +``` + +## Add the button in the sidebar + +You can add a widget to the right sidebar through the application shell: + +```ts +// src/index.ts#L128-L131 + +const shoutWidget: ShoutWidget = new ShoutWidget(); +shoutWidget.id = 'JupyterShoutWidget'; // Widgets need an id + +app.shell.add(shoutWidget, 'right'); +``` + +The `ShoutWidget` is a widget that contains a button that when clicked +emit a signal `messageShouted` that any callback can listen to to react +to it and display an alert to the user. + +```ts +// src/index.ts#L99-L103 + +shout() { + this._lastShoutTime = new Date(); + this._messageShouted.emit(this._lastShoutTime); + window.alert('Shouting at ' + this._lastShoutTime); +} +``` + +## Connect the button and the status bar + +The status bar does not exist in all Jupyter applications (e.g. in +Jupyter Notebook). So a good practice is to make that dependency +optional and test for it to be non-null to carry related action: + +```ts +// src/index.ts#L135-L135 + +if (statusBar) { +``` + +In this specific case, the action is to create a widget to add to the +status bar. You can achieve that by calling the `registerStatusItem` +method from the status bar object. + +```ts +// src/index.ts#L136-L138 + +const statusBarWidget = new ShoutStatusBarSummary(); + +statusBar.registerStatusItem('shoutStatusBarSummary', { +``` + +If you want to react to a click on the button, you can `connect` to the +widget `messageShouted` signal. In which for example, you update the +text displayed in the status bar. + +```ts +// src/index.ts#L142-L144 + +// Connect to the messageShouted to be notified when a new message +// is published and react to it by updating the status bar widget. +shoutWidget.messageShouted.connect((widget: ShoutWidget, time: Date) => { +``` + +## Where to Go Next + +You can have more information about making extension compatible with +multiple applications in the +[Extension Dual Compatibility Guide](https://jupyterlab.readthedocs.io/en/latest/extension_dual_compatibility.html). diff --git a/shout-button-message/install.json b/shout-button-message/install.json new file mode 100644 index 00000000..a27f7f3f --- /dev/null +++ b/shout-button-message/install.json @@ -0,0 +1,5 @@ +{ + "packageManager": "python", + "packageName": "jupyterlab_examples_shout_button", + "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab_examples_shout_button" +} diff --git a/shout-button-message/jupyterlab_examples_shout_button/__init__.py b/shout-button-message/jupyterlab_examples_shout_button/__init__.py new file mode 100644 index 00000000..2e2ab422 --- /dev/null +++ b/shout-button-message/jupyterlab_examples_shout_button/__init__.py @@ -0,0 +1,16 @@ +try: + from ._version import __version__ +except ImportError: + # Fallback when using the package in dev mode without installing + # in editable mode with pip. It is highly recommended to install + # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs + import warnings + warnings.warn("Importing 'jupyterlab_examples_shout_button' outside a proper installation.") + __version__ = "dev" + + +def _jupyter_labextension_paths(): + return [{ + "src": "labextension", + "dest": "@jupyterlab-examples/shout-button" + }] diff --git a/shout-button-message/package.json b/shout-button-message/package.json new file mode 100644 index 00000000..857e685c --- /dev/null +++ b/shout-button-message/package.json @@ -0,0 +1,192 @@ +{ + "name": "@jupyterlab-examples/shout-button", + "version": "0.1.0", + "description": "An extension that adds a button and message to the right toolbar, with optional status bar widget in JupyterLab.", + "keywords": [ + "jupyter", + "jupyterlab", + "jupyterlab-extension" + ], + "homepage": "https://github.com/jupyterlab/extension-examples", + "bugs": { + "url": "https://github.com/jupyterlab/extension-examples/issues" + }, + "license": "BSD-3-Clause", + "author": "Project Jupyter Contributors", + "files": [ + "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", + "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "style": "style/index.css", + "repository": { + "type": "git", + "url": "https://github.com/jupyterlab/extension-examples.git" + }, + "scripts": { + "build": "jlpm build:lib && jlpm build:labextension:dev", + "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", + "build:labextension": "jupyter labextension build .", + "build:labextension:dev": "jupyter labextension build --development True .", + "build:lib": "tsc --sourceMap", + "build:lib:prod": "tsc", + "clean": "jlpm clean:lib", + "clean:lib": "rimraf lib tsconfig.tsbuildinfo", + "clean:lintcache": "rimraf .eslintcache .stylelintcache", + "clean:labextension": "rimraf jupyterlab_examples_shout_button/labextension jupyterlab_examples_shout_button/_version.py", + "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", + "eslint": "jlpm eslint:check --fix", + "eslint:check": "eslint . --cache --ext .ts,.tsx", + "install:extension": "jlpm build", + "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", + "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", + "prettier": "jlpm prettier:base --write --list-different", + "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", + "prettier:check": "jlpm prettier:base --check", + "stylelint": "jlpm stylelint:check --fix", + "stylelint:check": "stylelint --cache \"style/**/*.css\"", + "watch": "run-p watch:src watch:labextension", + "watch:src": "tsc -w --sourceMap", + "watch:labextension": "jupyter labextension watch ." + }, + "dependencies": { + "@jupyterlab/application": "^4.0.0", + "@jupyterlab/statusbar": "^4.0.0", + "@lumino/signaling": "^2.0.0", + "@lumino/widgets": "^2.0.0" + }, + "devDependencies": { + "@jupyterlab/builder": "^4.0.0", + "@jupyterlab/testutils": "^4.0.0", + "@types/jest": "^29.2.0", + "@types/json-schema": "^7.0.11", + "@types/react": "^18.0.26", + "@types/react-addons-linked-state-mixin": "^0.14.22", + "@typescript-eslint/eslint-plugin": "^6.1.0", + "@typescript-eslint/parser": "^6.1.0", + "css-loader": "^6.7.1", + "eslint": "^8.36.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.2.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.0.0", + "rimraf": "^5.0.1", + "source-map-loader": "^1.0.2", + "style-loader": "^3.3.1", + "stylelint": "^15.10.1", + "stylelint-config-recommended": "^13.0.0", + "stylelint-config-standard": "^34.0.0", + "stylelint-csstree-validator": "^3.0.0", + "stylelint-prettier": "^4.0.0", + "typescript": "~5.0.2", + "yjs": "^13.5.0" + }, + "sideEffects": [ + "style/*.css", + "style/index.js" + ], + "styleModule": "style/index.js", + "publishConfig": { + "access": "public" + }, + "jupyterlab": { + "extension": true, + "outputDir": "jupyterlab_examples_shout_button/labextension" + }, + "eslintIgnore": [ + "node_modules", + "dist", + "coverage", + "**/*.d.ts", + "tests", + "**/__tests__", + "ui-tests" + ], + "eslintConfig": { + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "tsconfig.json", + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "interface", + "format": [ + "PascalCase" + ], + "custom": { + "regex": "^I[A-Z]", + "match": true + } + } + ], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "args": "none" + } + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/quotes": [ + "error", + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": false + } + ], + "curly": [ + "error", + "all" + ], + "eqeqeq": "error", + "prefer-arrow-callback": "error" + } + }, + "prettier": { + "singleQuote": true, + "trailingComma": "none", + "arrowParens": "avoid", + "endOfLine": "auto", + "overrides": [ + { + "files": "package.json", + "options": { + "tabWidth": 4 + } + } + ] + }, + "stylelint": { + "extends": [ + "stylelint-config-recommended", + "stylelint-config-standard", + "stylelint-prettier/recommended" + ], + "plugins": [ + "stylelint-csstree-validator" + ], + "rules": { + "csstree/validator": true, + "property-no-vendor-prefix": null, + "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$", + "selector-no-vendor-prefix": null, + "value-no-vendor-prefix": null + } + } +} diff --git a/shout-button-message/preview.jpg b/shout-button-message/preview.jpg new file mode 100644 index 00000000..2ce91e3c Binary files /dev/null and b/shout-button-message/preview.jpg differ diff --git a/shout-button-message/pyproject.toml b/shout-button-message/pyproject.toml new file mode 100644 index 00000000..409cc798 --- /dev/null +++ b/shout-button-message/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] +build-backend = "hatchling.build" + +[project] +name = "jupyterlab_examples_shout_button" +readme = "README.md" +license = {text = "BSD-3-Clause License"} +requires-python = ">=3.8" +classifiers = [ + "Framework :: Jupyter", + "Framework :: Jupyter :: JupyterLab", + "Framework :: Jupyter :: JupyterLab :: 4", + "Framework :: Jupyter :: JupyterLab :: Extensions", + "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ +] +dynamic = ["version", "description", "authors", "urls", "keywords"] + +[tool.hatch.version] +source = "nodejs" + +[tool.hatch.metadata.hooks.nodejs] +fields = ["description", "authors", "urls"] + +[tool.hatch.build.targets.sdist] +artifacts = ["jupyterlab_examples_shout_button/labextension"] +exclude = [".github", "binder"] + +[tool.hatch.build.targets.wheel.shared-data] +"jupyterlab_examples_shout_button/labextension" = "share/jupyter/labextensions/@jupyterlab-examples/shout-button" +"install.json" = "share/jupyter/labextensions/@jupyterlab-examples/shout-button/install.json" + +[tool.hatch.build.hooks.version] +path = "jupyterlab_examples_shout_button/_version.py" + +[tool.hatch.build.hooks.jupyter-builder] +dependencies = ["hatch-jupyter-builder>=0.5"] +build-function = "hatch_jupyter_builder.npm_builder" +ensured-targets = [ + "jupyterlab_examples_shout_button/labextension/static/style.js", + "jupyterlab_examples_shout_button/labextension/package.json", +] +skip-if-exists = ["jupyterlab_examples_shout_button/labextension/static/style.js"] + +[tool.hatch.build.hooks.jupyter-builder.build-kwargs] +build_cmd = "build:prod" +npm = ["jlpm"] + +[tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] +build_cmd = "install:extension" +npm = ["jlpm"] +source_dir = "src" +build_dir = "jupyterlab_examples_shout_button/labextension" + +[tool.jupyter-releaser.options] +version_cmd = "hatch version" + +[tool.jupyter-releaser.hooks] +before-build-npm = [ + "python -m pip install 'jupyterlab>=4.0.0,<5'", + "jlpm", + "jlpm build:prod" +] +before-build-python = ["jlpm clean:all"] + +[tool.check-wheel-contents] +ignore = ["W002"] diff --git a/shout-button-message/setup.py b/shout-button-message/setup.py new file mode 100644 index 00000000..aefdf20d --- /dev/null +++ b/shout-button-message/setup.py @@ -0,0 +1 @@ +__import__("setuptools").setup() diff --git a/shout-button-message/src/index.ts b/shout-button-message/src/index.ts new file mode 100644 index 00000000..19b61003 --- /dev/null +++ b/shout-button-message/src/index.ts @@ -0,0 +1,153 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; + +import { IStatusBar } from '@jupyterlab/statusbar'; + +import { Message } from '@lumino/messaging'; + +import { ISignal, Signal } from '@lumino/signaling'; + +import { Widget } from '@lumino/widgets'; + +/** + * Widget to display text it JupyterLab status bar. + */ +class ShoutStatusBarSummary extends Widget { + private _statusBarSummary: HTMLElement; + + constructor() { + super(); + + // Display the last shout time in the status bar + this._statusBarSummary = document.createElement('p'); + this._statusBarSummary.classList.add('jp-shout-summary'); + this._statusBarSummary.innerText = 'Last Shout: (None)'; + this.node.appendChild(this._statusBarSummary); + } + + /** + * Set the widget text content + * + * @param summary The text to display + */ + setSummary(summary: string) { + this._statusBarSummary.innerText = summary; + } +} + +/** + * ShoutWidget holds all the plugin's primary functionality. + * It also creates a widget for JupyterLab's status bar if the + * status bar is available. + */ +class ShoutWidget extends Widget { + // The last shout time for use in the status bar + private _lastShoutTime: Date | null; + // Signal triggered when a message is shouted + private _messageShouted = new Signal(this); + // Link to the shout button + private _shoutButton: HTMLElement; + + constructor() { + super(); + + // Create and add a button to this widget's root node + const shoutButton = document.createElement('div'); + shoutButton.innerText = 'Press to Shout'; + shoutButton.classList.add('jp-shout-button'); + this.node.appendChild(shoutButton); + this._shoutButton = shoutButton; + + this._lastShoutTime = null; + } + + /** + * The last shout time for use in the status bar + */ + get lastShoutTime(): Date | null { + return this._lastShoutTime; + } + + /** + * Signal emitted when a message is shouted + */ + get messageShouted(): ISignal { + return this._messageShouted; + } + + /** + * Callback when the widget is added to the DOM + */ + protected onAfterAttach(msg: Message): void { + // Add a listener to "shout" when the button is clicked + this._shoutButton.addEventListener('click', this.shout.bind(this)); + } + + /** + * Callback when the widget is removed from the DOM + */ + protected onBeforeDetach(msg: Message): void { + this._shoutButton.removeEventListener('click', this.shout.bind(this)); + } + + /** + * Make an alert popup that shouts upon user click + * And signal that a message is emitted. + */ + shout() { + this._lastShoutTime = new Date(); + this._messageShouted.emit(this._lastShoutTime); + window.alert('Shouting at ' + this._lastShoutTime); + } +} + +/** + * JupyterLab extensions are made up of plugin(s). You can specify some + * information about your plugin with the properties defined here. This + * extension exports a single plugin, and lists the IStatusBar from + * JupyterLab as optional. + */ +const plugin: JupyterFrontEndPlugin = { + id: '@jupyterlab-examples/shout-button:plugin', + description: + 'An extension that adds a button and message to the right toolbar, with optional status bar widget in JupyterLab.', + autoStart: true, + // The IStatusBar is marked optional here. If it's available, it will + // be provided to the plugin as an argument to the activate function + // (shown below), and if not it will be null. + optional: [IStatusBar], + // Make sure to list any 'requires' and 'optional' features as arguments + // to your activate function (activate is always passed an Application, + // then required arguments, then optional arguments) + activate: (app: JupyterFrontEnd, statusBar: IStatusBar | null) => { + console.log('JupyterLab extension shout_button_message is activated!'); + + // Create a ShoutWidget and add it to the interface in the right sidebar + const shoutWidget: ShoutWidget = new ShoutWidget(); + shoutWidget.id = 'JupyterShoutWidget'; // Widgets need an id + + app.shell.add(shoutWidget, 'right'); + + // Check if the status bar is available, and if so, make + // a status bar widget to hold some information + if (statusBar) { + const statusBarWidget = new ShoutStatusBarSummary(); + + statusBar.registerStatusItem('shoutStatusBarSummary', { + item: statusBarWidget + }); + + // Connect to the messageShouted to be notified when a new message + // is published and react to it by updating the status bar widget. + shoutWidget.messageShouted.connect((widget: ShoutWidget, time: Date) => { + statusBarWidget.setSummary( + 'Last Shout: ' + widget.lastShoutTime?.toString() ?? '(None)' + ); + }); + } + } +}; + +export default plugin; diff --git a/shout-button-message/style/base.css b/shout-button-message/style/base.css new file mode 100644 index 00000000..e1defee8 --- /dev/null +++ b/shout-button-message/style/base.css @@ -0,0 +1,21 @@ +/* + See the JupyterLab Developer Guide for useful CSS Patterns: + + https://jupyterlab.readthedocs.io/en/stable/developer/css.html +*/ + +.jp-shout-button { + height: 30px; + width: 150px; + margin: 8px; + padding-top: 9px; + text-align: center; + vertical-align: middle; + border-radius: 2px; + background-color: #2296f3; + color: #212121; +} + +.jp-shout-summary { + margin: 4px; +} diff --git a/shout-button-message/style/index.css b/shout-button-message/style/index.css new file mode 100644 index 00000000..8a7ea29e --- /dev/null +++ b/shout-button-message/style/index.css @@ -0,0 +1 @@ +@import url('base.css'); diff --git a/shout-button-message/style/index.js b/shout-button-message/style/index.js new file mode 100644 index 00000000..a028a764 --- /dev/null +++ b/shout-button-message/style/index.js @@ -0,0 +1 @@ +import './base.css'; diff --git a/shout-button-message/tsconfig.json b/shout-button-message/tsconfig.json new file mode 100644 index 00000000..98979175 --- /dev/null +++ b/shout-button-message/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "composite": true, + "declaration": true, + "esModuleInterop": true, + "incremental": true, + "jsx": "react", + "module": "esnext", + "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "preserveWatchOutput": true, + "resolveJsonModule": true, + "outDir": "lib", + "rootDir": "src", + "strict": true, + "strictNullChecks": true, + "target": "ES2018" + }, + "include": ["src/*"] +} diff --git a/shout-button-message/ui-tests/README.md b/shout-button-message/ui-tests/README.md new file mode 100644 index 00000000..8d377fcc --- /dev/null +++ b/shout-button-message/ui-tests/README.md @@ -0,0 +1,167 @@ +# Integration Testing + +This folder contains the integration tests of the extension. + +They are defined using [Playwright](https://playwright.dev/docs/intro) test runner +and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper. + +The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). + +The JupyterLab server configuration to use for the integration test is defined +in [jupyter_server_test_config.py](./jupyter_server_test_config.py). + +The default configuration will produce video for failing tests and an HTML report. + +> There is a new experimental UI mode that you may fall in love with; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). + +## Run the tests + +> All commands are assumed to be executed from the root directory + +To run the tests, you need to: + +1. Compile the extension: + +```sh +jlpm install +jlpm build:prod +``` + +> Check the extension is installed in JupyterLab. + +2. Install test dependencies (needed only once): + +```sh +cd ./ui-tests +jlpm install +jlpm playwright install +cd .. +``` + +3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: + +```sh +cd ./ui-tests +jlpm playwright test +``` + +Test results will be shown in the terminal. In case of any test failures, the test report +will be opened in your browser at the end of the tests execution; see +[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) +for configuring that behavior. + +## Update the tests snapshots + +> All commands are assumed to be executed from the root directory + +If you are comparing snapshots to validate your tests, you may need to update +the reference snapshots stored in the repository. To do that, you need to: + +1. Compile the extension: + +```sh +jlpm install +jlpm build:prod +``` + +> Check the extension is installed in JupyterLab. + +2. Install test dependencies (needed only once): + +```sh +cd ./ui-tests +jlpm install +jlpm playwright install +cd .. +``` + +3. Execute the [Playwright](https://playwright.dev/docs/intro) command: + +```sh +cd ./ui-tests +jlpm playwright test -u +``` + +> Some discrepancy may occurs between the snapshots generated on your computer and +> the one generated on the CI. To ease updating the snapshots on a PR, you can +> type `please update playwright snapshots` to trigger the update by a bot on the CI. +> Once the bot has computed new snapshots, it will commit them to the PR branch. + +## Create tests + +> All commands are assumed to be executed from the root directory + +To create tests, the easiest way is to use the code generator tool of playwright: + +1. Compile the extension: + +```sh +jlpm install +jlpm build:prod +``` + +> Check the extension is installed in JupyterLab. + +2. Install test dependencies (needed only once): + +```sh +cd ./ui-tests +jlpm install +jlpm playwright install +cd .. +``` + +3. Start the server: + +```sh +cd ./ui-tests +jlpm start +``` + +4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: + +```sh +cd ./ui-tests +jlpm playwright codegen localhost:8888 +``` + +## Debug tests + +> All commands are assumed to be executed from the root directory + +To debug tests, a good way is to use the inspector tool of playwright: + +1. Compile the extension: + +```sh +jlpm install +jlpm build:prod +``` + +> Check the extension is installed in JupyterLab. + +2. Install test dependencies (needed only once): + +```sh +cd ./ui-tests +jlpm install +jlpm playwright install +cd .. +``` + +3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): + +```sh +cd ./ui-tests +jlpm playwright test --debug +``` + +## Upgrade Playwright and the browsers + +To update the web browser versions, you must update the package `@playwright/test`: + +```sh +cd ./ui-tests +jlpm up "@playwright/test" +jlpm playwright install +``` diff --git a/shout-button-message/ui-tests/jupyter_server_test_config.py b/shout-button-message/ui-tests/jupyter_server_test_config.py new file mode 100644 index 00000000..f2a94782 --- /dev/null +++ b/shout-button-message/ui-tests/jupyter_server_test_config.py @@ -0,0 +1,12 @@ +"""Server configuration for integration tests. + +!! Never use this configuration in production because it +opens the server to the world and provide access to JupyterLab +JavaScript objects through the global window variable. +""" +from jupyterlab.galata import configure_jupyter_server + +configure_jupyter_server(c) + +# Uncomment to set server log level to debug level +# c.ServerApp.log_level = "DEBUG" diff --git a/shout-button-message/ui-tests/package.json b/shout-button-message/ui-tests/package.json new file mode 100644 index 00000000..66470051 --- /dev/null +++ b/shout-button-message/ui-tests/package.json @@ -0,0 +1,15 @@ +{ + "name": "@jupyterlab-examples/shout-button-ui-tests", + "version": "1.0.0", + "description": "JupyterLab @jupyterlab-examples/shout-button Integration Tests", + "private": true, + "scripts": { + "start": "jupyter lab --config jupyter_server_test_config.py", + "test": "jlpm playwright test", + "test:update": "jlpm playwright test --update-snapshots" + }, + "devDependencies": { + "@jupyterlab/galata": "^5.0.5", + "@playwright/test": "^1.37.0" + } +} diff --git a/shout-button-message/ui-tests/playwright.config.js b/shout-button-message/ui-tests/playwright.config.js new file mode 100644 index 00000000..9ece6fa1 --- /dev/null +++ b/shout-button-message/ui-tests/playwright.config.js @@ -0,0 +1,14 @@ +/** + * Configuration for Playwright using default from @jupyterlab/galata + */ +const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); + +module.exports = { + ...baseConfig, + webServer: { + command: 'jlpm start', + url: 'http://localhost:8888/lab', + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI + } +}; diff --git a/shout-button-message/ui-tests/tests/jupyterlab_shout_button_message.spec.ts b/shout-button-message/ui-tests/tests/jupyterlab_shout_button_message.spec.ts new file mode 100644 index 00000000..a518c2a4 --- /dev/null +++ b/shout-button-message/ui-tests/tests/jupyterlab_shout_button_message.spec.ts @@ -0,0 +1,5 @@ +import { test, expect } from '@jupyterlab/galata'; + +test('should add a shout button', async ({ page }) => { + await expect(page.locator('.jp-shout-button')).toHaveText('Press to Shout'); +}); diff --git a/shout-button-message/ui-tests/yarn.lock b/shout-button-message/ui-tests/yarn.lock new file mode 100644 index 00000000..e69de29b diff --git a/widgets/README.md b/widgets/README.md index 7eb71a8d..e9d9ce1d 100644 --- a/widgets/README.md +++ b/widgets/README.md @@ -18,7 +18,7 @@ The base widget class can be imported with: ```ts // src/index.ts#L8-L8 -import { Widget } from '@lumino/widgets'; +import { Message } from '@lumino/messaging'; ``` It requires to add the library as package dependency: @@ -36,7 +36,7 @@ of the `app` object: ```ts // src/index.ts#L19-L19 -const { commands, shell } = app; +requires: [ICommandPalette], ``` Then the widget can be inserted by calling the `add` method, like in the command defined @@ -46,10 +46,10 @@ in this example: ```ts // src/index.ts#L25-L28 +label: 'Open a Tab Widget', +caption: 'Open the Widgets Example Tab', execute: () => { const widget = new ExampleWidget(); - shell.add(widget, 'main'); -} ``` @@ -62,8 +62,11 @@ In this case, no specific behavior is defined for the widget. Only some properti - `title.label`: The widget tab title - `title.closable`: Allow the widget tab to be closed + ```ts -// src/index.ts#L36-L44 +// src/index.ts#L36-L43 + +export default extension; class ExampleWidget extends Widget { constructor() { @@ -71,24 +74,76 @@ class ExampleWidget extends Widget { this.addClass('jp-example-view'); this.id = 'simple-widget-example'; this.title.label = 'Widget Example View'; - this.title.closable = true; - } -} ``` + You can associate style properties to the custom CSS class in the file `style/base.css`: - + ```css .jp-example-view { background-color: aliceblue; + cursor: pointer; } ``` +## Adding event listeners + +A very often required need for widgets is the ability to react to user events. +As widget is a wrapper around a HTML element accessible through the attribute +`this.node`, you can add event listeners using the standard API: + +```ts +// src/index.ts#L69-L75 + +// The first two events are not linked to a specific callback but +// to this object. In that case, the object method `handleEvent` +// is the function called when an event occurs. +this.node.addEventListener('pointerenter', this); +this.node.addEventListener('pointerleave', this); +// This event will call a specific function when occuring +this.node.addEventListener('click', this._onEventClick.bind(this)); +``` + +The listeners can either be directly a function as for the _click_ event in this +example or the widget (as for _pointerenter_ and _pointerleave_ here). In the +second case, you will need to defined a `handleEvent` method in the widget that will +be called when an event is triggered: + +```ts +// src/index.ts#L52-L61 + +handleEvent(event: Event): void { + switch (event.type) { + case 'pointerenter': + this._onMouseEnter(event); + break; + case 'pointerleave': + this._onMouseLeave(event); + break; + } +} +``` + +The best place for adding listeners is the method `onAfterAttach` that is inherited +by the `Widget` class and is called when the widget is attached to the DOM. And you +should remove the listeners in `onBeforeDetach` when the widget is about to be detached +from the DOM. + +```ts +// src/index.ts#L83-L87 + +protected onBeforeDetach(msg: Message): void { + this.node.removeEventListener('pointerenter', this); + this.node.removeEventListener('pointerleave', this); + this.node.removeEventListener('click', this._onEventClick.bind(this)); +} +``` + ## Where to Go Next This example uses a command to display the widget. Have a look a the diff --git a/widgets/src/index.ts b/widgets/src/index.ts index f173dadb..4e9d01ad 100644 --- a/widgets/src/index.ts +++ b/widgets/src/index.ts @@ -5,6 +5,8 @@ import { import { ICommandPalette } from '@jupyterlab/apputils'; +import { Message } from '@lumino/messaging'; + import { Widget } from '@lumino/widgets'; /** @@ -41,4 +43,67 @@ class ExampleWidget extends Widget { this.title.label = 'Widget Example View'; this.title.closable = true; } + + /** + * Event generic callback on an object as defined in the specification + * + * See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#the_event_listener_callback + */ + handleEvent(event: Event): void { + switch (event.type) { + case 'pointerenter': + this._onMouseEnter(event); + break; + case 'pointerleave': + this._onMouseLeave(event); + break; + } + } + + /** + * Callback when the widget is added to the DOM + * + * This is the recommended place to listen for DOM events + */ + protected onAfterAttach(msg: Message): void { + // The first two events are not linked to a specific callback but + // to this object. In that case, the object method `handleEvent` + // is the function called when an event occurs. + this.node.addEventListener('pointerenter', this); + this.node.addEventListener('pointerleave', this); + // This event will call a specific function when occuring + this.node.addEventListener('click', this._onEventClick.bind(this)); + } + + /** + * Callback when the widget is removed from the DOM + * + * This is the recommended place to stop listening for DOM events + */ + protected onBeforeDetach(msg: Message): void { + this.node.removeEventListener('pointerenter', this); + this.node.removeEventListener('pointerleave', this); + this.node.removeEventListener('click', this._onEventClick.bind(this)); + } + + /** + * Callback on click on the widget + */ + private _onEventClick(event: Event): void { + window.alert('You clicked on the widget'); + } + + /** + * Callback on pointer entering the widget + */ + private _onMouseEnter(event: Event): void { + this.node.style['backgroundColor'] = 'orange'; + } + + /** + * Callback on pointer leaving the widget + */ + private _onMouseLeave(event: Event): void { + this.node.style['backgroundColor'] = 'aliceblue'; + } } diff --git a/widgets/style/base.css b/widgets/style/base.css index ed27ec0b..177c164b 100644 --- a/widgets/style/base.css +++ b/widgets/style/base.css @@ -6,4 +6,5 @@ .jp-example-view { background-color: aliceblue; + cursor: pointer; } diff --git a/widgets/ui-tests/tests/widgets.spec.ts b/widgets/ui-tests/tests/widgets.spec.ts index fd6388db..63da19a5 100644 --- a/widgets/ui-tests/tests/widgets.spec.ts +++ b/widgets/ui-tests/tests/widgets.spec.ts @@ -10,7 +10,20 @@ test('should open a widget panel', async ({ page }) => { // Open a new tab from menu await page.menu.clickMenuItem('Widget Example>Open a Tab Widget'); - await page.click('div[role="main"] >> text=Widget Example View'); + let resolveAlert: (arg0: boolean) => void; + const gotAlerted = new Promise(resolve => { + resolveAlert = resolve; + }); + page.on('dialog', dialog => { + if (dialog.message() == 'You clicked on the widget') { + resolveAlert(true); + } + dialog.accept(); + }); + + await page.getByRole('main').getByLabel('Widget Example View').click(); + + expect(await gotAlerted).toEqual(true); expect(await page.screenshot()).toMatchSnapshot('widgets-example.png'); }); diff --git a/widgets/ui-tests/tests/widgets.spec.ts-snapshots/widgets-example-linux.png b/widgets/ui-tests/tests/widgets.spec.ts-snapshots/widgets-example-linux.png index 6fc3995a..0b4959cf 100644 Binary files a/widgets/ui-tests/tests/widgets.spec.ts-snapshots/widgets-example-linux.png and b/widgets/ui-tests/tests/widgets.spec.ts-snapshots/widgets-example-linux.png differ