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..20a5c6793a4 --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -0,0 +1,275 @@ +/** + * 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 { GuiConfigService } from "src/app/common/service/gui-config.service"; +import { of } from "rxjs"; + +describe("JupyterPanelService", () => { + let service: JupyterPanelService; + let httpMock: HttpTestingController; + + let mockWorkflow: any; + let mockNotification: any; + let mockNotebook: any; + // Mutable so individual describe blocks can flip the flag mid-spec; the + // service stores a reference, so mutations are observed on the next read. + let mockGuiConfig: { env: { pythonNotebookMigrationEnabled: boolean } }; + + 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"), + }; + + mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + JupyterPanelService, + { provide: WorkflowActionService, useValue: mockWorkflow }, + { provide: NotificationService, useValue: mockNotification }, + { provide: NotebookMigrationService, useValue: mockNotebook }, + { provide: GuiConfigService, useValue: mockGuiConfig }, + ], + }); + + 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(); + }); + + // 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) => { + 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"); + }); + + // 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" + ); + }); + + // 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; + }); + + 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", () => { + // 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() }, + } 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 new file mode 100644 index 00000000000..b5f704c1397 --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -0,0 +1,276 @@ +/** + * 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"; +import { GuiConfigService } from "../../../common/service/gui-config.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, + 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( + 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 (!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; + 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 { + if (!this.enabled) return; + 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}`); + } + } + } +}