From 699a1a32c6d683d726ebf485ced960367ce2be51 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 28 May 2026 09:49:19 -0700 Subject: [PATCH 01/19] added jupyter-panel service that handles mapping logic and opening/closing the iframe --- frontend/src/app/app.module.ts | 9 +- .../mini-map/mini-map.component.html | 11 + .../mini-map/mini-map.component.scss | 7 + .../mini-map/mini-map.component.ts | 19 +- .../workflow-editor.component.ts | 6 +- .../jupyter-panel.service.spec.ts | 214 ++++++++++++++ .../jupyter-panel/jupyter-panel.service.ts | 263 ++++++++++++++++++ 7 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts create mode 100644 frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 78fc75d7cbc..db907f7745f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -20,7 +20,7 @@ import { DatePipe, registerLocaleData } from "@angular/common"; import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http"; import en from "@angular/common/locales/en"; -import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; +import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, APP_BOOTSTRAP_LISTENER, NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; @@ -191,6 +191,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox"; import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component"; +import { JupyterPanelService } from "./workspace/service/jupyter-panel/jupyter-panel.service"; registerLocaleData(en); @@ -404,6 +405,12 @@ registerLocaleData(en); deps: [GuiConfigService], multi: true, }, + { + provide: APP_BOOTSTRAP_LISTENER, + useFactory: (jupyterPanelService: JupyterPanelService) => () => jupyterPanelService.init(), + deps: [JupyterPanelService], + multi: true, + }, ], bootstrap: [AppComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html index adbf9f6ab5c..df1d86f6386 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html @@ -53,6 +53,17 @@ nz-icon nzType="zoom-in"> +
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss index c4d9667dc86..293dae89316 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss @@ -45,6 +45,13 @@ z-index: 4; } +#minimap-expand-jupyter-button { + position: absolute; + bottom: 0; + right: 120px; + z-index: 4; +} + #mini-map-container { position: relative; overflow: hidden; diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts index 447ab8d1f68..305f37503ca 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts @@ -17,6 +17,7 @@ * under the License. */ +import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, HostListener, OnDestroy, ViewChild } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; @@ -31,6 +32,8 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component"; +import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; +import { GuiConfigService } from "../../../../common/service/gui-config.service"; @UntilDestroy() @Component({ @@ -45,6 +48,7 @@ import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/r NzIconDirective, CdkDrag, FormlyRepeatDndComponent, + CommonModule, ], }) export class MiniMapComponent implements AfterViewInit, OnDestroy { @@ -57,7 +61,9 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { constructor( private workflowActionService: WorkflowActionService, - private panelService: PanelService + private panelService: PanelService, + protected config: GuiConfigService, + private jupyterPanelService: JupyterPanelService ) {} ngAfterViewInit() { @@ -156,6 +162,17 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { ); } + /** + * This method will expand and redisplay the jupyter notebook. + */ + public get pythonNotebookMigrationEnabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + + public onClickExpandJupyterNotebookPanel(): void { + this.jupyterPanelService.openJupyterNotebookPanel(); + } + public triggerCenter(): void { this.workflowActionService.getTexeraGraph().triggerCenterEvent(); if (this.navigatorDrag) this.navigatorDrag.reset(); diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts index 979f131ad3c..8d07eebaf71 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts @@ -48,6 +48,7 @@ import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation"; import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component"; import { NgIf } from "@angular/common"; import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; // jointjs interactive options for enabling and disabling interactivity // https://resources.jointjs.com/docs/jointjs/v3.2/joint.html#dia.Paper.prototype.options.interactive @@ -128,7 +129,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy public nzContextMenu: NzContextMenuService, private elementRef: ElementRef, private config: GuiConfigService, - private agentService: AgentService + private agentService: AgentService, + private jupyterPanelService: JupyterPanelService ) { this.wrapper = this.workflowActionService.getJointGraphWrapper(); } @@ -603,12 +605,14 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy const elementID = event[0].model.id.toString(); const highlightedOperatorIDs = this.wrapper.getCurrentHighlightedOperatorIDs(); const highlightedCommentBoxIDs = this.wrapper.getCurrentHighlightedCommentBoxIDs(); + this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell if (event[1].shiftKey) { // if in multiselect toggle highlights on click if (highlightedOperatorIDs.includes(elementID)) { this.workflowActionService.unhighlightOperators(elementID); } else if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) { this.workflowActionService.highlightOperators(event[1].shiftKey, elementID); + this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell } if (highlightedCommentBoxIDs.includes(elementID)) { this.wrapper.unhighlightCommentBoxes(elementID); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts new file mode 100644 index 00000000000..6731236746c --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -0,0 +1,214 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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 { of } from "rxjs"; + +describe("JupyterPanelService", () => { + let service: JupyterPanelService; + let httpMock: HttpTestingController; + + let mockWorkflow: any; + let mockNotification: any; + let mockNotebook: any; + + beforeEach(() => { + mockWorkflow = { + workflowMetaDataChanged: jasmine.createSpy().and.returnValue(of({ wid: 1 })), + getWorkflow: jasmine.createSpy().and.returnValue({ wid: 1 }), + getTexeraGraph: jasmine.createSpy().and.returnValue({ + getAllLinks: () => [ + { + linkID: "L1", + source: { operatorID: "A" }, + target: { operatorID: "B" }, + }, + ], + getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }], + }), + highlightOperators: jasmine.createSpy(), + highlightLinks: jasmine.createSpy(), + unhighlightOperators: jasmine.createSpy(), + unhighlightLinks: jasmine.createSpy(), + }; + + mockNotification = { + warning: jasmine.createSpy(), + }; + + mockNotebook = { + hasMapping: jasmine.createSpy().and.returnValue(true), + getMapping: jasmine.createSpy().and.returnValue({ + cell_to_operator: { + cell1: ["A", "B"], + }, + operator_to_cell: {}, + }), + deleteMapping: jasmine.createSpy(), + setMapping: jasmine.createSpy(), + getJupyterURL: jasmine.createSpy().and.resolveTo("http://jupyter"), + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + JupyterPanelService, + { provide: WorkflowActionService, useValue: mockWorkflow }, + { provide: NotificationService, useValue: mockNotification }, + { provide: NotebookMigrationService, useValue: mockNotebook }, + ], + }); + + service = TestBed.inject(JupyterPanelService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + 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).toBeTrue(); + + service.closeJupyterNotebookPanel(); + expect(state).toBeFalse(); + }); + + it("should minimize panel", () => { + let state: boolean | null = true; + + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + + service.minimizeJupyterNotebookPanel(); + + expect(state).toBeFalse(); + }); + + // openJupyterNotebookPanel + it("should warn if no mapping exists", () => { + mockNotebook.hasMapping.and.returnValue(false); + + service.openJupyterNotebookPanel(); + + expect(mockNotification.warning).toHaveBeenCalled(); + }); + + it("should open panel if mapping exists", () => { + mockNotebook.hasMapping.and.returnValue(true); + + let state: boolean | null = false; + + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + + service.openJupyterNotebookPanel(); + + expect(state).toBeTrue(); + }); + + // HTTP fetchNotebookAndMapping + it("should return 0 when exists=false", done => { + (service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => { + expect(result).toBe(0); + done(); + }); + + const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); + + req.flush({ exists: false }); + }); + + // iframe ref + it("should store iframe reference", () => { + const iframe = document.createElement("iframe"); + + service.setIframeRef(iframe); + + expect((service as any).iframeRef).toBe(iframe); + }); + + // highlightFromCell + it("should highlight operators and links", () => { + (service as any).cellToHighlightMapping = { + cell1: { + components: ["op1", "op2"], + edges: ["link1"], + }, + }; + + const method = (service as any).highlightFromCell.bind(service); + + method("cell1"); + + expect(mockWorkflow.unhighlightOperators).toHaveBeenCalled(); + expect(mockWorkflow.unhighlightLinks).toHaveBeenCalled(); + expect(mockWorkflow.highlightOperators).toHaveBeenCalledWith(true, "op1", "op2"); + expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1"); + }); + + // 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).toBeFalse(); + + service.openPanel("JupyterNotebookPanel"); + expect(state).toBeTrue(); + }); + + // onWorkflowComponentClick + it("should postMessage when mapping exists", async () => { + const mockIframe = { + contentWindow: { + postMessage: jasmine.createSpy(), + }, + } as any; + + service.setIframeRef(mockIframe); + (mockNotebook as any).getMapping.and.returnValue({ + cell_to_operator: {}, + operator_to_cell: { + cell1: ["op1", "op2"], + }, + }); + + await service.onWorkflowComponentClick("cell1"); + + expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith( + { + action: "triggerCellClick", + operators: ["op1", "op2"], + }, + "http://jupyter" + ); + }); +}); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts new file mode 100644 index 00000000000..7c5f96a3fd2 --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -0,0 +1,263 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +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 { UntilDestroy } from "@ngneat/until-destroy"; +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"; + +@UntilDestroy() +@Injectable({ + providedIn: "root", +}) +export class JupyterPanelService { + private jupyterNotebookPanelVisible = new BehaviorSubject(false); + public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable(); + + private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element + private cellContent: string[] = []; // Store the content of the cells + private highlightedCell: number | null = null; // Track the highlighted cell + + // Precomputed dictionary for cell to highlight mapping + private cellToHighlightMapping: Record = {}; + + constructor( + private workflowActionService: WorkflowActionService, + private http: HttpClient, + private notificationService: NotificationService, + private notebookMigrationService: NotebookMigrationService + ) { + window.addEventListener("message", this.handleNotebookMessage); + } + + public init(): void { + this.workflowActionService + .workflowMetaDataChanged() + .pipe( + map(meta => meta.wid), + distinctUntilChanged() + ) + .subscribe(wid => { + this.closeJupyterNotebookPanel(); + if (wid != 0) { + this.fetchNotebookAndMapping(wid).subscribe(result => { + if (result == 1) { + this.precomputeHighlightMapping(); + this.openJupyterNotebookPanel(); + } + }); + } + }); + } + + private fetchNotebookAndMapping( + workflowID: number | undefined = this.workflowActionService.getWorkflow().wid, + vId: number = 1 + ) { + // Fetch mapping and notebook from migration database if exists for wid + const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/fetch-notebook-and-mapping`; + const headers = new HttpHeaders({ "Content-Type": "application/json" }); + const payload = { + wid: workflowID, + vid: vId, // Future work: add dynamic fetching of current workflow vId + }; + + return this.http.post(dbAPIUrl, payload, { headers }).pipe( + switchMap(async (response: any) => { + // Only load mapping and workflow if they exist + if (response.exists) { + this.notebookMigrationService.setMapping("mapping_wid_" + workflowID, response.mapping); + + if ((await this.notebookMigrationService.sendNotebookToJupyter(response.notebook)) == 1) { + return 1; + } else { + return 0; + } + } else { + return 0; + } + }), + catchError((error: unknown) => { + console.error("Network response was not ok when fetching notebook and mapping:", error); + return of(0); + }) + ); + } + + // Precompute the dictionary for O(1) highlighting + private precomputeHighlightMapping(): void { + const wid = this.workflowActionService.getWorkflow().wid; + + if (wid === undefined) { + console.warn("Workflow ID is undefined. Cannot compute highlight mapping."); + return; + } + const mappingKey = "mapping_wid_" + wid; + const mapping = this.notebookMigrationService.getMapping(mappingKey); + + if (mapping == undefined) { + console.warn(`Mapping key '${mappingKey}' not found. Cannot compute highlight mapping.`); + return; + } + const cellToOperator = mapping.cell_to_operator; + + const allLinks: OperatorLink[] = this.workflowActionService.getTexeraGraph().getAllLinks(); + if (allLinks.length === 0) { + console.warn("No links found in the graph during precompute."); + return; + } + + for (const cellUUID in cellToOperator) { + const components = cellToOperator[cellUUID] || []; + const componentSet = new Set(components); + const edges: string[] = []; + + allLinks.forEach(link => { + const sourceOperatorID = link.source.operatorID; + const targetOperatorID = link.target.operatorID; + + if ( + componentSet.has(sourceOperatorID) && + componentSet.has(targetOperatorID) && + sourceOperatorID !== targetOperatorID + ) { + edges.push(link.linkID); + } + }); + + this.cellToHighlightMapping[cellUUID] = { components, edges }; + } + } + + // Set the iframe reference (from the component's ViewChild) + setIframeRef(iframe: HTMLIFrameElement) { + this.iframeRef = iframe; + } + + // Open the Jupyter Notebook panel + openPanel(panelName: string): void { + if (panelName === "JupyterNotebookPanel") { + this.jupyterNotebookPanelVisible.next(true); + } + } + + // Close the Jupyter Notebook panel + closeJupyterNotebookPanel(): void { + 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 { + this.jupyterNotebookPanelVisible.next(false); + } + + // Expand the Jupyter Notebook panel + public openJupyterNotebookPanel(): void { + 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) => { + const allowedOrigins = [window.location.origin, await this.notebookMigrationService.getJupyterURL()]; + if (!allowedOrigins.includes(event.origin)) { + return; + } + + const { action, cellIndex, cellContent, cellUUID } = event.data; + if (action === "cellClicked") { + this.highlightedCell = cellIndex; + this.cellContent[cellIndex] = cellContent || `Cell ${cellIndex + 1}`; + this.highlightFromCell(cellUUID); + } + }; + + // Highlight operators and edges based on the clicked cell + private highlightFromCell(cellUUID: string): void { + const highlightData = this.cellToHighlightMapping[cellUUID] || { components: [], edges: [] }; + + // Unhighlight all operators and links + this.workflowActionService.unhighlightOperators( + ...this.workflowActionService + .getTexeraGraph() + .getAllOperators() + .map(op => op.operatorID) + ); + this.workflowActionService.unhighlightLinks( + ...this.workflowActionService + .getTexeraGraph() + .getAllLinks() + .map(link => link.linkID) + ); + + // Highlight components and edges + if (highlightData.components.length > 0) { + this.workflowActionService.highlightOperators(true, ...highlightData.components); + } + if (highlightData.edges.length > 0) { + this.workflowActionService.highlightLinks(true, ...highlightData.edges); + } + } + + // Handle when a Texera component is clicked to trigger the corresponding notebook cell + async onWorkflowComponentClick(cellUUID: string): Promise { + const jupyterURL = await this.notebookMigrationService.getJupyterURL(); + if (jupyterURL && this.iframeRef && this.iframeRef.contentWindow) { + const wid = this.workflowActionService.getWorkflow().wid; + + if (wid == undefined) { + console.error("Error fetching wid of current workflow"); + return; + } + + const mappingKey = "mapping_wid_" + wid; + const mappingEntry = this.notebookMigrationService.getMapping(mappingKey); + + if (!mappingEntry) { + console.error("Missing mapping for workflow:", mappingKey); + return; + } + + const operatorArray = mappingEntry["operator_to_cell"][cellUUID]; + if (operatorArray) { + this.iframeRef.contentWindow.postMessage({ action: "triggerCellClick", operators: operatorArray }, jupyterURL); + } else { + console.error(`No operators found for cellUUID: ${cellUUID}`); + } + } + } +} From 30c73170c43da20d4e2226afd17b0e06f0b28f5a Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 28 May 2026 09:53:41 -0700 Subject: [PATCH 02/19] added flag to enable/disable the service --- .../jupyter-panel.service.spec.ts | 65 +++++++++++++++++++ .../jupyter-panel/jupyter-panel.service.ts | 15 ++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index 6731236746c..0c755bb5816 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -23,6 +23,7 @@ import { WorkflowActionService } from "../workflow-graph/model/workflow-action.s 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 { of } from "rxjs"; describe("JupyterPanelService", () => { @@ -32,6 +33,9 @@ describe("JupyterPanelService", () => { 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. + let mockGuiConfig: { env: { pythonNotebookMigrationEnabled: boolean } }; beforeEach(() => { mockWorkflow = { @@ -70,6 +74,8 @@ describe("JupyterPanelService", () => { getJupyterURL: jasmine.createSpy().and.resolveTo("http://jupyter"), }; + mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; + TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ @@ -77,6 +83,7 @@ describe("JupyterPanelService", () => { { provide: WorkflowActionService, useValue: mockWorkflow }, { provide: NotificationService, useValue: mockNotification }, { provide: NotebookMigrationService, useValue: mockNotebook }, + { provide: GuiConfigService, useValue: mockGuiConfig }, ], }); @@ -211,4 +218,62 @@ describe("JupyterPanelService", () => { "http://jupyter" ); }); + + // Feature flag gate (defence in depth). With the flag off, every public + // method must short-circuit — no subscription in init, no visibility flips, + // no postMessage. The window message listener is installed in the constructor + // unconditionally, but the handler returns early on the flag check. + describe("when the feature flag is disabled", () => { + beforeEach(() => { + mockGuiConfig.env.pythonNotebookMigrationEnabled = false; + }); + + it("init does not subscribe to workflowMetaDataChanged", () => { + service.init(); + 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).toBeFalse(); + }); + + it("closeJupyterNotebookPanel does not flip visibility or delete the mapping", () => { + let state: boolean | null = true; + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + service.closeJupyterNotebookPanel(); + // BehaviorSubject's initial value is false; we asserted via subscription + // that no `next(false)` was emitted by the gated method itself. With the + // initial value also being false, the meaningful check is that + // deleteMapping was never called. + 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).toBeTrue(); + }); + + it("openJupyterNotebookPanel does not warn or flip visibility", () => { + mockNotebook.hasMapping.and.returnValue(false); + let state: boolean | null = false; + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + service.openJupyterNotebookPanel(); + expect(state).toBeFalse(); + expect(mockNotification.warning).not.toHaveBeenCalled(); + }); + + it("onWorkflowComponentClick does not postMessage to the iframe", async () => { + const mockIframe = { + contentWindow: { postMessage: jasmine.createSpy() }, + } as any; + service.setIframeRef(mockIframe); + await service.onWorkflowComponentClick("cell1"); + expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 7c5f96a3fd2..b5f704c1397 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -27,6 +27,7 @@ import { NotificationService } from "src/app/common/service/notification/notific import { distinctUntilChanged, switchMap } from "rxjs/operators"; import { AppSettings } from "../../../common/app-setting"; import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; @UntilDestroy() @Injectable({ @@ -47,12 +48,18 @@ export class JupyterPanelService { private workflowActionService: WorkflowActionService, private http: HttpClient, private notificationService: NotificationService, - private notebookMigrationService: NotebookMigrationService + private notebookMigrationService: NotebookMigrationService, + private config: GuiConfigService ) { window.addEventListener("message", this.handleNotebookMessage); } + private get enabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + public init(): void { + if (!this.enabled) return; this.workflowActionService .workflowMetaDataChanged() .pipe( @@ -158,6 +165,7 @@ export class JupyterPanelService { // Open the Jupyter Notebook panel openPanel(panelName: string): void { + if (!this.enabled) return; if (panelName === "JupyterNotebookPanel") { this.jupyterNotebookPanelVisible.next(true); } @@ -165,6 +173,7 @@ export class JupyterPanelService { // Close the Jupyter Notebook panel closeJupyterNotebookPanel(): void { + if (!this.enabled) return; this.jupyterNotebookPanelVisible.next(false); const wid = this.workflowActionService.getWorkflow().wid; if (wid != undefined) { @@ -174,11 +183,13 @@ export class JupyterPanelService { // 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 @@ -193,6 +204,7 @@ export class JupyterPanelService { // Handle messages from the Jupyter notebook iframe private handleNotebookMessage = async (event: MessageEvent) => { + if (!this.enabled) return; const allowedOrigins = [window.location.origin, await this.notebookMigrationService.getJupyterURL()]; if (!allowedOrigins.includes(event.origin)) { return; @@ -235,6 +247,7 @@ export class JupyterPanelService { // Handle when a Texera component is clicked to trigger the corresponding notebook cell async onWorkflowComponentClick(cellUUID: string): Promise { + if (!this.enabled) return; const jupyterURL = await this.notebookMigrationService.getJupyterURL(); if (jupyterURL && this.iframeRef && this.iframeRef.contentWindow) { const wid = this.workflowActionService.getWorkflow().wid; From bcf214a23b7c3254557805192fad0ebbb2806bef Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 28 May 2026 10:06:57 -0700 Subject: [PATCH 03/19] removed mini map and open/close panel logic to be added in a separate PR --- .../mini-map/mini-map.component.html | 11 -- .../mini-map/mini-map.component.scss | 7 -- .../mini-map/mini-map.component.ts | 19 +--- .../jupyter-panel.service.spec.ts | 100 +----------------- .../jupyter-panel/jupyter-panel.service.ts | 59 +++-------- 5 files changed, 19 insertions(+), 177 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html index df1d86f6386..adbf9f6ab5c 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html @@ -53,17 +53,6 @@ nz-icon nzType="zoom-in"> -
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss index 293dae89316..c4d9667dc86 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss @@ -45,13 +45,6 @@ z-index: 4; } -#minimap-expand-jupyter-button { - position: absolute; - bottom: 0; - right: 120px; - z-index: 4; -} - #mini-map-container { position: relative; overflow: hidden; diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts index 305f37503ca..447ab8d1f68 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts @@ -17,7 +17,6 @@ * under the License. */ -import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, HostListener, OnDestroy, ViewChild } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; @@ -32,8 +31,6 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component"; -import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; -import { GuiConfigService } from "../../../../common/service/gui-config.service"; @UntilDestroy() @Component({ @@ -48,7 +45,6 @@ import { GuiConfigService } from "../../../../common/service/gui-config.service" NzIconDirective, CdkDrag, FormlyRepeatDndComponent, - CommonModule, ], }) export class MiniMapComponent implements AfterViewInit, OnDestroy { @@ -61,9 +57,7 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { constructor( private workflowActionService: WorkflowActionService, - private panelService: PanelService, - protected config: GuiConfigService, - private jupyterPanelService: JupyterPanelService + private panelService: PanelService ) {} ngAfterViewInit() { @@ -162,17 +156,6 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { ); } - /** - * This method will expand and redisplay the jupyter notebook. - */ - public get pythonNotebookMigrationEnabled(): boolean { - return this.config.env.pythonNotebookMigrationEnabled; - } - - public onClickExpandJupyterNotebookPanel(): void { - this.jupyterPanelService.openJupyterNotebookPanel(); - } - public triggerCenter(): void { this.workflowActionService.getTexeraGraph().triggerCenterEvent(); if (this.navigatorDrag) this.navigatorDrag.reset(); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index 0c755bb5816..e5e9a436180 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -95,50 +95,6 @@ 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).toBeTrue(); - - service.closeJupyterNotebookPanel(); - expect(state).toBeFalse(); - }); - - it("should minimize panel", () => { - let state: boolean | null = true; - - service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); - - service.minimizeJupyterNotebookPanel(); - - expect(state).toBeFalse(); - }); - - // openJupyterNotebookPanel - it("should warn if no mapping exists", () => { - mockNotebook.hasMapping.and.returnValue(false); - - service.openJupyterNotebookPanel(); - - expect(mockNotification.warning).toHaveBeenCalled(); - }); - - it("should open panel if mapping exists", () => { - mockNotebook.hasMapping.and.returnValue(true); - - let state: boolean | null = false; - - service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); - - service.openJupyterNotebookPanel(); - - expect(state).toBeTrue(); - }); - // HTTP fetchNotebookAndMapping it("should return 0 when exists=false", done => { (service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => { @@ -179,19 +135,6 @@ describe("JupyterPanelService", () => { expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1"); }); - // 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).toBeFalse(); - - service.openPanel("JupyterNotebookPanel"); - expect(state).toBeTrue(); - }); - // onWorkflowComponentClick it("should postMessage when mapping exists", async () => { const mockIframe = { @@ -219,10 +162,11 @@ describe("JupyterPanelService", () => { ); }); - // Feature flag gate (defence in depth). With the flag off, every public - // method must short-circuit — no subscription in init, no visibility flips, - // no postMessage. The window message listener is installed in the constructor - // unconditionally, but the handler returns early on the flag check. + // Feature flag gate (defence in depth). With the flag off, init must not + // subscribe to workflow changes, and onWorkflowComponentClick must not + // postMessage to the iframe. The window message listener is installed in + // the constructor unconditionally, but handleNotebookMessage returns early + // on the flag check. describe("when the feature flag is disabled", () => { beforeEach(() => { mockGuiConfig.env.pythonNotebookMigrationEnabled = false; @@ -233,40 +177,6 @@ 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).toBeFalse(); - }); - - it("closeJupyterNotebookPanel does not flip visibility or delete the mapping", () => { - let state: boolean | null = true; - service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); - service.closeJupyterNotebookPanel(); - // BehaviorSubject's initial value is false; we asserted via subscription - // that no `next(false)` was emitted by the gated method itself. With the - // initial value also being false, the meaningful check is that - // deleteMapping was never called. - 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).toBeTrue(); - }); - - it("openJupyterNotebookPanel does not warn or flip visibility", () => { - mockNotebook.hasMapping.and.returnValue(false); - let state: boolean | null = false; - service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); - service.openJupyterNotebookPanel(); - expect(state).toBeFalse(); - expect(mockNotification.warning).not.toHaveBeenCalled(); - }); - it("onWorkflowComponentClick does not postMessage to the iframe", async () => { const mockIframe = { contentWindow: { postMessage: jasmine.createSpy() }, diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index b5f704c1397..395147df07c 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -18,7 +18,7 @@ */ import { Injectable } from "@angular/core"; -import { BehaviorSubject, catchError, map, of } from "rxjs"; +import { 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"; @@ -34,9 +34,6 @@ import { GuiConfigService } from "../../../common/service/gui-config.service"; providedIn: "root", }) export class JupyterPanelService { - private jupyterNotebookPanelVisible = new BehaviorSubject(false); - public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable(); - private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element private cellContent: string[] = []; // Store the content of the cells private highlightedCell: number | null = null; // Track the highlighted cell @@ -67,12 +64,20 @@ export class JupyterPanelService { distinctUntilChanged() ) .subscribe(wid => { - this.closeJupyterNotebookPanel(); + // Drop any stale mapping for the current workflow. This previously + // happened inside closeJupyterNotebookPanel; the panel-visibility + // surface lives with the iframe component in + // `migration-tool-jupyter-panel` now, so the cleanup is inlined. + const currentWid = this.workflowActionService.getWorkflow().wid; + if (currentWid !== undefined) { + this.notebookMigrationService.deleteMapping("mapping_wid_" + currentWid); + } if (wid != 0) { this.fetchNotebookAndMapping(wid).subscribe(result => { if (result == 1) { this.precomputeHighlightMapping(); - this.openJupyterNotebookPanel(); + // Panel auto-open on workflow restore is wired in + // `migration-tool-jupyter-panel` once the visibility API exists. } }); } @@ -158,50 +163,12 @@ export class JupyterPanelService { } } - // Set the iframe reference (from the component's ViewChild) + // Set the iframe reference (from the component's ViewChild). The panel + // component that calls this lives in `migration-tool-jupyter-panel`. setIframeRef(iframe: HTMLIFrameElement) { this.iframeRef = iframe; } - // Open the Jupyter Notebook panel - openPanel(panelName: string): void { - if (!this.enabled) return; - if (panelName === "JupyterNotebookPanel") { - this.jupyterNotebookPanelVisible.next(true); - } - } - - // Close the Jupyter Notebook panel - 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; From 7cbec8ae927e9314fbd52b3dee76576b568ebf81 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 28 May 2026 10:23:30 -0700 Subject: [PATCH 04/19] added mini map and open/close panel logic --- .../mini-map/mini-map.component.html | 11 +++ .../mini-map/mini-map.component.scss | 7 ++ .../mini-map/mini-map.component.ts | 19 +++- .../jupyter-panel.service.spec.ts | 96 ++++++++++++++++++- .../jupyter-panel/jupyter-panel.service.ts | 59 +++++++++--- 5 files changed, 173 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html index adbf9f6ab5c..df1d86f6386 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html @@ -53,6 +53,17 @@ nz-icon nzType="zoom-in"> +
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss index c4d9667dc86..293dae89316 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss @@ -45,6 +45,13 @@ z-index: 4; } +#minimap-expand-jupyter-button { + position: absolute; + bottom: 0; + right: 120px; + z-index: 4; +} + #mini-map-container { position: relative; overflow: hidden; diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts index 447ab8d1f68..305f37503ca 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts @@ -17,6 +17,7 @@ * under the License. */ +import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, HostListener, OnDestroy, ViewChild } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; @@ -31,6 +32,8 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component"; +import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; +import { GuiConfigService } from "../../../../common/service/gui-config.service"; @UntilDestroy() @Component({ @@ -45,6 +48,7 @@ import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/r NzIconDirective, CdkDrag, FormlyRepeatDndComponent, + CommonModule, ], }) export class MiniMapComponent implements AfterViewInit, OnDestroy { @@ -57,7 +61,9 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { constructor( private workflowActionService: WorkflowActionService, - private panelService: PanelService + private panelService: PanelService, + protected config: GuiConfigService, + private jupyterPanelService: JupyterPanelService ) {} ngAfterViewInit() { @@ -156,6 +162,17 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { ); } + /** + * This method will expand and redisplay the jupyter notebook. + */ + public get pythonNotebookMigrationEnabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + + public onClickExpandJupyterNotebookPanel(): void { + this.jupyterPanelService.openJupyterNotebookPanel(); + } + public triggerCenter(): void { this.workflowActionService.getTexeraGraph().triggerCenterEvent(); if (this.navigatorDrag) this.navigatorDrag.reset(); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index e5e9a436180..20a5c6793a4 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -95,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).toBeTrue(); + + service.closeJupyterNotebookPanel(); + expect(state).toBeFalse(); + }); + + it("should minimize panel", () => { + let state: boolean | null = true; + + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + + service.minimizeJupyterNotebookPanel(); + + expect(state).toBeFalse(); + }); + + // openJupyterNotebookPanel + it("should warn if no mapping exists", () => { + mockNotebook.hasMapping.and.returnValue(false); + + service.openJupyterNotebookPanel(); + + expect(mockNotification.warning).toHaveBeenCalled(); + }); + + it("should open panel if mapping exists", () => { + mockNotebook.hasMapping.and.returnValue(true); + + let state: boolean | null = false; + + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + + service.openJupyterNotebookPanel(); + + expect(state).toBeTrue(); + }); + + // 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).toBeFalse(); + + service.openPanel("JupyterNotebookPanel"); + expect(state).toBeTrue(); + }); + // HTTP fetchNotebookAndMapping it("should return 0 when exists=false", done => { (service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => { @@ -162,11 +219,10 @@ describe("JupyterPanelService", () => { ); }); - // Feature flag gate (defence in depth). With the flag off, init must not - // subscribe to workflow changes, and onWorkflowComponentClick must not - // postMessage to the iframe. The window message listener is installed in - // the constructor unconditionally, but handleNotebookMessage returns early - // on the flag check. + // Feature flag gate (defence in depth). With the flag off, every public + // method must short-circuit — no subscription in init, no visibility flips, + // no postMessage. The window message listener is installed in the constructor + // unconditionally, but handleNotebookMessage returns early on the flag check. describe("when the feature flag is disabled", () => { beforeEach(() => { mockGuiConfig.env.pythonNotebookMigrationEnabled = false; @@ -177,6 +233,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).toBeFalse(); + }); + + 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).toBeTrue(); + }); + + it("openJupyterNotebookPanel does not warn or flip visibility", () => { + mockNotebook.hasMapping.and.returnValue(false); + let state: boolean | null = false; + service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); + service.openJupyterNotebookPanel(); + expect(state).toBeFalse(); + expect(mockNotification.warning).not.toHaveBeenCalled(); + }); + it("onWorkflowComponentClick does not postMessage to the iframe", async () => { const mockIframe = { contentWindow: { postMessage: jasmine.createSpy() }, diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 395147df07c..b5f704c1397 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -18,7 +18,7 @@ */ 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"; @@ -34,6 +34,9 @@ import { GuiConfigService } from "../../../common/service/gui-config.service"; providedIn: "root", }) export class JupyterPanelService { + private jupyterNotebookPanelVisible = new BehaviorSubject(false); + public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable(); + private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element private cellContent: string[] = []; // Store the content of the cells private highlightedCell: number | null = null; // Track the highlighted cell @@ -64,20 +67,12 @@ export class JupyterPanelService { distinctUntilChanged() ) .subscribe(wid => { - // Drop any stale mapping for the current workflow. This previously - // happened inside closeJupyterNotebookPanel; the panel-visibility - // surface lives with the iframe component in - // `migration-tool-jupyter-panel` now, so the cleanup is inlined. - const currentWid = this.workflowActionService.getWorkflow().wid; - if (currentWid !== undefined) { - this.notebookMigrationService.deleteMapping("mapping_wid_" + currentWid); - } + this.closeJupyterNotebookPanel(); if (wid != 0) { this.fetchNotebookAndMapping(wid).subscribe(result => { if (result == 1) { this.precomputeHighlightMapping(); - // Panel auto-open on workflow restore is wired in - // `migration-tool-jupyter-panel` once the visibility API exists. + this.openJupyterNotebookPanel(); } }); } @@ -163,12 +158,50 @@ export class JupyterPanelService { } } - // Set the iframe reference (from the component's ViewChild). The panel - // component that calls this lives in `migration-tool-jupyter-panel`. + // Set the iframe reference (from the component's ViewChild) setIframeRef(iframe: HTMLIFrameElement) { this.iframeRef = iframe; } + // Open the Jupyter Notebook panel + openPanel(panelName: string): void { + if (!this.enabled) return; + if (panelName === "JupyterNotebookPanel") { + this.jupyterNotebookPanelVisible.next(true); + } + } + + // Close the Jupyter Notebook panel + 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; From d48a403f395bc10a85e9440d9870f44b407991c4 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 20 Jul 2026 10:50:47 -0700 Subject: [PATCH 05/19] untrack local dev-metadata docker-compose file --- sql/docker-compose.dev-metadata.yml | 45 ----------------------------- 1 file changed, 45 deletions(-) delete mode 100644 sql/docker-compose.dev-metadata.yml diff --git a/sql/docker-compose.dev-metadata.yml b/sql/docker-compose.dev-metadata.yml deleted file mode 100644 index c38468f2a2d..00000000000 --- a/sql/docker-compose.dev-metadata.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Local dev metadata Postgres for Texera (created during local setup). -# Runs Postgres 15 with the PGroonga full-text-search extension preinstalled, -# published on localhost:5432 so backend services (run in IntelliJ) can reach it. -# -# The two SQL files are mounted into /docker-entrypoint-initdb.d and run ONCE, -# on first boot with an empty data volume, via psql. They create: -# - texera_db (metadata / user system) -> storage.conf `jdbc` -# - texera_iceberg_catalog (Iceberg catalog) -> storage.conf `iceberg.catalog.postgres` -# and the `texera` role, and enable the pgroonga extension. -# -# Credentials (postgres/postgres) match the defaults in -# common/config/src/main/resources/storage.conf. -# -# Usage: -# docker compose -f sql/docker-compose.dev-metadata.yml up -d # start -# docker compose -f sql/docker-compose.dev-metadata.yml logs -f # watch init -# docker compose -f sql/docker-compose.dev-metadata.yml down # stop (keeps data) -# docker compose -f sql/docker-compose.dev-metadata.yml down -v # stop + wipe data (re-runs init next boot) - -name: texera-db -services: - postgres: - image: groonga/pgroonga:4.0.1-debian-15 - container_name: texera-db - restart: unless-stopped - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - # POSTGRES_DB defaults to POSTGRES_USER (postgres); the init scripts - # create texera_db and texera_iceberg_catalog themselves. - ports: - - "5432:5432" - volumes: - - texera-db-data:/var/lib/postgresql/data - # Init scripts (run alphabetically, once, on empty volume): - - ./iceberg_postgres_catalog.sql:/docker-entrypoint-initdb.d/01_iceberg_postgres_catalog.sql:ro - - ./texera_ddl.sql:/docker-entrypoint-initdb.d/02_texera_ddl.sql:ro - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres", "-d", "texera_db"] - interval: 10s - retries: 5 - start_period: 10s - -volumes: - texera-db-data: From 2ffebb20b75ae3a5c855d0d37aa0900193bfb121 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 20 Jul 2026 11:03:08 -0700 Subject: [PATCH 06/19] fix to trigger notebook cell once per operator click --- .../component/workflow-editor/workflow-editor.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts index fb662af55cc..f84ed89e88a 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts @@ -681,14 +681,15 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy const elementID = event[0].model.id.toString(); const highlightedOperatorIDs = this.wrapper.getCurrentHighlightedOperatorIDs(); const highlightedCommentBoxIDs = this.wrapper.getCurrentHighlightedCommentBoxIDs(); - this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell + if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) { + this.jupyterPanelService.onWorkflowComponentClick(elementID); // highlight corresponding Jupyter notebook cell + } if (event[1].shiftKey) { // if in multiselect toggle highlights on click if (highlightedOperatorIDs.includes(elementID)) { this.workflowActionService.unhighlightOperators(elementID); } else if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) { this.workflowActionService.highlightOperators(event[1].shiftKey, elementID); - this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell } if (highlightedCommentBoxIDs.includes(elementID)) { this.wrapper.unhighlightCommentBoxes(elementID); From a167fbbf2c9d83b19a707d3f64c060966b20c13f Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 20 Jul 2026 11:24:45 -0700 Subject: [PATCH 07/19] migrate service spec from jasmine to vitest --- .../jupyter-panel.service.spec.ts | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index 20a5c6793a4..5dd0bc306dd 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -24,7 +24,7 @@ import { HttpClientTestingModule, HttpTestingController } from "@angular/common/ 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 { of } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; describe("JupyterPanelService", () => { let service: JupyterPanelService; @@ -39,9 +39,9 @@ describe("JupyterPanelService", () => { beforeEach(() => { mockWorkflow = { - workflowMetaDataChanged: jasmine.createSpy().and.returnValue(of({ wid: 1 })), - getWorkflow: jasmine.createSpy().and.returnValue({ wid: 1 }), - getTexeraGraph: jasmine.createSpy().and.returnValue({ + workflowMetaDataChanged: vi.fn().mockReturnValue(of({ wid: 1 })), + getWorkflow: vi.fn().mockReturnValue({ wid: 1 }), + getTexeraGraph: vi.fn().mockReturnValue({ getAllLinks: () => [ { linkID: "L1", @@ -51,27 +51,27 @@ describe("JupyterPanelService", () => { ], getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }], }), - highlightOperators: jasmine.createSpy(), - highlightLinks: jasmine.createSpy(), - unhighlightOperators: jasmine.createSpy(), - unhighlightLinks: jasmine.createSpy(), + highlightOperators: vi.fn(), + highlightLinks: vi.fn(), + unhighlightOperators: vi.fn(), + unhighlightLinks: vi.fn(), }; mockNotification = { - warning: jasmine.createSpy(), + warning: vi.fn(), }; mockNotebook = { - hasMapping: jasmine.createSpy().and.returnValue(true), - getMapping: jasmine.createSpy().and.returnValue({ + hasMapping: vi.fn().mockReturnValue(true), + getMapping: vi.fn().mockReturnValue({ cell_to_operator: { cell1: ["A", "B"], }, operator_to_cell: {}, }), - deleteMapping: jasmine.createSpy(), - setMapping: jasmine.createSpy(), - getJupyterURL: jasmine.createSpy().and.resolveTo("http://jupyter"), + deleteMapping: vi.fn(), + setMapping: vi.fn(), + getJupyterURL: vi.fn().mockResolvedValue("http://jupyter"), }; mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; @@ -102,10 +102,10 @@ describe("JupyterPanelService", () => { service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); service.openPanel("JupyterNotebookPanel"); - expect(state).toBeTrue(); + expect(state).toBe(true); service.closeJupyterNotebookPanel(); - expect(state).toBeFalse(); + expect(state).toBe(false); }); it("should minimize panel", () => { @@ -115,12 +115,12 @@ describe("JupyterPanelService", () => { service.minimizeJupyterNotebookPanel(); - expect(state).toBeFalse(); + expect(state).toBe(false); }); // openJupyterNotebookPanel it("should warn if no mapping exists", () => { - mockNotebook.hasMapping.and.returnValue(false); + mockNotebook.hasMapping.mockReturnValue(false); service.openJupyterNotebookPanel(); @@ -128,7 +128,7 @@ describe("JupyterPanelService", () => { }); it("should open panel if mapping exists", () => { - mockNotebook.hasMapping.and.returnValue(true); + mockNotebook.hasMapping.mockReturnValue(true); let state: boolean | null = false; @@ -136,7 +136,7 @@ describe("JupyterPanelService", () => { service.openJupyterNotebookPanel(); - expect(state).toBeTrue(); + expect(state).toBe(true); }); // openPanel @@ -146,22 +146,20 @@ describe("JupyterPanelService", () => { service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); service.openPanel("WrongPanel"); - expect(state).toBeFalse(); + expect(state).toBe(false); service.openPanel("JupyterNotebookPanel"); - expect(state).toBeTrue(); + expect(state).toBe(true); }); // HTTP fetchNotebookAndMapping - it("should return 0 when exists=false", done => { - (service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => { - expect(result).toBe(0); - done(); - }); + it("should return 0 when exists=false", async () => { + const resultPromise = firstValueFrom((service as any).fetchNotebookAndMapping(1, 1)); const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); - req.flush({ exists: false }); + + expect(await resultPromise).toBe(0); }); // iframe ref @@ -196,12 +194,12 @@ describe("JupyterPanelService", () => { it("should postMessage when mapping exists", async () => { const mockIframe = { contentWindow: { - postMessage: jasmine.createSpy(), + postMessage: vi.fn(), }, } as any; service.setIframeRef(mockIframe); - (mockNotebook as any).getMapping.and.returnValue({ + mockNotebook.getMapping.mockReturnValue({ cell_to_operator: {}, operator_to_cell: { cell1: ["op1", "op2"], @@ -237,7 +235,7 @@ describe("JupyterPanelService", () => { let state: boolean | null = false; service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); service.openPanel("JupyterNotebookPanel"); - expect(state).toBeFalse(); + expect(state).toBe(false); }); it("closeJupyterNotebookPanel does not flip visibility or delete the mapping", () => { @@ -251,21 +249,21 @@ describe("JupyterPanelService", () => { const visibleSubject = (service as any).jupyterNotebookPanelVisible; visibleSubject.next(true); service.minimizeJupyterNotebookPanel(); - expect(visibleSubject.value).toBeTrue(); + expect(visibleSubject.value).toBe(true); }); it("openJupyterNotebookPanel does not warn or flip visibility", () => { - mockNotebook.hasMapping.and.returnValue(false); + mockNotebook.hasMapping.mockReturnValue(false); let state: boolean | null = false; service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); service.openJupyterNotebookPanel(); - expect(state).toBeFalse(); + expect(state).toBe(false); expect(mockNotification.warning).not.toHaveBeenCalled(); }); it("onWorkflowComponentClick does not postMessage to the iframe", async () => { const mockIframe = { - contentWindow: { postMessage: jasmine.createSpy() }, + contentWindow: { postMessage: vi.fn() }, } as any; service.setIframeRef(mockIframe); await service.onWorkflowComponentClick("cell1"); From 7dc3b4d9bc739756be9f88437641ad109c69c19d Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Mon, 20 Jul 2026 11:30:17 -0700 Subject: [PATCH 08/19] cache jupyter origin and normalize it for message checks --- .../jupyter-panel.service.spec.ts | 19 +++++++++ .../jupyter-panel/jupyter-panel.service.ts | 42 +++++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index 5dd0bc306dd..b0d63531381 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -217,6 +217,25 @@ describe("JupyterPanelService", () => { ); }); + // The Jupyter origin is process-static, so it must be resolved once and cached + // rather than re-fetched on every click / incoming message. + it("resolves the Jupyter URL only once across multiple clicks", async () => { + const mockIframe = { + contentWindow: { postMessage: vi.fn() }, + } as any; + service.setIframeRef(mockIframe); + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: {}, + operator_to_cell: { cell1: ["op1"] }, + }); + + await service.onWorkflowComponentClick("cell1"); + await service.onWorkflowComponentClick("cell1"); + await service.onWorkflowComponentClick("cell1"); + + expect(mockNotebook.getJupyterURL).toHaveBeenCalledTimes(1); + }); + // Feature flag gate (defence in depth). With the flag off, every public // method must short-circuit — no subscription in init, no visibility flips, // no postMessage. The window message listener is installed in the constructor diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index b5f704c1397..b58196e00ed 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -44,6 +44,9 @@ export class JupyterPanelService { // Precomputed dictionary for cell to highlight mapping private cellToHighlightMapping: Record = {}; + // Cached Jupyter server origin (see resolveJupyterOrigin) + private jupyterOrigin: Promise | null = null; + constructor( private workflowActionService: WorkflowActionService, private http: HttpClient, @@ -58,6 +61,32 @@ export class JupyterPanelService { return this.config.env.pythonNotebookMigrationEnabled; } + /** + * Resolve and cache the Jupyter server origin, used both to validate incoming + * iframe messages and as the postMessage target. The backend serves a + * process-static base URL, so the origin is fixed for the app's lifetime. + * A failed/unavailable lookup is not cached, so it can be retried once the + * Jupyter pod becomes reachable. + */ + private resolveJupyterOrigin(): Promise { + if (this.jupyterOrigin) { + return this.jupyterOrigin; + } + const pending: Promise = this.notebookMigrationService.getJupyterURL().then(url => { + if (url) { + try { + return new URL(url).origin; + } catch { + /* malformed URL — fall through to retry */ + } + } + this.jupyterOrigin = null; // don't cache failures + return null; + }); + this.jupyterOrigin = pending; + return pending; + } + public init(): void { if (!this.enabled) return; this.workflowActionService @@ -205,7 +234,9 @@ export class JupyterPanelService { // Handle messages from the Jupyter notebook iframe private handleNotebookMessage = async (event: MessageEvent) => { if (!this.enabled) return; - const allowedOrigins = [window.location.origin, await this.notebookMigrationService.getJupyterURL()]; + const jupyterOrigin = await this.resolveJupyterOrigin(); + const allowedOrigins = [window.location.origin]; + if (jupyterOrigin) allowedOrigins.push(jupyterOrigin); if (!allowedOrigins.includes(event.origin)) { return; } @@ -248,8 +279,8 @@ export class JupyterPanelService { // Handle when a Texera component is clicked to trigger the corresponding notebook cell async onWorkflowComponentClick(cellUUID: string): Promise { if (!this.enabled) return; - const jupyterURL = await this.notebookMigrationService.getJupyterURL(); - if (jupyterURL && this.iframeRef && this.iframeRef.contentWindow) { + const jupyterOrigin = await this.resolveJupyterOrigin(); + if (jupyterOrigin && this.iframeRef && this.iframeRef.contentWindow) { const wid = this.workflowActionService.getWorkflow().wid; if (wid == undefined) { @@ -267,7 +298,10 @@ export class JupyterPanelService { const operatorArray = mappingEntry["operator_to_cell"][cellUUID]; if (operatorArray) { - this.iframeRef.contentWindow.postMessage({ action: "triggerCellClick", operators: operatorArray }, jupyterURL); + this.iframeRef.contentWindow.postMessage( + { action: "triggerCellClick", operators: operatorArray }, + jupyterOrigin + ); } else { console.error(`No operators found for cellUUID: ${cellUUID}`); } From da972317e4b9a83922f8902333b3ac928f96ff84 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:04:44 -0700 Subject: [PATCH 09/19] remove unused FormlyRepeatDndComponent import from mini-map --- .../component/workflow-editor/mini-map/mini-map.component.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts index 305f37503ca..795d610a926 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts @@ -31,7 +31,6 @@ import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; -import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component"; import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; @@ -47,7 +46,6 @@ import { GuiConfigService } from "../../../../common/service/gui-config.service" ɵNzTransitionPatchDirective, NzIconDirective, CdkDrag, - FormlyRepeatDndComponent, CommonModule, ], }) From 4e83e26b4fc2c173d61fe86df3d7a9a4340ef650 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:13:36 -0700 Subject: [PATCH 10/19] build component highlight mapping for linkless workflows --- .../jupyter-panel.service.spec.ts | 19 +++++++++++++++++++ .../jupyter-panel/jupyter-panel.service.ts | 4 ---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index b0d63531381..69b26e537c6 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -190,6 +190,25 @@ describe("JupyterPanelService", () => { expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1"); }); + // A workflow with operators but no links is valid; precompute must still + // record each cell's components (with empty edges) so cell clicks highlight. + it("precomputes component mappings even when the graph has no links", () => { + mockWorkflow.getTexeraGraph.mockReturnValue({ + getAllLinks: () => [], + getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }], + }); + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: { cell1: ["A", "B"] }, + operator_to_cell: {}, + }); + + (service as any).precomputeHighlightMapping(); + + expect((service as any).cellToHighlightMapping).toEqual({ + cell1: { components: ["A", "B"], edges: [] }, + }); + }); + // onWorkflowComponentClick it("should postMessage when mapping exists", async () => { const mockIframe = { diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index b58196e00ed..6e24188f9c9 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -160,10 +160,6 @@ export class JupyterPanelService { const cellToOperator = mapping.cell_to_operator; const allLinks: OperatorLink[] = this.workflowActionService.getTexeraGraph().getAllLinks(); - if (allLinks.length === 0) { - console.warn("No links found in the graph during precompute."); - return; - } for (const cellUUID in cellToOperator) { const components = cellToOperator[cellUUID] || []; From 709068454b0487d9ec6e4a8520262ed65741cc59 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:25:01 -0700 Subject: [PATCH 11/19] reset highlight mapping on each precompute to avoid stale entries --- .../jupyter-panel.service.spec.ts | 25 +++++++++++++++++++ .../jupyter-panel/jupyter-panel.service.ts | 3 +++ 2 files changed, 28 insertions(+) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index 69b26e537c6..b37a48a3dc3 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -209,6 +209,31 @@ describe("JupyterPanelService", () => { }); }); + // Switching workflows re-runs precompute; the map must reflect only the + // current workflow, not accumulate entries from previously opened ones. + it("resets the highlight mapping on each precompute", () => { + mockWorkflow.getTexeraGraph.mockReturnValue({ + getAllLinks: () => [], + getAllOperators: () => [], + }); + + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: { cellA: ["A"] }, + operator_to_cell: {}, + }); + (service as any).precomputeHighlightMapping(); + + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: { cellB: ["B"] }, + operator_to_cell: {}, + }); + (service as any).precomputeHighlightMapping(); + + expect((service as any).cellToHighlightMapping).toEqual({ + cellB: { components: ["B"], edges: [] }, + }); + }); + // onWorkflowComponentClick it("should postMessage when mapping exists", async () => { const mockIframe = { diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 6e24188f9c9..4b849ca2840 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -144,6 +144,9 @@ export class JupyterPanelService { // Precompute the dictionary for O(1) highlighting private precomputeHighlightMapping(): void { + // Rebuild from scratch so entries from a previously opened workflow don't linger. + this.cellToHighlightMapping = {}; + const wid = this.workflowActionService.getWorkflow().wid; if (wid === undefined) { From 42c0dc0ad8efc60f526db01d33be5b15836eb4d1 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:27:27 -0700 Subject: [PATCH 12/19] remove unused highlightedCell and cellContent fields --- .../service/jupyter-panel/jupyter-panel.service.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 4b849ca2840..7443dfaca1b 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -38,8 +38,6 @@ export class JupyterPanelService { public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable(); private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element - private cellContent: string[] = []; // Store the content of the cells - private highlightedCell: number | null = null; // Track the highlighted cell // Precomputed dictionary for cell to highlight mapping private cellToHighlightMapping: Record = {}; @@ -240,10 +238,8 @@ export class JupyterPanelService { return; } - const { action, cellIndex, cellContent, cellUUID } = event.data; + const { action, cellUUID } = event.data; if (action === "cellClicked") { - this.highlightedCell = cellIndex; - this.cellContent[cellIndex] = cellContent || `Cell ${cellIndex + 1}`; this.highlightFromCell(cellUUID); } }; From 5bfa2e1ae19b85d9d99691973ecea40eadd2adbc Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:33:48 -0700 Subject: [PATCH 13/19] drop no-op UntilDestroy decorator from jupyter panel service --- .../workspace/service/jupyter-panel/jupyter-panel.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 7443dfaca1b..f9699fa5206 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -22,14 +22,12 @@ 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 { UntilDestroy } from "@ngneat/until-destroy"; 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"; import { GuiConfigService } from "../../../common/service/gui-config.service"; -@UntilDestroy() @Injectable({ providedIn: "root", }) From 8a47fdb398a9b99597849b6f63babcc6503e9820 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:39:53 -0700 Subject: [PATCH 14/19] cover expand-jupyter button visibility and click --- .../mini-map/mini-map.component.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts index 3686454ca6a..dfc91d4d5fd 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts @@ -28,6 +28,11 @@ import { UndoRedoService } from "../../../service/undo-redo/undo-redo.service"; import { WorkflowUtilService } from "../../../service/workflow-graph/util/workflow-util.service"; import { DragDropModule } from "@angular/cdk/drag-drop"; import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { GuiConfigService } from "../../../../common/service/gui-config.service"; +import { MockGuiConfigService } from "../../../../common/service/gui-config.service.mock"; +import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; + +const EXPAND_JUPYTER_BUTTON = "#minimap-expand-jupyter-button"; describe("MiniMapComponent", () => { let fixture: ComponentFixture; @@ -58,4 +63,33 @@ describe("MiniMapComponent", () => { it("should create", () => { expect(fixture.componentInstance).toBeTruthy(); }); + + it("hides the expand-jupyter button when the migration flag is off", () => { + // commonTestProviders' MockGuiConfigService defaults the flag to false. + expect(fixture.nativeElement.querySelector(EXPAND_JUPYTER_BUTTON)).toBeNull(); + }); + + it("shows the expand-jupyter button when the migration flag is on", () => { + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + pythonNotebookMigrationEnabled: true, + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.pythonNotebookMigrationEnabled).toBe(true); + expect(fixture.nativeElement.querySelector(EXPAND_JUPYTER_BUTTON)).not.toBeNull(); + }); + + it("opens the jupyter panel when the expand button is clicked", () => { + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + pythonNotebookMigrationEnabled: true, + }); + fixture.detectChanges(); + const openSpy = vi + .spyOn(TestBed.inject(JupyterPanelService), "openJupyterNotebookPanel") + .mockImplementation(() => {}); + + fixture.nativeElement.querySelector(EXPAND_JUPYTER_BUTTON).click(); + + expect(openSpy).toHaveBeenCalled(); + }); }); From 5ae0458f097ba29ca49f92dcc2d7d6552eaca839 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 11:42:20 -0700 Subject: [PATCH 15/19] tidy mini-map jupyter expand: private config and correct jsdoc --- .../workflow-editor/mini-map/mini-map.component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts index 795d610a926..1c13dc326b5 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts @@ -60,7 +60,7 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { constructor( private workflowActionService: WorkflowActionService, private panelService: PanelService, - protected config: GuiConfigService, + private config: GuiConfigService, private jupyterPanelService: JupyterPanelService ) {} @@ -160,13 +160,13 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { ); } - /** - * This method will expand and redisplay the jupyter notebook. - */ public get pythonNotebookMigrationEnabled(): boolean { return this.config.env.pythonNotebookMigrationEnabled; } + /** + * Expand and redisplay the Jupyter notebook panel. + */ public onClickExpandJupyterNotebookPanel(): void { this.jupyterPanelService.openJupyterNotebookPanel(); } From 8a143c869230bafa8acb432d5238a80968ad65fb Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Tue, 21 Jul 2026 12:05:25 -0700 Subject: [PATCH 16/19] claim ai-sdk npm dependencies in frontend LICENSE-binary --- frontend/LICENSE-binary | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary index ac390c179e5..ec9ea36ede8 100644 --- a/frontend/LICENSE-binary +++ b/frontend/LICENSE-binary @@ -211,6 +211,13 @@ Dependencies under the Apache License, Version 2.0 -------------------------------------------------------------------------------- Angular / npm packages: + - @ai-sdk/gateway@2.0.9 + - @ai-sdk/openai@2.0.67 + - @ai-sdk/provider@2.0.0 + - @ai-sdk/provider-utils@3.0.17 + - @opentelemetry/api@1.9.0 + - @vercel/oidc@3.0.3 + - ai@5.0.93 - dompurify@3.3.1 - fast-diff@1.3.0 - fuse.js@6.5.3 @@ -291,6 +298,7 @@ Angular / npm packages: - dagre@0.8.5 - date-fns@2.30.0 - eventemitter3@5.0.4 + - eventsource-parser@3.0.8 - fast-deep-equal@3.1.3 - fflate@0.7.4 - file-saver@2.0.5 @@ -337,6 +345,7 @@ Angular / npm packages: - y-quill@1.0.0 - y-websocket@1.5.4 - yjs@13.6.31 + - zod@3.25.76 - zone.js@0.15.1 -------------------------------------------------------------------------------- From 543f7aa51bcf4d7cd7cd9d2be56b012a4e61f49f Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Wed, 22 Jul 2026 15:30:11 -0700 Subject: [PATCH 17/19] reconcile jupyter panel init after merging main --- .../jupyter-panel/jupyter-panel.service.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 54538754544..0f90b02d7ef 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -92,30 +92,19 @@ 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 = {}; // Skip unsaved workflows (wid undefined) and wid 0; both would POST // without a usable wid and 500 on the backend. if (wid) { - this.closeJupyterNotebookPanel(); - if (wid != 0) { this.fetchNotebookAndMapping(wid).subscribe(result => { if (result == 1) { this.precomputeHighlightMapping(); this.openJupyterNotebookPanel(); - // Panel auto-open on workflow restore is wired in - // `migration-tool-jupyter-panel` once the visibility API exists. } }); } @@ -200,7 +189,6 @@ export class JupyterPanelService { } } - // Set the iframe reference (from the component's ViewChild) // Set the iframe reference (from the component's ViewChild). The panel // component that calls this lives in `migration-tool-jupyter-panel`. setIframeRef(iframe: HTMLIFrameElement) { From 5e66e5b689925a18d18add34e44f7eb304abd979 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Fri, 24 Jul 2026 13:43:43 -0700 Subject: [PATCH 18/19] remove mini-map expand button and add notebook-exists signal to jupyter panel service --- .../mini-map/mini-map.component.html | 11 ------ .../mini-map/mini-map.component.scss | 7 ---- .../mini-map/mini-map.component.spec.ts | 34 ------------------- .../mini-map/mini-map.component.ts | 19 +---------- .../jupyter-panel.service.spec.ts | 17 ++++++++++ .../jupyter-panel/jupyter-panel.service.ts | 9 +++++ 6 files changed, 27 insertions(+), 70 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html index df1d86f6386..adbf9f6ab5c 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html @@ -53,17 +53,6 @@ nz-icon nzType="zoom-in"> -
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss index 293dae89316..c4d9667dc86 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss @@ -45,13 +45,6 @@ z-index: 4; } -#minimap-expand-jupyter-button { - position: absolute; - bottom: 0; - right: 120px; - z-index: 4; -} - #mini-map-container { position: relative; overflow: hidden; diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts index dfc91d4d5fd..3686454ca6a 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.spec.ts @@ -28,11 +28,6 @@ import { UndoRedoService } from "../../../service/undo-redo/undo-redo.service"; import { WorkflowUtilService } from "../../../service/workflow-graph/util/workflow-util.service"; import { DragDropModule } from "@angular/cdk/drag-drop"; import { commonTestProviders } from "../../../../common/testing/test-utils"; -import { GuiConfigService } from "../../../../common/service/gui-config.service"; -import { MockGuiConfigService } from "../../../../common/service/gui-config.service.mock"; -import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; - -const EXPAND_JUPYTER_BUTTON = "#minimap-expand-jupyter-button"; describe("MiniMapComponent", () => { let fixture: ComponentFixture; @@ -63,33 +58,4 @@ describe("MiniMapComponent", () => { it("should create", () => { expect(fixture.componentInstance).toBeTruthy(); }); - - it("hides the expand-jupyter button when the migration flag is off", () => { - // commonTestProviders' MockGuiConfigService defaults the flag to false. - expect(fixture.nativeElement.querySelector(EXPAND_JUPYTER_BUTTON)).toBeNull(); - }); - - it("shows the expand-jupyter button when the migration flag is on", () => { - (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ - pythonNotebookMigrationEnabled: true, - }); - fixture.detectChanges(); - - expect(fixture.componentInstance.pythonNotebookMigrationEnabled).toBe(true); - expect(fixture.nativeElement.querySelector(EXPAND_JUPYTER_BUTTON)).not.toBeNull(); - }); - - it("opens the jupyter panel when the expand button is clicked", () => { - (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ - pythonNotebookMigrationEnabled: true, - }); - fixture.detectChanges(); - const openSpy = vi - .spyOn(TestBed.inject(JupyterPanelService), "openJupyterNotebookPanel") - .mockImplementation(() => {}); - - fixture.nativeElement.querySelector(EXPAND_JUPYTER_BUTTON).click(); - - expect(openSpy).toHaveBeenCalled(); - }); }); diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts index 1c13dc326b5..73a2c5bd089 100644 --- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts @@ -17,7 +17,6 @@ * under the License. */ -import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, HostListener, OnDestroy, ViewChild } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; @@ -31,8 +30,6 @@ import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; -import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service"; -import { GuiConfigService } from "../../../../common/service/gui-config.service"; @UntilDestroy() @Component({ @@ -46,7 +43,6 @@ import { GuiConfigService } from "../../../../common/service/gui-config.service" ɵNzTransitionPatchDirective, NzIconDirective, CdkDrag, - CommonModule, ], }) export class MiniMapComponent implements AfterViewInit, OnDestroy { @@ -59,9 +55,7 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { constructor( private workflowActionService: WorkflowActionService, - private panelService: PanelService, - private config: GuiConfigService, - private jupyterPanelService: JupyterPanelService + private panelService: PanelService ) {} ngAfterViewInit() { @@ -160,17 +154,6 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy { ); } - public get pythonNotebookMigrationEnabled(): boolean { - return this.config.env.pythonNotebookMigrationEnabled; - } - - /** - * Expand and redisplay the Jupyter notebook panel. - */ - public onClickExpandJupyterNotebookPanel(): void { - this.jupyterPanelService.openJupyterNotebookPanel(); - } - public triggerCenter(): void { this.workflowActionService.getTexeraGraph().triggerCenterEvent(); if (this.navigatorDrag) this.navigatorDrag.reset(); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index daf48aa362e..17c8c0d33f2 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -162,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", () => { diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 0f90b02d7ef..83ad3600457 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -35,6 +35,13 @@ export class JupyterPanelService { private jupyterNotebookPanelVisible = new BehaviorSubject(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(false); + public jupyterNotebookExists$ = this.jupyterNotebookExists.asObservable(); + private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element // Precomputed dictionary for cell to highlight mapping @@ -98,11 +105,13 @@ export class JupyterPanelService { // 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(); this.openJupyterNotebookPanel(); } From d33f318c5df37e8aa8e9bcf1bfb48d2ac2323b75 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Fri, 24 Jul 2026 14:16:09 -0700 Subject: [PATCH 19/19] make jupyter panel visibility methods consistently public --- .../workspace/service/jupyter-panel/jupyter-panel.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index 83ad3600457..ceccff995a4 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -205,7 +205,7 @@ export class JupyterPanelService { } // Open the Jupyter Notebook panel - openPanel(panelName: string): void { + public openPanel(panelName: string): void { if (!this.enabled) return; if (panelName === "JupyterNotebookPanel") { this.jupyterNotebookPanelVisible.next(true); @@ -213,7 +213,7 @@ export class JupyterPanelService { } // Close the Jupyter Notebook panel - closeJupyterNotebookPanel(): void { + public closeJupyterNotebookPanel(): void { if (!this.enabled) return; this.jupyterNotebookPanelVisible.next(false); const wid = this.workflowActionService.getWorkflow().wid;