Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
699a1a3
added jupyter-panel service that handles mapping logic and opening/cl…
zyratlo May 28, 2026
30c7317
added flag to enable/disable the service
zyratlo May 28, 2026
bcf214a
removed mini map and open/close panel logic to be added in a separate PR
zyratlo May 28, 2026
7cbec8a
added mini map and open/close panel logic
zyratlo May 28, 2026
91ed99e
Merge branch 'main' into migration-tool-panel-controls
zyratlo Jun 1, 2026
9be4640
Merge branch 'main' into migration-tool-panel-controls
zyratlo Jun 5, 2026
22ba148
Merge branch 'main' into migration-tool-panel-controls
zyratlo Jun 9, 2026
d5707d5
Merge branch 'refs/heads/main' into migration-tool-panel-controls
zyratlo Jun 15, 2026
b48f5c0
Merge remote-tracking branch 'origin/main' into migration-tool-panel-…
zyratlo Jul 20, 2026
d48a403
untrack local dev-metadata docker-compose file
zyratlo Jul 20, 2026
2ffebb2
fix to trigger notebook cell once per operator click
zyratlo Jul 20, 2026
a167fbb
migrate service spec from jasmine to vitest
zyratlo Jul 20, 2026
7dc3b4d
cache jupyter origin and normalize it for message checks
zyratlo Jul 20, 2026
27b1cfc
Merge branch 'main' into migration-tool-panel-controls
zyratlo Jul 21, 2026
da97231
remove unused FormlyRepeatDndComponent import from mini-map
zyratlo Jul 21, 2026
701d0d8
Merge remote-tracking branch 'origin/migration-tool-panel-controls' i…
zyratlo Jul 21, 2026
4e83e26
build component highlight mapping for linkless workflows
zyratlo Jul 21, 2026
7090684
reset highlight mapping on each precompute to avoid stale entries
zyratlo Jul 21, 2026
42c0dc0
remove unused highlightedCell and cellContent fields
zyratlo Jul 21, 2026
5bfa2e1
drop no-op UntilDestroy decorator from jupyter panel service
zyratlo Jul 21, 2026
8a47fdb
cover expand-jupyter button visibility and click
zyratlo Jul 21, 2026
5ae0458
tidy mini-map jupyter expand: private config and correct jsdoc
zyratlo Jul 21, 2026
8a143c8
claim ai-sdk npm dependencies in frontend LICENSE-binary
zyratlo Jul 21, 2026
c1df956
Merge branch 'main' into migration-tool-panel-controls
zyratlo Jul 22, 2026
543f7aa
reconcile jupyter panel init after merging main
zyratlo Jul 22, 2026
5e66e5b
remove mini-map expand button and add notebook-exists signal to jupyt…
zyratlo Jul 24, 2026
b9a46ff
Merge branch 'main' into migration-tool-panel-controls
zyratlo Jul 24, 2026
d33f318
make jupyter panel visibility methods consistently public
zyratlo Jul 24, 2026
6cf15df
Merge branch 'main' into migration-tool-panel-controls
mengw15 Jul 25, 2026
db9ccb0
Merge branch 'main' into migration-tool-panel-controls
mengw15 Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TestBed } from "@angular/core/testing";
import { JupyterPanelService } from "./jupyter-panel.service";
import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { NotificationService } from "src/app/common/service/notification/notification.service";
import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service";
import { GuiConfigService } from "src/app/common/service/gui-config.service";
import { firstValueFrom, of } from "rxjs";
Expand All @@ -30,6 +31,7 @@ describe("JupyterPanelService", () => {
let httpMock: HttpTestingController;

let mockWorkflow: any;
let mockNotification: any;
let mockNotebook: any;
// Mutable so individual describe blocks can flip the flag mid-spec; the
// service stores a reference, so mutations are observed on the next read.
Expand All @@ -55,6 +57,10 @@ describe("JupyterPanelService", () => {
unhighlightLinks: vi.fn(),
};

mockNotification = {
warning: vi.fn(),
};

mockNotebook = {
hasMapping: vi.fn().mockReturnValue(true),
getMapping: vi.fn().mockReturnValue({
Expand All @@ -75,6 +81,7 @@ describe("JupyterPanelService", () => {
providers: [
JupyterPanelService,
{ provide: WorkflowActionService, useValue: mockWorkflow },
{ provide: NotificationService, useValue: mockNotification },
{ provide: NotebookMigrationService, useValue: mockNotebook },
{ provide: GuiConfigService, useValue: mockGuiConfig },
],
Expand All @@ -88,6 +95,63 @@ describe("JupyterPanelService", () => {
httpMock.verify();
});

// Panel visibility
it("should open and close panel", () => {
let state: boolean | null = null;

service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));

service.openPanel("JupyterNotebookPanel");
expect(state).toBe(true);

service.closeJupyterNotebookPanel();
expect(state).toBe(false);
});

it("should minimize panel", () => {
let state: boolean | null = true;

service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));

service.minimizeJupyterNotebookPanel();

expect(state).toBe(false);
});

// openJupyterNotebookPanel
it("should warn if no mapping exists", () => {
mockNotebook.hasMapping.mockReturnValue(false);

service.openJupyterNotebookPanel();

expect(mockNotification.warning).toHaveBeenCalled();
});

it("should open panel if mapping exists", () => {
mockNotebook.hasMapping.mockReturnValue(true);

let state: boolean | null = false;

service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));

service.openJupyterNotebookPanel();

expect(state).toBe(true);
});

// openPanel
it("should open panel only for correct name", () => {
let state: boolean | null = false;

service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));

service.openPanel("WrongPanel");
expect(state).toBe(false);

service.openPanel("JupyterNotebookPanel");
expect(state).toBe(true);
});

