From 699a1a32c6d683d726ebf485ced960367ce2be51 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Thu, 28 May 2026 09:49:19 -0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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;