diff --git a/.gitignore b/.gitignore index 61233810..61a1afa9 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ dev/*.ipynb # Jest coverage reports and a side effect coverage junit.xml + +# Interfaces generated from json-schema +src/workflows/_interface diff --git a/package.json b/package.json index 8c60bbe5..f92e21e1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build:labextension": "jupyter labextension build .", "build:labextension:dev": "jupyter labextension build --development True .", "build:lib": "tsc", + "build:schema:js": "json2ts -i src/workflows/schema -o src/workflows/_interface --no-unknownAny --unreachableDefinitions --cwd ./src/workflows/schema", "clean": "jlpm clean:lib", "clean:lib": "rimraf lib tsconfig.tsbuildinfo", "clean:lintcache": "rimraf .eslintcache .stylelintcache", @@ -74,6 +75,7 @@ "@mui/system": "^5.10.6", "@types/react-dom": "^18.0.5", "cronstrue": "^2.12.0", + "json-schema-to-typescript": "^15.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "tzdata": "^1.0.33" diff --git a/src/index.tsx b/src/index.tsx index fe7a71ec..9281ef5f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,6 +27,8 @@ import { NotebookJobsPanel } from './notebook-jobs-panel'; import { Scheduler } from './tokens'; import { SERVER_EXTENSION_404_JSX } from './util/errors'; import { MakeNameValid } from './util/job-name-validation'; +import { WorkflowModelFactory } from './workflows/workflowModel'; +import { WorkflowWidgetFactory } from './workflows/workflowWidgetFactory'; export namespace CommandIDs { export const deleteJob = 'scheduling:delete-job'; @@ -194,10 +196,58 @@ function activatePlugin( telemetryHandler: Scheduler.TelemetryHandler, launcher: ILauncher | null ): void { + // Hardcoded boolean for testing. If true, set up workflow widget instead of scheduler UI + const showWorkflowsWidget = true; + const trans = translator.load('jupyterlab'); const api = new SchedulerService({}); verifyServerExtension({ api, translator }); + if (showWorkflowsWidget) { + const WORKFLOW_FACTORY = 'Workflow Editor'; + const WORKFLOW_CONTENT_TYPE = 'workflow'; + const WORKFLOW_FILE_EXT = '.jwf'; + + // Register the workflow file type + app.docRegistry.addFileType({ + name: WORKFLOW_CONTENT_TYPE, + displayName: 'Workflow File', + extensions: [WORKFLOW_FILE_EXT], + fileFormat: 'text', + contentType: 'file', + mimeTypes: ['application/json'] + }); + + // Register the workflow model factory + const modelFactory = new WorkflowModelFactory(); + app.docRegistry.addModelFactory(modelFactory); + + // Register the workflow widget factory + const widgetFactory = new WorkflowWidgetFactory({ + name: WORKFLOW_FACTORY, + modelName: modelFactory.name, + fileTypes: [WORKFLOW_CONTENT_TYPE], + defaultFor: [WORKFLOW_CONTENT_TYPE] + }); + app.docRegistry.addWidgetFactory(widgetFactory); + + // Create a new untitled .jwf file and open it + void app.commands + .execute('docmanager:new-untitled', { + type: 'file', + ext: '.jwf' + }) + .then(model => { + if (model) { + void app.commands.execute('docmanager:open', { + path: model.path + }); + } + }); + + return; + } + const { commands } = app; const fileBrowserTracker = browserFactory.tracker; const widgetTracker = new WidgetTracker>({ diff --git a/src/workflows/interfaces.ts b/src/workflows/interfaces.ts new file mode 100644 index 00000000..6ebd010b --- /dev/null +++ b/src/workflows/interfaces.ts @@ -0,0 +1,21 @@ +import { DocumentChange, StateChange, YDocument } from '@jupyter/ydoc'; +import { ISignal } from '@lumino/signaling'; + +export interface IWorkflowDoc extends YDocument { + name: string; + + getName(): string | undefined; + setName(name: string): void; + + nameChanged: ISignal; +} + +export interface IWorkflowDocChange extends DocumentChange { + nameChange?: StringChange; + stateChange?: StateChange[]; +} + +export type StringChange = { + oldValue?: string; + newValue?: string; +}; diff --git a/src/workflows/schema/workflow.schema.json b/src/workflows/schema/workflow.schema.json new file mode 100644 index 00000000..c9b8aac5 --- /dev/null +++ b/src/workflows/schema/workflow.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Workflow", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the workflow." + } + } +} diff --git a/src/workflows/workflowDoc.ts b/src/workflows/workflowDoc.ts new file mode 100644 index 00000000..14a397b4 --- /dev/null +++ b/src/workflows/workflowDoc.ts @@ -0,0 +1,54 @@ +import { YDocument } from '@jupyter/ydoc'; +import * as Y from 'yjs'; +import { IWorkflowDoc, IWorkflowDocChange, StringChange } from './interfaces'; +import { ISignal, Signal } from '@lumino/signaling'; + +export class WorkflowDoc + extends YDocument + implements IWorkflowDoc +{ + constructor() { + super(); + + this._name = this.ydoc.getText('name'); + this._previousName = this._name.toString(); + this._name.observe(this._nameObserver); + } + + private _nameObserver = (event: Y.YTextEvent): void => { + const oldValue = this._previousName; + const newValue = this._name.toString(); + + this._previousName = newValue; + + this._nameChanged.emit({ oldValue, newValue }); + }; + + get nameChanged(): ISignal { + return this._nameChanged; + } + + get name(): string { + return this._name.toString(); + } + + get version(): string { + return '0.0.1'; + } + + getName(): string | undefined { + return this.name; + } + + setName(name: string): void { + const currentLength = this._name.length; + if (currentLength > 0) { + this._name.delete(0, currentLength); + } + this._name.insert(0, name); + } + + private _name: Y.Text; + private _previousName: string; + private _nameChanged = new Signal(this); +} diff --git a/src/workflows/workflowModel.ts b/src/workflows/workflowModel.ts new file mode 100644 index 00000000..ae2875ce --- /dev/null +++ b/src/workflows/workflowModel.ts @@ -0,0 +1,170 @@ +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { PartialJSONObject } from '@lumino/coreutils'; +import { ISignal, Signal } from '@lumino/signaling'; +import { IWorkflowDoc } from './interfaces'; +import { WorkflowDoc } from './workflowDoc'; +import { Contents } from '@jupyterlab/services'; + +export interface IWorkflowModel extends DocumentRegistry.IModel { + sharedModel: IWorkflowDoc; +} + +export class WorkflowModel implements IWorkflowModel { + constructor(options: DocumentRegistry.IModelOptions) { + this._sharedModel = options.sharedModel ?? this.createSharedModel(); + this._isDisposed = false; + this._dirty = false; + this._readOnly = false; + + // Listen to changes on the shared model + this._sharedModel.nameChanged.connect(this._onNameChanged, this); + } + + /** + * Create a default shared model if one is not provided. + */ + protected createSharedModel(): IWorkflowDoc { + return new WorkflowDoc(); + } + + get sharedModel(): IWorkflowDoc { + return this._sharedModel; + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + get dirty(): boolean { + return this._dirty; + } + + set dirty(value: boolean) { + this._dirty = value; + } + + get readOnly(): boolean { + return this._readOnly; + } + + set readOnly(value: boolean) { + this._readOnly = value; + } + + get contentChanged(): ISignal { + return this._contentChanged; + } + + get stateChanged(): ISignal { + return this._stateChanged; + } + + /** + * Convert the model to string (JSON in this case). + * We only have a `name` field, so just return a JSON string with that. + */ + toString(): string { + const data = { name: this.sharedModel.getName() }; + return JSON.stringify(data, null, 2); + } + + /** + * Load from a string. Assume it’s JSON with a `name` field. + */ + fromString(data: string): void { + const jsonData = JSON.parse(data); + if (jsonData.name && typeof jsonData.name === 'string') { + this.sharedModel.transact(() => { + this.sharedModel.setName(jsonData.name); + }); + this.dirty = true; + this._contentChanged.emit(void 0); + } + } + + toJSON(): PartialJSONObject { + return JSON.parse(this.toString()); + } + + fromJSON(data: PartialJSONObject): void { + if (data.name && typeof data.name === 'string') { + this.sharedModel.transact(() => { + this.sharedModel.setName(data.name as string); + }); + this.dirty = true; + this._contentChanged.emit(void 0); + } + } + + initialize(): void { + // No initialization needed for this simple example + } + + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + + // Disconnect signals + this._sharedModel.nameChanged.disconnect(this._onNameChanged, this); + + Signal.clearData(this); + } + + private _onNameChanged(): void { + this.dirty = true; + this._contentChanged.emit(void 0); + } + + private _sharedModel: IWorkflowDoc; + private _dirty: boolean; + private _readOnly: boolean; + private _isDisposed: boolean; + + private _contentChanged = new Signal(this); + private _stateChanged = new Signal(this); + + readonly defaultKernelName = ''; + readonly defaultKernelLanguage = ''; +} + +export class WorkflowModelFactory + implements DocumentRegistry.IModelFactory +{ + //TODO: set conditionally + readonly collaborative = true; + + get name(): string { + return 'workflow-model-factory'; + } + + get contentType(): Contents.ContentType { + return 'file'; + } + + get fileFormat(): Contents.FileFormat { + return 'text'; + } + + get isDisposed(): boolean { + return this._disposed; + } + + dispose(): void { + this._disposed = true; + } + + preferredLanguage(path: string): string { + return ''; + } + + createNew( + options: DocumentRegistry.IModelOptions + ): WorkflowModel { + const model = new WorkflowModel(options); + return model; + } + + private _disposed = false; +} diff --git a/src/workflows/workflowWidgetFactory.ts b/src/workflows/workflowWidgetFactory.ts new file mode 100644 index 00000000..3456109f --- /dev/null +++ b/src/workflows/workflowWidgetFactory.ts @@ -0,0 +1,82 @@ +import { + ABCWidgetFactory, + DocumentRegistry, + DocumentWidget +} from '@jupyterlab/docregistry'; +import { Widget } from '@lumino/widgets'; +import { IWorkflowModel } from './workflowModel'; + +export class WorkflowWidget extends Widget { + constructor(context: DocumentRegistry.IContext) { + super(); + this.addClass('jp-WorkflowWidget'); + this._context = context; + + this._input = document.createElement('input'); + this._input.type = 'text'; + this._input.value = this._context.model.sharedModel.getName() ?? ''; + + this._input.addEventListener('input', () => { + // Update the shared model when the user types + this._context.model.sharedModel.transact(() => { + this._context.model.sharedModel.setName(this._input.value); + }); + }); + + this.node.appendChild(this._input); + + // Listen for remote changes + this._context.model.sharedModel.nameChanged.connect( + this._onNameChanged, + this + ); + + // Listen to contentChanged + this._context.model.contentChanged.connect(() => { + console.log('Content changed, doc may have changed externally.'); + }); + } + + dispose(): void { + super.dispose(); + this._context.model.sharedModel.nameChanged.disconnect( + this._onNameChanged, + this + ); + } + + private _onNameChanged(): void { + // Update the input value to reflect remote changes + this._input.value = this._context.model.sharedModel.getName() ?? ''; + } + + private _context: DocumentRegistry.IContext; + private _input: HTMLInputElement; +} + +export class WorkflowDocumentWidget extends DocumentWidget< + Widget, + IWorkflowModel +> { + constructor(options: DocumentWidget.IOptions) { + super(options); + } +} + +export class WorkflowWidgetFactory extends ABCWidgetFactory< + WorkflowDocumentWidget, + IWorkflowModel +> { + protected createNewWidget( + context: DocumentRegistry.IContext + ): WorkflowDocumentWidget { + const content = new WorkflowWidget(context); + + const widget = new WorkflowDocumentWidget({ + context, + content + }); + + return widget; + } +} diff --git a/yarn.lock b/yarn.lock index e50f85cf..0e1e80d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,6 +25,17 @@ __metadata: languageName: node linkType: hard +"@apidevtools/json-schema-ref-parser@npm:^11.5.5": + version: 11.7.2 + resolution: "@apidevtools/json-schema-ref-parser@npm:11.7.2" + dependencies: + "@jsdevtools/ono": ^7.1.3 + "@types/json-schema": ^7.0.15 + js-yaml: ^4.1.0 + checksum: 44096e5cd5a03b17ee5eb0a3b9e9a4db85d87da8ae2abda264eae615f2a43e3e6ba5ca208e1161d4d946755b121c10a9550e88792a725951f2c4cff6df0d8a19 + languageName: node + linkType: hard + "@babel/code-frame@npm:7.12.11": version: 7.12.11 resolution: "@babel/code-frame@npm:7.12.11" @@ -3321,6 +3332,13 @@ __metadata: languageName: node linkType: hard +"@jsdevtools/ono@npm:^7.1.3": + version: 7.1.3 + resolution: "@jsdevtools/ono@npm:7.1.3" + checksum: 2297fcd472ba810bffe8519d2249171132844c7174f3a16634f9260761c8c78bc0428a4190b5b6d72d45673c13918ab9844d706c3ed4ef8f62ab11a2627a08ad + languageName: node + linkType: hard + "@jupyter/ydoc@npm:^1.0.2": version: 1.0.2 resolution: "@jupyter/ydoc@npm:1.0.2" @@ -3839,6 +3857,7 @@ __metadata: eslint-config-prettier: ^6.15.0 eslint-plugin-prettier: ^3.1.4 jest: ^29 + json-schema-to-typescript: ^15.0.3 mkdirp: ^1.0.3 npm-run-all: ^4.1.5 prettier: ^2.1.1 @@ -4751,6 +4770,13 @@ __metadata: languageName: node linkType: hard +"@types/json-schema@npm:^7.0.15": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 97ed0cb44d4070aecea772b7b2e2ed971e10c81ec87dd4ecc160322ffa55ff330dace1793489540e3e318d90942064bb697cc0f8989391797792d919737b3b98 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.12 resolution: "@types/json-schema@npm:7.0.12" @@ -4758,6 +4784,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4.17.7": + version: 4.17.13 + resolution: "@types/lodash@npm:4.17.13" + checksum: d0bf8fbd950be71946e0076b30fd40d492293baea75f05931b6b5b906fd62583708c6229abdb95b30205ad24ce1ed2f48bc9d419364f682320edd03405cc0c7e + languageName: node + linkType: hard + "@types/minimist@npm:^1.2.0": version: 1.2.2 resolution: "@types/minimist@npm:1.2.2" @@ -5465,6 +5498,13 @@ __metadata: languageName: node linkType: hard +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 83644b56493e89a254bae05702abf3a1101b4fa4d0ca31df1c9985275a5a5bd47b3c27b7fa0b71098d41114d8ca000e6ed90cad764b306f8a503665e4d517ced + languageName: node + linkType: hard + "array-union@npm:^2.1.0": version: 2.1.0 resolution: "array-union@npm:2.1.0" @@ -6967,6 +7007,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.2": + version: 6.4.2 + resolution: "fdir@npm:6.4.2" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 517ad31c495f1c0778238eef574a7818788efaaf2ce1969ffa18c70793e2951a9763dfa2e6720b8fcef615e602a3cbb47f9b8aea9da0b02147579ab36043f22f + languageName: node + linkType: hard + "file-entry-cache@npm:^6.0.1": version: 6.0.1 resolution: "file-entry-cache@npm:6.0.1" @@ -7722,7 +7774,7 @@ __metadata: languageName: node linkType: hard -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1": +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" dependencies: @@ -8446,6 +8498,17 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a + languageName: node + linkType: hard + "jsdom@npm:^20.0.0": version: 20.0.3 resolution: "jsdom@npm:20.0.3" @@ -8537,6 +8600,25 @@ __metadata: languageName: node linkType: hard +"json-schema-to-typescript@npm:^15.0.3": + version: 15.0.3 + resolution: "json-schema-to-typescript@npm:15.0.3" + dependencies: + "@apidevtools/json-schema-ref-parser": ^11.5.5 + "@types/json-schema": ^7.0.15 + "@types/lodash": ^4.17.7 + is-glob: ^4.0.3 + js-yaml: ^4.1.0 + lodash: ^4.17.21 + minimist: ^1.2.8 + prettier: ^3.2.5 + tinyglobby: ^0.2.9 + bin: + json2ts: dist/src/cli.js + checksum: e2337655d4a6f9ec0baccecda1e6d29bfdc5c04afed03d27669e87652334c1db5b8f7b3ad84623ad987ebe3e83e931dc181380bcd8b03d21ee3812a1f8a56239 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -9022,6 +9104,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.8": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 + languageName: node + linkType: hard + "minimist@npm:~1.2.0": version: 1.2.6 resolution: "minimist@npm:1.2.6" @@ -9571,6 +9660,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: a7a5188c954f82c6585720e9143297ccd0e35ad8072231608086ca950bee672d51b0ef676254af0788205e59bd4e4deb4e7708769226bed725bf13370a7d1464 + languageName: node + linkType: hard + "pidtree@npm:^0.3.0": version: 0.3.1 resolution: "pidtree@npm:0.3.1" @@ -9730,6 +9826,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.2.5": + version: 3.4.2 + resolution: "prettier@npm:3.4.2" + bin: + prettier: bin/prettier.cjs + checksum: 061c84513db62d3944c8dc8df36584dad82883ce4e49efcdbedd8703dce5b173c33fd9d2a4e1725d642a3b713c932b55418342eaa347479bc4a9cca114a04cd0 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.6.1": version: 29.6.1 resolution: "pretty-format@npm:29.6.1" @@ -11004,6 +11109,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.9": + version: 0.2.10 + resolution: "tinyglobby@npm:0.2.10" + dependencies: + fdir: ^6.4.2 + picomatch: ^4.0.2 + checksum: 7e2ffe262ebc149036bdef37c56b32d02d52cf09efa7d43dbdab2ea3c12844a4da881058835ce4c74d1891190e5ad5ec5133560a11ec8314849b68ad0d99d3f4 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5"