This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 0f7a42610e feat(python-notebook-migration, frontend): add Jupyter
notebook iframe panel component (#5271)
0f7a42610e is described below
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;
}