Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add main area widget (form in headless mode) with state restoration #12

Merged
merged 13 commits into from
Oct 24, 2024
18 changes: 17 additions & 1 deletion schema/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,29 @@
{
"name": "deploy-app",
"command": "jhub-apps:deploy-app",
"args": {
"origin": "toolbar"
},
"label": "Deploy App"
}
]
},
"title": "jupyterlab-jhub-apps",
"description": "jupyterlab-jhub-apps custom command settings.",
"type": "object",
"properties": {},
"properties": {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved to properties as I was unable to find another way to update the settings values at runtime. This is useful for situations like testing, where I want to test different settings values. I took this approach from https://github.com/jupyterlab/extension-examples/tree/main/settings.

I also noticed that if a user updates their settings, then the application will not notice this. I have now used properties and settings.changed.connect below to handle this.

"baseUrl": {
"type": "string",
"title": "Base URL",
"description": "The base URL of the app deployment form.",
"default": "/services/japps/create-app"
},
"queryParameters": {
"type": "object",
"title": "Query Parameters",
"description": "Additional query parameters to be appended to the baseUrl. All keys and values must be strings.",
"default": { "headless": "true" }
}
},
"additionalProperties": false
}
47 changes: 44 additions & 3 deletions src/__tests__/jupyterlab_jhub_apps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,49 @@
* Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
*/

