Skip to content

Commit

Permalink
Merge pull request #47 from vivek1729/vipradha/customWidgets
Browse files Browse the repository at this point in the history
feat: Add support for custom widgets
  • Loading branch information
captainsafia authored Dec 3, 2020
2 parents 0074fa7 + e693ef4 commit 3353058
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 25 deletions.
22 changes: 21 additions & 1 deletion packages/jupyter-widgets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,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<any>;
```

### 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
<script data-jupyter-widgets-cdn="https://cdn.jsdelivr.net/npm" src="bundle.js"></script>
```
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

Expand Down
78 changes: 70 additions & 8 deletions packages/jupyter-widgets/__tests__/manager/manager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
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",
"1.5.0"
);
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 [email protected]");
});
});
});

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",
Expand Down Expand Up @@ -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"
Expand All @@ -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);
});
});
63 changes: 63 additions & 0 deletions packages/jupyter-widgets/__tests__/manager/widget-loader.spec.ts
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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/[email protected]/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!");
});
});
});
2 changes: 2 additions & 0 deletions packages/jupyter-widgets/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface Props {
| null;
id: CellId;
contentRef: ContentRef;
customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise<any>;
}

interface State {
Expand Down Expand Up @@ -72,6 +73,7 @@ export class WidgetDisplay extends React.Component<Props, State> {
contentRef={this.props.contentRef}
modelById={this.props.modelById}
kernel={this.props.kernel}
customWidgetLoader={this.props.customWidgetLoader}
/>
);
} else {
Expand Down
4 changes: 3 additions & 1 deletion packages/jupyter-widgets/src/manager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface OwnProps {
model_id: string;
id: CellId;
contentRef: ContentRef;
customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise<any>;
}

type Props = ConnectedProps & OwnProps & ManagerActions;
Expand Down Expand Up @@ -68,7 +69,8 @@ export class Manager extends React.Component<Props> {
Manager.manager = new WidgetManager(
this.props.kernel,
this.props.modelById,
this.props.actions
this.props.actions,
this.props.customWidgetLoader
);
} else {
Manager.manager.update(
Expand Down
4 changes: 2 additions & 2 deletions packages/jupyter-widgets/src/manager/widget-comms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,13 @@ export function request_state(kernel: any, comm_id: string): Promise<any> {
.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();
Expand Down
108 changes: 108 additions & 0 deletions packages/jupyter-widgets/src/manager/widget-loader.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<any> {
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]);
}
}
Loading

0 comments on commit 3353058

Please sign in to comment.