This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5271-ba63cedf0185dc1cd92fd4eec5f175a660639c02 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 0f7a42610e124e3860e74780ed62bc59ec5afe67 Author: Ryan Zhang <[email protected]> AuthorDate: Fri Jul 31 14:56:32 2026 -0700 feat(python-notebook-migration, frontend): add Jupyter notebook iframe panel component (#5271) ### What changes were proposed in this PR? Adds `JupyterNotebookPanelComponent`, a draggable iframe panel that hosts JupyterLab inside the Texera workspace, mounts it in the workspace shell, and wires its delete button to remove the current workflow's stored notebook through the panel service. - `frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts` - `JupyterNotebookPanelComponent` (standalone) - `jupyterUrl` is typed `SafeResourceUrl | null` and starts as `null`. - `ngOnInit` subscribes to `jupyterPanelService.jupyterNotebookPanelVisible$`. Each emission sets `isVisible`. When the panel becomes visible it fetches the iframe URL from `notebookMigrationService.getJupyterIframeURL()` (a rejected fetch is caught and yields `null` without ending the subscription), then assigns `jupyterUrl` to the sanitized URL (via `DomSanitizer.bypassSecurityTrustResourceUrl`) or to `null`, and calls `checkIframeRef()`. A `null` result (panel hidden, or a failed or empty fetch) clears `jupyterUrl` so a stale URL is never shown. - `ngAfterViewInit` calls `checkIframeRef()`, which registers the iframe with the panel service via `setIframeRef` inside a `setTimeout`, only when the panel is visible and `iframeRef` is present, and stays silent when the panel is hidden. - `deletePanel()` calls `jupyterPanelService.deleteJupyterNotebook()`. - `minimizePanel()` calls `jupyterPanelService.minimizeJupyterNotebookPanel()`; visibility is driven by the observable. - `frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html` - `cdkDrag` panel gated on `*ngIf="isVisible"`, with `cdkDragHandle` on the header so only the header is draggable. - Header title plus a minimize button (tooltip "Minimize") and a delete button (tooltip "Delete notebook") that opens an `nz-popconfirm` confirmation before deleting. - `<iframe [src]="jupyterUrl">` gated on `*ngIf="jupyterUrl"`, so the iframe renders only once a sanitized URL is available. - `frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss` - Fixed-position panel styling, header visuals, a muted hover color shared by the two header buttons, and iframe sizing. - `frontend/src/app/workspace/component/workspace.component.ts` and `workspace.component.html` - The standalone `WorkspaceComponent` imports `JupyterNotebookPanelComponent` and mounts `<texera-jupyter-notebook-panel>` in the workspace shell. - `frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts` - `deleteJupyterNotebook()`: deletes the current workflow's stored notebook via `notebookMigrationService.deleteNotebookAndMapping(wid)`. On success it hides the panel, drops the in-memory mapping, resets `jupyterNotebookExists$`, and unhighlights all operators and links. On failure it shows a notification and leaves the panel open. An unsaved workflow (no `wid`) resets local state without a backend call. - `hideAndClearLocalState()` (private): hides the panel and drops the current workflow's in-memory mapping only. The workflow-change handler in `init()` uses this, so switching workflows never deletes a notebook from the backend. - `clearHighlights()` (private): unhighlights all operators and links and clears the highlight index. - `frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts` - `deleteNotebookAndMapping(wid)`: sends a `POST` to `/notebook-migration/delete-notebook-and-mapping`, short-circuiting when the feature is disabled. ### Any related issues, documentation, discussions? Closes #5270 Parent issue #4301 ### How was this PR tested? Unit tests (Vitest): - `jupyter-notebook-panel.component.spec.ts`: visibility transitions, URL fetch and sanitization, the rejected-fetch path (the subscription stays alive and `jupyterUrl` stays `null`), the iframe rendering only when a URL is present and being removed on hide, iframe ref registration and the silent-when-hidden behavior, and delegation of the minimize and delete buttons. - `jupyter-panel.service.spec.ts`: `deleteJupyterNotebook` calls the backend, hides the panel, resets `jupyterNotebookExists$`, and unhighlights on success; keeps the panel open and notifies on failure; and a guard that switching workflows never triggers a backend delete. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --- .../jupyter-notebook-panel.component.html | 67 +++++ .../jupyter-notebook-panel.component.scss | 77 ++++++ .../jupyter-notebook-panel.component.spec.ts | 279 +++++++++++++++++++++ .../jupyter-notebook-panel.component.ts | 115 +++++++++ .../workspace/component/workspace.component.html | 1 + .../app/workspace/component/workspace.component.ts | 2 + .../jupyter-panel/jupyter-panel.service.spec.ts | 83 +++++- .../service/jupyter-panel/jupyter-panel.service.ts | 67 ++++- .../notebook-migration.service.spec.ts | 19 ++ .../notebook-migration.service.ts | 22 ++ 10 files changed, 718 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html new file mode 100644 index 0000000000..edca1ba47b --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html @@ -0,0 +1,67 @@ +<!-- + 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. +--> + +<div + class="draggable-panel" + *ngIf="isVisible" + cdkDrag + cdkDragBoundary="texera-workspace"> + <div + class="panel-header" + cdkDragHandle> + <span>Jupyter Notebook</span> + <div class="panel-buttons"> + <!-- Minimize button --> + <button + class="minimize-button" + nz-tooltip + nzTooltipTitle="Minimize" + (click)="minimizePanel()"> + <i + nz-icon + nzType="minus" + nzTheme="outline"></i> + </button> + <!-- Delete button --> + <button + class="delete-button" + nz-tooltip + nzTooltipTitle="Delete notebook" + nz-popconfirm + nzPopconfirmTitle="Delete this workflow's Jupyter notebook? This cannot be undone." + (nzOnConfirm)="deletePanel()"> + <i + nz-icon + nzType="delete" + nzTheme="outline"></i> + </button> + </div> + </div> + + <div class="iframe-container"> + <iframe + *ngIf="jupyterUrl" + #iframeRef + [src]="jupyterUrl" + title="Jupyter Notebook" + width="100%" + height="100%" + style="border: none"></iframe> + </div> +</div> diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss new file mode 100644 index 0000000000..8d8bb0631f --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss @@ -0,0 +1,77 @@ +/** + * 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. + */ + +.draggable-panel { + position: absolute; + top: 50%; + left: 50%; + margin-top: 200px; + width: 660px; + height: 400px; + background-color: #fff; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3); + border: 1px solid #ccc; + border-radius: 5px; + z-index: 1000; + resize: both; + overflow: auto; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #547baa; + color: white; + padding: 5px 10px 5px 10px; + font-weight: bold; + text-align: center; + cursor: move; +} + +.panel-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.minimize-button, +.delete-button { + background: none; + border: none; + color: white; + font-size: 16px; + cursor: pointer; +} + +.minimize-button:hover, +.delete-button:hover { + color: #bfbfbf; +} + +.iframe-container { + width: 100%; + height: calc(100% - 40px); /* Adjust height for the header */ +} + +.iframe-container iframe { + width: 100%; + height: 100%; + border: none; +} diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts new file mode 100644 index 0000000000..74d2e70298 --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts @@ -0,0 +1,279 @@ +/** + * 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 { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { JupyterNotebookPanelComponent } from "./jupyter-notebook-panel.component"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { Subject } from "rxjs"; +import { ElementRef } from "@angular/core"; +import { By, DomSanitizer } from "@angular/platform-browser"; + +describe("JupyterNotebookPanelComponent", () => { + let component: JupyterNotebookPanelComponent; + let fixture: ComponentFixture<JupyterNotebookPanelComponent>; + + let mockJupyterPanelService: any; + let mockNotebookMigrationService: any; + let bypassSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(async () => { + mockJupyterPanelService = { + jupyterNotebookPanelVisible$: new Subject<boolean>(), + setIframeRef: vi.fn(), + deleteJupyterNotebook: vi.fn(), + minimizeJupyterNotebookPanel: vi.fn(), + }; + + mockNotebookMigrationService = { + getJupyterIframeURL: vi.fn().mockResolvedValue("http://localhost:8888"), + }; + + await TestBed.configureTestingModule({ + imports: [JupyterNotebookPanelComponent], + providers: [ + { provide: JupyterPanelService, useValue: mockJupyterPanelService }, + { provide: NotebookMigrationService, useValue: mockNotebookMigrationService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(JupyterNotebookPanelComponent); + component = fixture.componentInstance; + // Spy on the shared DomSanitizer so tests can assert the raw URL was trusted, + // without matching Angular's serialized SafeResourceUrl string. + bypassSpy = vi.spyOn(TestBed.inject(DomSanitizer), "bypassSecurityTrustResourceUrl"); + fixture.detectChanges(); + }); + + // Destroy the component so its subscriptions complete, and restore spies so a + // shared spy's call history (e.g. console.error) does not accumulate across + // tests. Mocks are not auto-reset by the Vitest config. + afterEach(() => { + fixture.destroy(); + vi.restoreAllMocks(); + }); + + it("should create", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + expect(component).toBeTruthy(); + }); + + it("should be hidden by default", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + expect(component.isVisible).toBe(false); + }); + + it("should update visibility when service emits", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.isVisible).toBe(true); + }); + + it("should fetch and sanitize URL when panel becomes visible", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalled(); + expect(bypassSpy).toHaveBeenCalledWith("http://localhost:8888"); + expect(component.jupyterUrl).toBe(bypassSpy.mock.results[0].value); + }); + + it("should not render the iframe while visible without a URL", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + component.isVisible = true; + + // Rendering with an unset jupyterUrl must not throw NG0904 (unsafe resource + // URL); the iframe should simply be absent until a URL is available. + expect(() => fixture.detectChanges()).not.toThrow(); + expect(fixture.nativeElement.querySelector("iframe")).toBeNull(); + }); + + it("should render the iframe once a URL is available", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + fixture.detectChanges(); + + const iframe = fixture.nativeElement.querySelector("iframe"); + expect(iframe).not.toBeNull(); + // Don't match the serialized SafeResourceUrl; assert the binding produced a src. + expect(iframe.hasAttribute("src")).toBe(true); + }); + + it("should clear jupyterUrl and remove the iframe when hidden", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.jupyterUrl).not.toBeNull(); + expect(fixture.nativeElement.querySelector("iframe")).not.toBeNull(); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(false); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.jupyterUrl).toBeNull(); + expect(fixture.nativeElement.querySelector("iframe")).toBeNull(); + }); + + it("should not update jupyterUrl when the iframe URL fetch rejects", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + mockNotebookMigrationService.getJupyterIframeURL.mockRejectedValueOnce(new Error("network error")); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + + await fixture.whenStable(); + + expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalled(); + expect(component.jupyterUrl).toBeNull(); + }); + + it("should keep handling visibility emissions after a failed fetch", async () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + mockNotebookMigrationService.getJupyterIframeURL + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValueOnce("http://localhost:9999"); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + await fixture.whenStable(); + + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalledTimes(2); + expect(bypassSpy).toHaveBeenCalledWith("http://localhost:9999"); + expect(component.jupyterUrl).toBe(bypassSpy.mock.results[0].value); + }); + + it("should call setIframeRef when iframe exists and visible", fakeAsync(() => { + component.isVisible = true; + + const mockIframe = document.createElement("iframe"); + component.iframeRef = new ElementRef(mockIframe); + + component.checkIframeRef(); + tick(0); + + expect(mockJupyterPanelService.setIframeRef).toHaveBeenCalledWith(mockIframe); + })); + + it("should NOT call setIframeRef if not visible", fakeAsync(() => { + component.isVisible = false; + + const mockIframe = document.createElement("iframe"); + component.iframeRef = new ElementRef(mockIframe); + + component.checkIframeRef(); + tick(0); + + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); + })); + + it("should not log an error when checkIframeRef runs while the panel is hidden", fakeAsync(() => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + component.isVisible = false; + + component.checkIframeRef(); + tick(0); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); + })); + + it("should log an error when visible but the iframe ref is missing", fakeAsync(() => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + component.isVisible = true; + component.iframeRef = undefined as any; + + component.checkIframeRef(); + tick(0); + + expect(errorSpy).toHaveBeenCalledWith("Jupyter Iframe reference not found."); + expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled(); + })); + + it("should delete notebook via service", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + component.deletePanel(); + expect(mockJupyterPanelService.deleteJupyterNotebook).toHaveBeenCalled(); + }); + + it("should minimize panel and update visibility", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + // The real service emits false on minimize; visibility is observable-driven, + // so the mock must emit for isVisible to update. + mockJupyterPanelService.minimizeJupyterNotebookPanel = vi.fn(() => + mockJupyterPanelService.jupyterNotebookPanelVisible$.next(false) + ); + component.isVisible = true; + + component.minimizePanel(); + + expect(component.isVisible).toBe(false); + expect(mockJupyterPanelService.minimizeJupyterNotebookPanel).toHaveBeenCalled(); + }); + + it("should minimize via the header minimize button click", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + component.isVisible = true; + fixture.detectChanges(); + + const minimizeButton = fixture.debugElement.query(By.css(".minimize-button")); + expect(minimizeButton).not.toBeNull(); + minimizeButton.triggerEventHandler("click", new MouseEvent("click")); + + expect(mockJupyterPanelService.minimizeJupyterNotebookPanel).toHaveBeenCalled(); + }); + + it("should delete via the header delete button popconfirm", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + component.isVisible = true; + fixture.detectChanges(); + + const deleteButton = fixture.debugElement.query(By.css(".delete-button")); + expect(deleteButton).not.toBeNull(); + // Fire the popconfirm's confirm output, which runs the (nzOnConfirm) binding. + deleteButton.triggerEventHandler("nzOnConfirm", undefined); + + expect(mockJupyterPanelService.deleteJupyterNotebook).toHaveBeenCalled(); + }); + + it("should clean up on destroy", () => { + vi.spyOn(component, "checkIframeRef").mockImplementation(() => {}); + const nextSpy = vi.spyOn(component["destroy$"] as any, "next"); + const completeSpy = vi.spyOn(component["destroy$"] as any, "complete"); + + component.ngOnDestroy(); + + expect(nextSpy).toHaveBeenCalled(); + expect(completeSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts new file mode 100644 index 0000000000..4f3d957b51 --- /dev/null +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts @@ -0,0 +1,115 @@ +/** + * 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 { Component, ElementRef, OnDestroy, OnInit, ViewChild, AfterViewInit } from "@angular/core"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { from, of, Subject } from "rxjs"; +import { catchError, switchMap, takeUntil } from "rxjs/operators"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { CommonModule } from "@angular/common"; +import { DragDropModule } from "@angular/cdk/drag-drop"; +import { NzButtonModule } from "ng-zorro-antd/button"; +import { NzIconModule } from "ng-zorro-antd/icon"; +import { NzPopconfirmModule } from "ng-zorro-antd/popconfirm"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; + +@Component({ + selector: "texera-jupyter-notebook-panel", + templateUrl: "./jupyter-notebook-panel.component.html", + styleUrls: ["./jupyter-notebook-panel.component.scss"], + imports: [CommonModule, DragDropModule, NzButtonModule, NzIconModule, NzPopconfirmModule, NzTooltipModule], +}) +export class JupyterNotebookPanelComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild("iframeRef", { static: false }) iframeRef!: ElementRef<HTMLIFrameElement>; // Use static: false + + isVisible: boolean = false; // Initialize to false, meaning the panel is hidden by default + jupyterUrl: SafeResourceUrl | null = null; // Store the notebook URL dynamically; null when no URL is loaded + private destroy$ = new Subject<void>(); + + constructor( + private jupyterPanelService: JupyterPanelService, + private sanitizer: DomSanitizer, + private notebookMigrationService: NotebookMigrationService + ) {} + + ngOnInit(): void { + this.jupyterPanelService.jupyterNotebookPanelVisible$ + .pipe( + switchMap((visible: boolean) => { + this.isVisible = visible; + + if (!visible) { + return of(null); + } + + return from(this.notebookMigrationService.getJupyterIframeURL()).pipe( + catchError(() => { + console.error("Failed to fetch Jupyter iframe URL."); + return of(null); + }) + ); + }), + takeUntil(this.destroy$) + ) + .subscribe(url => { + // Always reflect the latest fetch result. A null url (panel hidden, or a + // failed/empty fetch) clears jupyterUrl so a stale URL is never rendered. + this.jupyterUrl = url ? this.sanitizer.bypassSecurityTrustResourceUrl(url) : null; + if (url) { + this.checkIframeRef(); + } + }); + } + + ngAfterViewInit(): void { + // Ensure iframe is handled after it's available in the DOM + this.checkIframeRef(); + } + + checkIframeRef(): void { + setTimeout(() => { + if (!this.isVisible) { + // Panel hidden; no iframe to register. + return; + } + if (this.iframeRef?.nativeElement) { + this.jupyterPanelService.setIframeRef(this.iframeRef.nativeElement); + } else { + console.error("Jupyter Iframe reference not found."); + } + }, 0); // Small timeout to ensure DOM is updated + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); // Cleanup subscriptions to avoid memory leaks + } + + // Delete the workflow's Jupyter notebook by invoking the service method + deletePanel(): void { + this.jupyterPanelService.deleteJupyterNotebook(); + } + + // Minimize the jupyter notebook by invoking the service method. Visibility is + // driven by jupyterNotebookPanelVisible$, so no local isVisible mutation here. + minimizePanel(): void { + this.jupyterPanelService.minimizeJupyterNotebookPanel(); + } +} diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html index c54446fb31..89eeb656f1 100644 --- a/frontend/src/app/workspace/component/workspace.component.html +++ b/frontend/src/app/workspace/component/workspace.component.html @@ -37,4 +37,5 @@ *ngIf="copilotEnabled" [agentIdToActivate]="agentIdToActivate"></texera-agent-panel> <texera-property-editor></texera-property-editor> +<texera-jupyter-notebook-panel></texera-jupyter-notebook-panel> <ng-template #codeEditor></ng-template> diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts index 2aa1c43db9..b978107c83 100644 --- a/frontend/src/app/workspace/component/workspace.component.ts +++ b/frontend/src/app/workspace/component/workspace.component.ts @@ -63,6 +63,7 @@ import { MiniMapComponent } from "./workflow-editor/mini-map/mini-map.component" import { LeftPanelComponent } from "./left-panel/left-panel.component"; import { AgentPanelComponent } from "./agent/agent-panel/agent-panel.component"; import { PropertyEditorComponent } from "./property-editor/property-editor.component"; +import { JupyterNotebookPanelComponent } from "./jupyter-notebook-panel/jupyter-notebook-panel.component"; export const SAVE_DEBOUNCE_TIME_IN_MS = 5000; @@ -85,6 +86,7 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000; NgIf, AgentPanelComponent, PropertyEditorComponent, + JupyterNotebookPanelComponent, ], }) export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { 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 17c8c0d33f..c60b06d368 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 { firstValueFrom, of } from "rxjs"; +import { firstValueFrom, of, throwError } from "rxjs"; describe("JupyterPanelService", () => { let service: JupyterPanelService; @@ -59,6 +59,7 @@ describe("JupyterPanelService", () => { mockNotification = { warning: vi.fn(), + error: vi.fn(), }; mockNotebook = { @@ -72,6 +73,7 @@ describe("JupyterPanelService", () => { deleteMapping: vi.fn(), setMapping: vi.fn(), getJupyterURL: vi.fn().mockResolvedValue("http://jupyter"), + deleteNotebookAndMapping: vi.fn().mockReturnValue(of({ success: true, deleted: 1 })), }; mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; @@ -96,7 +98,7 @@ describe("JupyterPanelService", () => { }); // Panel visibility - it("should open and close panel", () => { + it("should open panel and hide it after deleting the notebook", () => { let state: boolean | null = null; service.jupyterNotebookPanelVisible$.subscribe(v => (state = v)); @@ -104,10 +106,59 @@ describe("JupyterPanelService", () => { service.openPanel("JupyterNotebookPanel"); expect(state).toBe(true); - service.closeJupyterNotebookPanel(); + service.deleteJupyterNotebook(); + expect(mockNotebook.deleteNotebookAndMapping).toHaveBeenCalledWith(1); expect(state).toBe(false); }); + it("deleteJupyterNotebook clears local state and unhighlights on success", () => { + let visible: boolean | null = null; + let exists: boolean | null = null; + service.jupyterNotebookPanelVisible$.subscribe(v => (visible = v)); + service.jupyterNotebookExists$.subscribe(v => (exists = v)); + (service as any).jupyterNotebookPanelVisible.next(true); + (service as any).jupyterNotebookExists.next(true); + + service.deleteJupyterNotebook(); + + expect(mockNotebook.deleteNotebookAndMapping).toHaveBeenCalledWith(1); + expect(mockNotebook.deleteMapping).toHaveBeenCalledWith("mapping_wid_1"); + expect(visible).toBe(false); + expect(exists).toBe(false); + expect(mockWorkflow.unhighlightOperators).toHaveBeenCalled(); + expect(mockWorkflow.unhighlightLinks).toHaveBeenCalled(); + }); + + it("deleteJupyterNotebook keeps the panel open and notifies on failure", () => { + mockNotebook.deleteNotebookAndMapping.mockReturnValueOnce(throwError(() => new Error("boom"))); + let visible: boolean | null = null; + service.jupyterNotebookPanelVisible$.subscribe(v => (visible = v)); + (service as any).jupyterNotebookPanelVisible.next(true); + + service.deleteJupyterNotebook(); + + expect(mockNotification.error).toHaveBeenCalled(); + expect(visible).toBe(true); + expect(mockNotebook.deleteMapping).not.toHaveBeenCalled(); + }); + + it("deleteJupyterNotebook only resets local state for the default wid 0 (no backend call)", () => { + mockWorkflow.getWorkflow.mockReturnValue({ wid: 0 }); + let visible: boolean | null = null; + let exists: boolean | null = null; + service.jupyterNotebookPanelVisible$.subscribe(v => (visible = v)); + service.jupyterNotebookExists$.subscribe(v => (exists = v)); + (service as any).jupyterNotebookPanelVisible.next(true); + (service as any).jupyterNotebookExists.next(true); + + service.deleteJupyterNotebook(); + + // wid 0 is the unsaved default workflow, so no backend delete should fire. + expect(mockNotebook.deleteNotebookAndMapping).not.toHaveBeenCalled(); + expect(visible).toBe(false); + expect(exists).toBe(false); + }); + it("should minimize panel", () => { let state: boolean | null = true; @@ -152,6 +203,20 @@ describe("JupyterPanelService", () => { expect(state).toBe(true); }); + it("openPanel flags jupyterNotebookExists$ so the toolbar expand button appears after an in-place import", () => { + const states: boolean[] = []; + service.jupyterNotebookExists$.subscribe(v => states.push(v)); + expect(states.at(-1)).toBe(false); + + // Wrong panel name does not flip the flag. + service.openPanel("WrongPanel"); + expect(states.at(-1)).toBe(false); + + // Opening the jupyter panel records that the workflow now has a notebook. + service.openPanel("JupyterNotebookPanel"); + expect(states.at(-1)).toBe(true); + }); + // HTTP fetchNotebookAndMapping it("should return 0 when exists=false", async () => { const resultPromise = firstValueFrom((service as any).fetchNotebookAndMapping(1, 1)); @@ -186,6 +251,9 @@ describe("JupyterPanelService", () => { expect(mockWorkflow.workflowMetaDataChanged).toHaveBeenCalled(); expect(mockNotebook.deleteMapping).toHaveBeenCalledWith("mapping_wid_1"); + // Data-loss guard: switching workflows must never delete a notebook from the + // backend. It only drops the in-memory mapping. + expect(mockNotebook.deleteNotebookAndMapping).not.toHaveBeenCalled(); const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); req.flush({ exists: false }); @@ -391,10 +459,11 @@ describe("JupyterPanelService", () => { expect(state).toBe(false); }); - it("closeJupyterNotebookPanel does not flip visibility or delete the mapping", () => { - // BehaviorSubject's initial value is false; the meaningful assertion is - // that the side effect (deleteMapping) was never called. - service.closeJupyterNotebookPanel(); + it("deleteJupyterNotebook does not call the backend or delete the mapping when disabled", () => { + // When the feature is disabled the method returns early, so neither the + // backend delete nor the local mapping drop should run. + service.deleteJupyterNotebook(); + expect(mockNotebook.deleteNotebookAndMapping).not.toHaveBeenCalled(); expect(mockNotebook.deleteMapping).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 ceccff995a..c4f8ffcfbb 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 @@ -99,11 +99,12 @@ export class JupyterPanelService { distinctUntilChanged() ) .subscribe(wid => { - // 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(); + // On every workflow change, hide the panel and drop the outgoing + // workflow's stale in-memory 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 is local cleanup only: it must never + // delete from the backend, or switching workflows would erase notebooks. + this.hideAndClearLocalState(); this.cellToHighlightMapping = {}; this.jupyterNotebookExists.next(false); // Skip unsaved workflows (wid undefined) and wid 0; both would POST @@ -209,12 +210,46 @@ export class JupyterPanelService { if (!this.enabled) return; if (panelName === "JupyterNotebookPanel") { this.jupyterNotebookPanelVisible.next(true); + // Opening the panel means the current workflow has an associated notebook, so + // surface the toolbar "expand" button (jupyterNotebookExists$) right away. Needed + // after an in-place import where the wid does not change and init() does not re-run + // to detect the notebook. + this.jupyterNotebookExists.next(true); } } - // Close the Jupyter Notebook panel - public closeJupyterNotebookPanel(): void { + // Delete the current workflow's stored notebook from the backend, then hide the + // panel and clear all local notebook state. This is the user-initiated action + // behind the panel's delete button, and is distinct from the workflow-switch + // cleanup (hideAndClearLocalState), which must never touch the backend. + public deleteJupyterNotebook(): void { if (!this.enabled) return; + const wid = this.workflowActionService.getWorkflow().wid; + // Unsaved workflow (wid undefined or the default wid 0): nothing is persisted, + // and a delete POST with such a wid would 500, so just reset local state. + if (!wid) { + this.hideAndClearLocalState(); + this.jupyterNotebookExists.next(false); + this.clearHighlights(); + return; + } + this.notebookMigrationService.deleteNotebookAndMapping(wid).subscribe({ + next: () => { + this.hideAndClearLocalState(); + this.jupyterNotebookExists.next(false); + this.clearHighlights(); + }, + error: (err: unknown) => { + // Keep the panel open on failure so the user sees the notebook wasn't removed. + console.error("Failed to delete Jupyter notebook:", err); + this.notificationService.error("Failed to delete the Jupyter notebook."); + }, + }); + } + + // Hide the panel and drop the current workflow's in-memory mapping. Local only; + // never calls the backend. Used on workflow switch and after a successful delete. + private hideAndClearLocalState(): void { this.jupyterNotebookPanelVisible.next(false); const wid = this.workflowActionService.getWorkflow().wid; if (wid != undefined) { @@ -222,6 +257,24 @@ export class JupyterPanelService { } } + // Unhighlight all operators and links and drop the highlight index, used once + // the notebook is gone so no stale cell-to-operator highlights remain. + private clearHighlights(): void { + this.workflowActionService.unhighlightOperators( + ...this.workflowActionService + .getTexeraGraph() + .getAllOperators() + .map(op => op.operatorID) + ); + this.workflowActionService.unhighlightLinks( + ...this.workflowActionService + .getTexeraGraph() + .getAllLinks() + .map(link => link.linkID) + ); + this.cellToHighlightMapping = {}; + } + // Minimize the Jupyter Notebook panel public minimizeJupyterNotebookPanel(): void { if (!this.enabled) return; diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts index 6de43b4146..6570e47a00 100644 --- a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts @@ -229,6 +229,19 @@ describe("NotebookMigrationService", () => { req.flush({ success: true, message: "stored" }); }); + // deleteNotebookAndMapping + it("should call deleteNotebookAndMapping API with the wid", () => { + let result: any; + service.deleteNotebookAndMapping(7).subscribe(r => (result = r)); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/delete-notebook-and-mapping")); + + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ wid: 7 }); + req.flush({ success: true, deleted: 1 }); + expect(result).toEqual({ success: true, deleted: 1 }); + }); + // sendToAIGenerateWorkflow (enabled) — drives the NotebookMigrationLLM lifecycle. // The service builds the client through its createMigrationLLM() seam, so stub // that with a plain fake. This keeps the real NotebookMigrationLLM (and its "ai" @@ -323,5 +336,11 @@ describe("NotebookMigrationService", () => { expect(result.success).toBe(false); httpMock.expectNone(req => req.url.includes("/notebook-migration/store-notebook-and-mapping")); }); + + it("deleteNotebookAndMapping emits a disabled result without making an HTTP call", async () => { + const result = await firstValueFrom(service.deleteNotebookAndMapping(7)); + expect(result.success).toBe(false); + httpMock.expectNone(req => req.url.includes("/notebook-migration/delete-notebook-and-mapping")); + }); }); }); diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts index 7bfd3ef541..3d6600c2a2 100644 --- a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts @@ -48,6 +48,12 @@ interface StoreNotebookResponse { message: string; } +interface DeleteNotebookResponse { + success: boolean; + deleted?: number; + message?: string; +} + @Injectable({ providedIn: "root", }) @@ -209,6 +215,22 @@ export class NotebookMigrationService { return this.http.post<StoreNotebookResponse>(dbAPIUrl, payload, { headers }); } + // Delete the stored notebook and its mapping for a workflow. The backend's + // notebook -> workflow_notebook_mapping FK is ON DELETE CASCADE, and wid alone + // identifies the notebook, so only wid is sent. `deleted` is 1 when a notebook + // was removed, 0 when nothing was stored. + public deleteNotebookAndMapping(wid: number | undefined): Observable<DeleteNotebookResponse> { + if (!this.enabled) { + return of({ success: false, message: "Notebook migration feature is disabled" }); + } + const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/delete-notebook-and-mapping`; + const headers = new HttpHeaders({ "Content-Type": "application/json" }); + + const payload = { wid }; + + return this.http.post<DeleteNotebookResponse>(dbAPIUrl, payload, { headers }); + } + public hasMapping(id: string): boolean { return id in this.mapping; }