// HTTP fetchNotebookAndMapping
it("should return 0 when exists=false", async () => {
const resultPromise = firstValueFrom((service as any).fetchNotebookAndMapping(1, 1));
Expand All @@ -98,6 +162,23 @@ describe("JupyterPanelService", () => {
expect(await resultPromise).toBe(0);
});

// jupyterNotebookExists$ starts false and flips true once init()'s fetch finds
// a notebook for the workflow; the toolbar's expand button binds to this.
it("sets jupyterNotebookExists$ true after a workflow's notebook is fetched", async () => {
mockNotebook.sendNotebookToJupyter = vi.fn().mockResolvedValue(1);
const states: boolean[] = [];
service.jupyterNotebookExists$.subscribe(v => states.push(v));

service.init();
httpMock
.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping"))
.flush({ exists: true, mapping: { cell_to_operator: {}, operator_to_cell: {} }, notebook: {} });
await new Promise(resolve => setTimeout(resolve, 0));

expect(states[0]).toBe(false); // starts false
expect(states.at(-1)).toBe(true); // true once the notebook is found
});

// init(): subscribes to workflow changes, drops the stale mapping for the
// current workflow, and fetches the incoming workflow's notebook + mapping.
it("init subscribes, drops the stale mapping, and fetches for the new workflow", () => {
Expand Down Expand Up @@ -303,6 +384,36 @@ describe("JupyterPanelService", () => {
expect(mockWorkflow.workflowMetaDataChanged).not.toHaveBeenCalled();
});

it("openPanel does not flip the visibility stream", () => {
let state: boolean | null = false;
service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
service.openPanel("JupyterNotebookPanel");
expect(state).toBe(false);
});

it("closeJupyterNotebookPanel does not flip visibility or delete the mapping", () => {
// BehaviorSubject's initial value is false; the meaningful assertion is
// that the side effect (deleteMapping) was never called.
service.closeJupyterNotebookPanel();
expect(mockNotebook.deleteMapping).not.toHaveBeenCalled();
});

it("minimizeJupyterNotebookPanel does not flip visibility", () => {
const visibleSubject = (service as any).jupyterNotebookPanelVisible;
visibleSubject.next(true);
service.minimizeJupyterNotebookPanel();
expect(visibleSubject.value).toBe(true);
});

it("openJupyterNotebookPanel does not warn or flip visibility", () => {
mockNotebook.hasMapping.mockReturnValue(false);
let state: boolean | null = false;
service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
service.openJupyterNotebookPanel();
expect(state).toBe(false);
expect(mockNotification.warning).not.toHaveBeenCalled();
});

it("onWorkflowComponentClick does not postMessage to the iframe", async () => {
const mockIframe = {
contentWindow: { postMessage: vi.fn() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
*/

import { Injectable } from "@angular/core";
import { catchError, map, of } from "rxjs";
import { BehaviorSubject, catchError, map, of } from "rxjs";
import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
import { OperatorLink } from "../../types/workflow-common.interface";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { NotificationService } from "src/app/common/service/notification/notification.service";
import { distinctUntilChanged, switchMap } from "rxjs/operators";
import { AppSettings } from "../../../common/app-setting";
import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service";
Expand All @@ -31,6 +32,16 @@ import { GuiConfigService } from "../../../common/service/gui-config.service";
providedIn: "root",
})
export class JupyterPanelService {
private jupyterNotebookPanelVisible = new BehaviorSubject<boolean>(false);
public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable();

// Whether the current workflow has an associated notebook in the migration DB.
// Driven by the per-workflow fetch in init(): reset on every workflow change,
// set true only when a notebook/mapping is found. Used to gate the toolbar's
// expand button so it appears only for workflows that actually have a notebook.
private jupyterNotebookExists = new BehaviorSubject<boolean>(false);
public jupyterNotebookExists$ = this.jupyterNotebookExists.asObservable();

private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element

// Precomputed dictionary for cell to highlight mapping
Expand All @@ -42,6 +53,7 @@ export class JupyterPanelService {
constructor(
private workflowActionService: WorkflowActionService,
private http: HttpClient,
private notificationService: NotificationService,
private notebookMigrationService: NotebookMigrationService,
private config: GuiConfigService
) {
Expand Down Expand Up @@ -87,27 +99,21 @@ export class JupyterPanelService {
distinctUntilChanged()
)
.subscribe(wid => {
// On every workflow change, drop the outgoing workflow's stale mapping
// and clear the highlight index. Clearing here (not only inside
// precomputeHighlightMapping, which runs only on a successful fetch)
// ensures switching to a workflow without a stored notebook can't leave
// the previous workflow's highlights active. This cleanup previously
// happened inside closeJupyterNotebookPanel; the panel-visibility
// surface lives with the iframe component in
// `migration-tool-jupyter-panel` now, so it is inlined.
const currentWid = this.workflowActionService.getWorkflow().wid;
if (currentWid !== undefined) {
this.notebookMigrationService.deleteMapping("mapping_wid_" + currentWid);
}
// On every workflow change, close the panel (which also drops the
// outgoing workflow's stale mapping) and clear the highlight index, so a
// switch to a workflow without a stored notebook can't leave the
// previous workflow's highlights active.
this.closeJupyterNotebookPanel();
this.cellToHighlightMapping = {};
this.jupyterNotebookExists.next(false);
// Skip unsaved workflows (wid undefined) and wid 0; both would POST
// without a usable wid and 500 on the backend.
if (wid) {
this.fetchNotebookAndMapping(wid).subscribe(result => {
if (result == 1) {
this.jupyterNotebookExists.next(true);
this.precomputeHighlightMapping();
// Panel auto-open on workflow restore is wired in
// `migration-tool-jupyter-panel` once the visibility API exists.
this.openJupyterNotebookPanel();
}
});
}
Expand Down Expand Up @@ -198,6 +204,45 @@ export class JupyterPanelService {
this.iframeRef = iframe;
}

// Open the Jupyter Notebook panel
public openPanel(panelName: string): void {
if (!this.enabled) return;
if (panelName === "JupyterNotebookPanel") {
this.jupyterNotebookPanelVisible.next(true);
}
}

// Close the Jupyter Notebook panel
public closeJupyterNotebookPanel(): void {
if (!this.enabled) return;
this.jupyterNotebookPanelVisible.next(false);
const wid = this.workflowActionService.getWorkflow().wid;
if (wid != undefined) {
this.notebookMigrationService.deleteMapping("mapping_wid_" + wid);
}
}

// Minimize the Jupyter Notebook panel
public minimizeJupyterNotebookPanel(): void {
if (!this.enabled) return;
this.jupyterNotebookPanelVisible.next(false);
}

// Expand the Jupyter Notebook panel
public openJupyterNotebookPanel(): void {
if (!this.enabled) return;
const wid = this.workflowActionService.getWorkflow().wid;
const mappingKey = "mapping_wid_" + wid;
// Check if there is corresponding mapping data
if (wid === undefined || !this.notebookMigrationService.hasMapping(mappingKey)) {
this.notificationService.warning("No Jupyter notebook associated with this workflow.");
return;
}

// Expand only if the mapping exists
this.jupyterNotebookPanelVisible.next(true);
}

// Handle messages from the Jupyter notebook iframe
private handleNotebookMessage = async (event: MessageEvent) => {
if (!this.enabled) return;
Expand Down
Loading