diff --git a/packages/jupyter-widgets/README.md b/packages/jupyter-widgets/README.md index 1dcf145..a208554 100644 --- a/packages/jupyter-widgets/README.md +++ b/packages/jupyter-widgets/README.md @@ -28,7 +28,27 @@ export default class MyNotebookApp extends ReactComponent { ## Documentation -We're working on adding more documentation for this component. Stay tuned by watching this repository! +The `jupyter-widgets` package supports two types of widgets: +- Standard widgets provided in the official [`jupyter-widgets/base`](https://www.npmjs.com/package/@jupyter-widgets/base) and [`jupyter-widgets/controls`](https://www.npmjs.com/package/@jupyter-widgets/controls) package +- [Custom Widgets](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Custom.html) or 3rd party widgets authored by the OSS community + +The `WidgetDisplay` component has an additional prop named `customWidgetLoader` to provide custom loaders for fetching 3rd party widgets. A reference implementation for a custom loader which serves as the default for this package can be found in `widget-loader.ts`. + +```typescript +customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise; +``` + +### Custom Widgets + +Since custom widgets are hosted on CDN, we set https://unkpg.com as our default CDN Base URL. The default base URL can be overriden by specifying another URL via the HTML attribute "data-jupyter-widgets-cdn" on any script tag of the page. + +For instance if your JavaScript bundle is loaded as `bundle.js` on your page and you wanted to set [jsdelivr](https://www.jsdelivr.com) as your default CDN url for custom widgets, you could do the following: +```html + +``` +Note: Custom widgets are fetched and loaded using the [requireJS](https://requirejs.org/) library. Please ensure that the library is loaded on your page and that the `require` and `define` APIs are available on the `window` object. We attempt to detect the presence of these APIs and emit a warning that custom widgets won't work when `requirejs` is missing. + + ## Support diff --git a/packages/jupyter-widgets/__tests__/manager/manager.spec.ts b/packages/jupyter-widgets/__tests__/manager/manager.spec.ts index bf79e21..5fcff42 100644 --- a/packages/jupyter-widgets/__tests__/manager/manager.spec.ts +++ b/packages/jupyter-widgets/__tests__/manager/manager.spec.ts @@ -1,13 +1,41 @@ import { IntSliderView } from "@jupyter-widgets/controls"; import { Map } from "immutable"; +import { ManagerActions } from "../../src/manager/index"; import { WidgetManager } from "../../src/manager/widget-manager"; +import * as customWidgetLoader from "../../src/manager/widget-loader"; + +// A mock valid module representing a custom widget +const mockFooModule = { + "foo" : "bar" +}; +// Mock implementation of the core require API +const mockRequireJS = jest.fn((modules, ready, errCB) => ready(mockFooModule)); +(window as any).requirejs = mockRequireJS; +(window as any).requirejs.config = jest.fn(); + +// Manager actions passed as the third arg when instantiating the WidgetManager class +const mockManagerActions: ManagerActions["actions"] = { + appendOutput: jest.fn(), + clearOutput: jest.fn(), + updateCellStatus: jest.fn(), + promptInputRequest: jest.fn() +}; + +// Default modelById stub +const mockModelById = (id: string) => undefined; describe("WidgetManager", () => { describe("loadClass", () => { + beforeAll(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it("returns a class if it exists", () => { - const modelById = (id: string) => undefined; - const manager = new WidgetManager(null, modelById); + const manager = new WidgetManager(null, mockModelById, mockManagerActions); const view = manager.loadClass( "IntSliderView", "@jupyter-widgets/controls", @@ -15,11 +43,46 @@ describe("WidgetManager", () => { ); expect(view).not.toBe(null); }); + + it("Returns a valid module class successfully from CDN for custom widgets", () => { + const manager = new WidgetManager(null, mockModelById, mockManagerActions); + const requireLoaderSpy = jest.spyOn(customWidgetLoader, "requireLoader"); + + return manager.loadClass( + "foo", + "fooModule", + "1.1.0" + ).then(view => { + expect(requireLoaderSpy).toHaveBeenCalledTimes(1); + // Get the second arg to Monaco.editor.create call + const mockLoaderArgs = requireLoaderSpy.mock.calls[0]; + expect(mockLoaderArgs).not.toBe(null); + expect(mockLoaderArgs.length).toBe(2); + expect(mockLoaderArgs[0]).toBe("fooModule"); + expect(mockLoaderArgs[1]).toBe("1.1.0"); + expect(view).not.toBe(null); + expect(view).toBe(mockFooModule["foo"]); + }); + }); + + it("Returns an error if the class does not exist on the module", () => { + const manager = new WidgetManager(null, mockModelById, mockManagerActions); + const requireLoaderSpy = jest.spyOn(customWidgetLoader, "requireLoader"); + + return manager.loadClass( + "INVALID_CLASS", + "fooModule", + "1.1.0" + ).catch(error => { + expect(requireLoaderSpy).toHaveBeenCalledTimes(1); + expect(error).toBe("Class INVALID_CLASS not found in module fooModule@1.1.0"); + }); + }); }); + describe("create_view", () => { it("returns a widget mounted on the provided element", async () => { - const modelById = (id: string) => undefined; - const manager = new WidgetManager(null, modelById); + const manager = new WidgetManager(null, mockModelById, mockManagerActions); const model = { _dom_classes: [], _model_module: "@jupyter-widgets/controls", @@ -99,7 +162,7 @@ describe("WidgetManager", () => { const model = id === "layout_id" ? layoutModel : styleModel; return Promise.resolve(Map({ state: Map(model) })); }; - const manager = new WidgetManager(null, modelById); + const manager = new WidgetManager(null, modelById, mockManagerActions); const widget = await manager.new_widget_from_state_and_id( model, "test_model_id" @@ -119,11 +182,10 @@ describe("WidgetManager", () => { }); }); it("can update class properties via method", () => { - const modelById = (id: string) => undefined; - const manager = new WidgetManager(null, modelById); + const manager = new WidgetManager(null, mockModelById, mockManagerActions); expect(manager.kernel).toBeNull(); const newKernel = { channels: { next: jest.fn() } }; - manager.update(newKernel, modelById, {}); + manager.update(newKernel, mockModelById, mockManagerActions); expect(manager.kernel).toBe(newKernel); }); }); diff --git a/packages/jupyter-widgets/__tests__/manager/widget-loader.spec.ts b/packages/jupyter-widgets/__tests__/manager/widget-loader.spec.ts new file mode 100644 index 0000000..60ba6c6 --- /dev/null +++ b/packages/jupyter-widgets/__tests__/manager/widget-loader.spec.ts @@ -0,0 +1,63 @@ +import { requireLoader } from "../../src/manager/widget-loader"; + +// A mock valid module representing a custom widget +const mockModule = { + "foo" : "bar" +}; +// Info representing an invalid module for testing the failure case +const invalidModule = { + name: "invalid_module", + version: "1.0", + url: "https://unpkg.com/invalid_module@1.0/dist/index.js" +}; +// Mock implementation of the core require API +const mockRequireJS = jest.fn((modules, ready, errCB) => { + if(modules.length > 0 && modules[0] === invalidModule.url){ + errCB(new Error("Whoops!")); + } + else { + ready(mockModule); + } +}); + +// Callback binding +(window as any).requirejs = mockRequireJS; +(window as any).requirejs.config = jest.fn(); + +describe("requireLoader", () => { + beforeAll(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it("Returns a module if linked to a valid CDN URL", () => { + return requireLoader("foo", "1.0.0").then(mod => { + expect(mockRequireJS).toHaveBeenCalledTimes(1); + const moduleURLs = mockRequireJS.mock.calls[0][0]; + expect(moduleURLs).not.toBe(null); + expect(moduleURLs.length).toBe(1); + expect(moduleURLs[0]).toBe("https://unpkg.com/foo@1.0.0/dist/index.js"); + expect(mod).toEqual(mockModule); + }); + }); + + it("Returns a module even when module version is missing", () => { + return requireLoader("foo", undefined).then(mod => { + expect(mockRequireJS).toHaveBeenCalledTimes(1); + const moduleURLs = mockRequireJS.mock.calls[0][0]; + expect(moduleURLs).not.toBe(null); + expect(moduleURLs.length).toBe(1); + expect(moduleURLs[0]).toBe("https://unpkg.com/foo/dist/index.js"); + expect(mod).toEqual(mockModule); + }); + }); + + it("Calls the error callback if an error is encountered during the module loading", () => { + const {name, version} = invalidModule; + return requireLoader(name, version).catch((error: Error) => { + expect(mockRequireJS).toHaveBeenCalledTimes(1); + expect(error.message).toBe("Whoops!"); + }); + }); +}); diff --git a/packages/jupyter-widgets/src/index.tsx b/packages/jupyter-widgets/src/index.tsx index 13c252d..5965a9c 100644 --- a/packages/jupyter-widgets/src/index.tsx +++ b/packages/jupyter-widgets/src/index.tsx @@ -30,6 +30,7 @@ interface Props { | null; id: CellId; contentRef: ContentRef; + customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise; } interface State { @@ -72,6 +73,7 @@ export class WidgetDisplay extends React.Component { contentRef={this.props.contentRef} modelById={this.props.modelById} kernel={this.props.kernel} + customWidgetLoader={this.props.customWidgetLoader} /> ); } else { diff --git a/packages/jupyter-widgets/src/manager/index.tsx b/packages/jupyter-widgets/src/manager/index.tsx index b7b4561..975a9a1 100644 --- a/packages/jupyter-widgets/src/manager/index.tsx +++ b/packages/jupyter-widgets/src/manager/index.tsx @@ -37,6 +37,7 @@ interface OwnProps { model_id: string; id: CellId; contentRef: ContentRef; + customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise; } type Props = ConnectedProps & OwnProps & ManagerActions; @@ -68,7 +69,8 @@ class Manager extends React.Component { Manager.manager = new WidgetManager( this.props.kernel, this.props.modelById, - this.props.actions + this.props.actions, + this.props.customWidgetLoader ); } else { Manager.manager.update( diff --git a/packages/jupyter-widgets/src/manager/widget-comms.ts b/packages/jupyter-widgets/src/manager/widget-comms.ts index 2dea0f5..9858aa9 100644 --- a/packages/jupyter-widgets/src/manager/widget-comms.ts +++ b/packages/jupyter-widgets/src/manager/widget-comms.ts @@ -205,13 +205,13 @@ export function request_state(kernel: any, comm_id: string): Promise { .pipe(childOf(message)) .subscribe((reply: any) => { // if we get a comm message back, it is the state we requested - if (reply.msg_type === "comm_msg") { + if (reply.header?.msg_type === "comm_msg") { replySubscription.unsubscribe(); return resolve(reply); } // otherwise, if we havent gotten a comm message and it goes idle, it wasn't found else if ( - reply.msg_type === "status" && + reply.header?.msg_type === "status" && reply.content.execution_state === "idle" ) { replySubscription.unsubscribe(); diff --git a/packages/jupyter-widgets/src/manager/widget-loader.ts b/packages/jupyter-widgets/src/manager/widget-loader.ts new file mode 100644 index 0000000..4054f2a --- /dev/null +++ b/packages/jupyter-widgets/src/manager/widget-loader.ts @@ -0,0 +1,108 @@ +/** + * Several functions in this file are based off the html-manager in jupyter-widgets project - + * https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/html-manager/src/libembed-amd.ts + */ + +import * as base from "@jupyter-widgets/base"; +import * as controls from "@jupyter-widgets/controls"; + +const requireJSMissingErrorMessage = "Requirejs is needed, please ensure it is loaded on the page. Docs - https://requirejs.org/docs/api.html"; +let cdn = "https://unpkg.com"; + +/** + * Constructs a well formed module URL for requireJS + * mapping the modulename and version from the base CDN URL + * @param moduleName Name of the module corresponding to the widget package + * @param moduleVersion Module version returned from kernel + */ +function moduleNameToCDNUrl(moduleName: string, moduleVersion: string): string { + let packageName = moduleName; + let fileName = "index.js"; // default filename + // if a '/' is present, like 'foo/bar', packageName is changed to 'foo', and path to 'bar' + // We first find the first '/' + let index = moduleName.indexOf('/'); + if (index !== -1 && moduleName[0] === '@') { + // if we have a namespace, it's a different story + // @foo/bar/baz should translate to @foo/bar and baz + // so we find the 2nd '/' + index = moduleName.indexOf('/', index + 1); + } + if (index !== -1) { + fileName = moduleName.substr(index + 1); + packageName = moduleName.substr(0, index); + } + const moduleNameString = moduleVersion ? `${packageName}@${moduleVersion}` : packageName; + return `${cdn}/${moduleNameString}/dist/${fileName}`; +} + +/** + * Load a package using requirejs and return a promise + * + * @param pkg Package name or names to load + */ +function requirePromise(pkg: string | string[]): Promise { + return new Promise((resolve, reject) => { + const require = (window as any).requirejs; + if (require === undefined) { + reject(requireJSMissingErrorMessage); + } + else { + // tslint:disable-next-line: non-literal-require + require(pkg, resolve, reject); + } + }); +}; + +/** + * Initialize dependencies that need to be preconfigured for requireJS module loading + * Here, we define the jupyter-base, controls package that most 3rd party widgets depend on + */ +export function initRequireDeps(){ + // Export the following for `requirejs`. + // tslint:disable-next-line: no-any no-function-expression no-empty + const define = (window as any).define || function () {}; + define("@jupyter-widgets/controls", () => controls); + define("@jupyter-widgets/base", () => base); +} + +/** + * Overrides the default CDN base URL by querying the DOM for script tags + * By default, the CDN service used is unpkg.com. However, this default can be + * overriden by specifying another URL via the HTML attribute + * "data-jupyter-widgets-cdn" on a script tag of the page. + */ +export function overrideCDNBaseURL(){ + // find the data-cdn for any script tag + const scripts = document.getElementsByTagName("script"); + Array.prototype.forEach.call(scripts, (script: HTMLScriptElement) => { + cdn = script.getAttribute("data-jupyter-widgets-cdn") || cdn; + }); + // Remove Single/consecutive trailing slashes from the URL to sanitize it + cdn = cdn.replace(/\/+$/, ""); +} + +/** + * Load an amd module from a specified CDN + * + * @param moduleName The name of the module to load. + * @param moduleVersion The semver range for the module, if loaded from a CDN. + * + * By default, the CDN service used is unpkg.com. However, this default can be + * overriden by specifying another URL via the HTML attribute + * "data-jupyter-widgets-cdn" on a script tag of the page. + * + * The semver range is only used with the CDN. + */ +export function requireLoader(moduleName: string, moduleVersion: string): Promise { + const require = (window as any).requirejs; + if (require === undefined) { + return Promise.reject(new Error(requireJSMissingErrorMessage)); + } + else { + const conf: { paths: { [key: string]: string } } = { paths: {} }; + const moduleCDN = moduleNameToCDNUrl(moduleName, moduleVersion); + conf.paths[moduleName] = moduleCDN; + require.config(conf); + return requirePromise([moduleCDN]); + } +} diff --git a/packages/jupyter-widgets/src/manager/widget-manager.ts b/packages/jupyter-widgets/src/manager/widget-manager.ts index d91409f..b1ba761 100644 --- a/packages/jupyter-widgets/src/manager/widget-manager.ts +++ b/packages/jupyter-widgets/src/manager/widget-manager.ts @@ -16,6 +16,7 @@ import { } from "@nteract/core"; import { JupyterMessage } from "@nteract/messaging"; import { ManagerActions } from "../manager/index"; +import * as widgetLoader from "./widget-loader"; interface IDomWidgetModel extends DOMWidgetModel { _model_name: string; @@ -41,17 +42,23 @@ export class WidgetManager extends base.ManagerBase { | null; actions: ManagerActions["actions"]; widgetsBeingCreated: { [model_id: string]: Promise }; + customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise; constructor( kernel: any, stateModelById: (id: string) => any, - actions: ManagerActions["actions"] + actions: ManagerActions["actions"], + customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise ) { super(); this.kernel = kernel; this.stateModelById = stateModelById; this.actions = actions; this.widgetsBeingCreated = {}; + this.customWidgetLoader = customWidgetLoader; + // Setup for supporting 3rd party widgets + widgetLoader.initRequireDeps(); // define jupyter-widgets base package for requirejs + widgetLoader.overrideCDNBaseURL(); // Override default CDN URL for fetching widgets } update( @@ -67,18 +74,19 @@ export class WidgetManager extends base.ManagerBase { /** * Load a class and return a promise to the loaded object. */ - loadClass(className: string, moduleName: string, moduleVersion: string): any { - return new Promise(function(resolve, reject) { - if (moduleName === "@jupyter-widgets/controls") { - resolve(controls); - } else if (moduleName === "@jupyter-widgets/base") { - resolve(base); - } else { - return Promise.reject( - `Module ${moduleName}@${moduleVersion} not found` - ); - } - }).then(function(module: any) { + loadClass(className: string, moduleName: string, moduleVersion: string): Promise { + const customWidgetLoader = this.customWidgetLoader ?? widgetLoader.requireLoader; + + let widgetPromise: Promise; + if (moduleName === "@jupyter-widgets/controls") { + widgetPromise = Promise.resolve(controls); + } else if (moduleName === "@jupyter-widgets/base") { + widgetPromise = Promise.resolve(base); + } else { + widgetPromise = customWidgetLoader(moduleName, moduleVersion); + } + + return widgetPromise.then(function(module: any) { if (module[className]) { return module[className]; } else { @@ -86,6 +94,8 @@ export class WidgetManager extends base.ManagerBase { `Class ${className} not found in module ${moduleName}@${moduleVersion}` ); } + }).catch(function(err: Error) { + console.warn(err.message); }); }