describe('jupyterlab-jhub-apps', () => {
it('should be tested', () => {
expect(1 + 1).toEqual(2);
import { coerceBooleanString, buildQueryString } from '../utils';

describe('coerceBooleanString', () => {
it('should return "true" for undefined input', () => {
expect(coerceBooleanString(undefined)).toBe('true');
});

it('should return "true" for input string "true"', () => {
expect(coerceBooleanString('true')).toBe('true');
});

it('should return "false" for input string "false"', () => {
expect(coerceBooleanString('false')).toBe('false');
});
});

describe('buildQueryString', () => {
it('should convert { headless: "true", baseUrl: "https://example.com" } to a query string', () => {
const params = { headless: 'true', baseUrl: 'https://example.com' };
const result = buildQueryString(params);
expect(result).toBe('headless=true&baseUrl=https%3A%2F%2Fexample.com');
});

it('should convert { headless: "false", baseUrl: "https://example.com" } to a query string', () => {
const params = { headless: 'false', baseUrl: 'https://example.com' };
const result = buildQueryString(params);
expect(result).toBe('headless=false&baseUrl=https%3A%2F%2Fexample.com');
});

it('should encode special characters in baseUrl', () => {
const params = {
headless: 'true',
baseUrl: 'https://example.com/search?q=Jest'
};
const result = buildQueryString(params);
expect(result).toBe(
'headless=true&baseUrl=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3DJest'
);
});

it('should return an empty string if no valid parameters are provided', () => {
const params = {};
const result = buildQueryString(params);
expect(result).toBe('');
});
});
259 changes: 217 additions & 42 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,244 @@
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
JupyterFrontEndPlugin,
ILayoutRestorer
} from '@jupyterlab/application';
import { deployAppIcon } from './icons';
import { DocumentWidget } from '@jupyterlab/docregistry';
import { WidgetTracker, IFrame, MainAreaWidget } from '@jupyterlab/apputils';
import { Widget } from '@lumino/widgets';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { coerceBooleanString, hasContextPath, buildQueryString } from './utils';

const DEFAULT_BASE_URL = '/services/japps/create-app';

const PLUGIN_ID = 'jupyterlab-jhub-apps:commands';

/**
* Settings for the runtime configuration of the application.
* These are read from the settings registry and updated any
* time a user changes their settings.
*/
const runtimeSettings: IDeployAppBaseConfig = {
queryParameters: {},
baseUrl: DEFAULT_BASE_URL
};

namespace CommandIDs {
/**
* Opens the URL to deploy the application with pre-populated fields
* Opens the URL for deploying an app with pre-populated fields.
*/
export const deployApp = 'jhub-apps:deploy-app';
}

interface IDeployAppArgs {
interface IDeployAppBaseConfig {
/**
* The origin of the command e.g. main-menu, context-menu, etc.
* The base URL of the app deployment form.
*/
baseUrl?: string;
/**
* Additional query parameters to be appended to the baseUrl.
*/
queryParameters?: {
headless?: string;
filepath?: string;
};
}

interface IDeployAppConfig extends IDeployAppBaseConfig {
/**
* The origin of the command e.g. main-menu, context-menu, etc.,
* which determines the icon and label to display.
*/
origin?: string;
IShell?: JupyterFrontEnd.IShell;
widgetTracker?: WidgetTracker<MainAreaWidget<DeployAppWidget>>;
}

interface IDeployAppWidgetArgs extends IDeployAppBaseConfig {}

function applySettings(setting: ISettingRegistry.ISettings): void {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the callback used by settings.changed.connect. It closes over the global state runtimeSettings and updates these values.

runtimeSettings.queryParameters = setting.get('queryParameters')
.composite as IDeployAppBaseConfig['queryParameters'];
runtimeSettings.baseUrl = setting.get('baseUrl')
.composite as IDeployAppBaseConfig['baseUrl'];
}

const calculateIcon = (args: IDeployAppConfig) => {
switch (args.origin) {
case 'main-menu':
return undefined;
case 'context-menu':
case 'toolbar':
case undefined:
default:
return deployAppIcon;
}
};

const openAppForm = ({
origin,
baseUrl,
IShell,
widgetTracker,
queryParameters = {}
}: IDeployAppConfig): void => {
if (!IShell || !widgetTracker) {
console.warn(
'IShell and widgetTracker not defined. These are required to open the deploy app form.'
);
return;
}

const currentWidget = IShell.currentWidget;

const filepath = hasContextPath(currentWidget)
? currentWidget.context.path
: '';

const selectedFilepath =
queryParameters.filepath !== undefined
? queryParameters.filepath
: filepath;

const headless = coerceBooleanString(queryParameters.headless);

const updatedQueryParameters = {
...queryParameters,
headless: headless,
filepath: selectedFilepath
};

let mainAreaWidget: MainAreaWidget<DeployAppWidget> | undefined;

if (headless === 'true') {
try {
if (!mainAreaWidget || mainAreaWidget.isDisposed) {
const content = new DeployAppWidget({
baseUrl: baseUrl,
queryParameters: updatedQueryParameters
});
mainAreaWidget = new MainAreaWidget({ content });
}

if (!widgetTracker.has(mainAreaWidget)) {
widgetTracker.add(mainAreaWidget);
}

// non-empty origin implies user has called command e.g. main-menu click.
// guards against (what appears to be) command execution from tracker
// on session restore.
if (origin && !mainAreaWidget.isAttached) {
IShell.add(mainAreaWidget, 'main');
}
} catch (error) {
console.warn(`Error opening in headless mode: ${error}`);
}
} else {
try {
// no restore logic required here as opening in a separate window
const completeUrl = `${baseUrl}?${buildQueryString(updatedQueryParameters)}`;
window.open(completeUrl, '_blank', 'noopener,noreferrer');
} catch (error) {
console.warn(`Error opening in window: ${error}`);
}
}
};

const jhubAppsPlugin: JupyterFrontEndPlugin<void> = {
id: 'jupyterlab-jhub-apps:commands',
id: PLUGIN_ID,
description: 'Adds additional commands used by jhub-apps.',
autoStart: true,
activate: (app: JupyterFrontEnd) => {
const openURL = (url: string) => {
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch (error) {
console.warn(`Error opening ${url}: ${error}`);
}
};

const calculateIcon = (args: IDeployAppArgs) => {
switch (args.origin) {
case 'main-menu':
return undefined;
case 'context-menu':
case undefined:
default:
return deployAppIcon;
}
};

app.commands.addCommand(CommandIDs.deployApp, {
execute: () => {
const currentWidget = app.shell.currentWidget;
const currentNotebookPath =
currentWidget && currentWidget instanceof DocumentWidget
? currentWidget.context.path
: '';
let deployUrl;
if (currentNotebookPath !== '') {
deployUrl = `/services/japps/create-app?filepath=${encodeURIComponent(currentNotebookPath)}`;
} else {
deployUrl = '/services/japps/create-app';
}
openURL(deployUrl);
},
requires: [ISettingRegistry],
optional: [ILayoutRestorer],
activate: async (
app: JupyterFrontEnd,
settingsRegistry: ISettingRegistry,
restorer: ILayoutRestorer | null
) => {
const { commands, shell } = app;

try {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines 205 - 211 are the new code that try to deal with when a user updates their settings.

const baseSettings = await settingsRegistry.load(PLUGIN_ID);
applySettings(baseSettings);
baseSettings.changed.connect(applySettings);
} catch (error) {
console.warn(`Failed to load settings for ${PLUGIN_ID}:`, error);
return;
}

const tracker = new WidgetTracker<MainAreaWidget<DeployAppWidget>>({
namespace: 'jhub-apps'
});

commands.addCommand(CommandIDs.deployApp, {
label: 'Deploy App',
icon: calculateIcon
icon: calculateIcon,
execute: ({
origin = undefined,
queryParameters = runtimeSettings.queryParameters,
baseUrl = runtimeSettings.baseUrl,
IShell = shell,
widgetTracker = tracker
}: IDeployAppConfig) => {
openAppForm({
origin,
queryParameters,
baseUrl,
IShell,
widgetTracker
});
}
});
if (restorer) {
restorer.restore(tracker, {
command: CommandIDs.deployApp,
args: widget => ({
baseUrl: widget.content.getBaseUrl(),
queryParameters: widget.content.getQueryParameters()
}),
name: widget => widget.content.getCompleteUrl()
});
}
}
};

const plugins = [jhubAppsPlugin];

export default plugins;

class DeployAppWidget extends Widget {
private _iframe: IFrame;
private _baseUrl: string;
private _queryParameters: Record<string, string>;

constructor({
baseUrl = DEFAULT_BASE_URL,
queryParameters = {}
}: IDeployAppWidgetArgs) {
super();
this._baseUrl = baseUrl;
this._queryParameters = queryParameters;

this.addClass('jp-deploy-app-widget');
this.id = 'deploy-app-jupyterlab';
this.title.label = 'Deploy App';
this.title.closable = true;

this._iframe = new IFrame();
this._iframe.sandbox = ['allow-scripts', 'allow-same-origin'];
this._iframe.url = this.getCompleteUrl();
this.node.appendChild(this._iframe.node);
}

getQueryParameters(): Record<string, string> {
return this._queryParameters;
}

getBaseUrl(): string {
return this._baseUrl;
}

getCompleteUrl(): string {
return `${this._baseUrl}?${buildQueryString(this._queryParameters)}`;
}
}
Loading
Loading