-
Notifications
You must be signed in to change notification settings - Fork 1
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
Changes from all commits
88911c2
3c32f39
8218b42
60a0db1
9d9e6a9
ed7923f
0c3ac89
e5f4ff6
8895f62
158c3d9
968b5b4
a94f3a2
f3bc252
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the callback used by |
||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)}`; | ||
} | ||
} |
There was a problem hiding this comment.
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
andsettings.changed.connect
below to handle this.