diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 78fc75d7cbc..d02e2ac96c0 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -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 { JupyterNotebookPanelComponent } from "./workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component";
registerLocaleData(en);
@@ -360,6 +361,7 @@ registerLocaleData(en);
MarkdownDescriptionComponent,
UserComputingUnitComponent,
UserComputingUnitListItemComponent,
+ JupyterNotebookPanelComponent,
],
providers: [
provideNzI18n(en_US),
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 00000000000..546f4e43571
--- /dev/null
+++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.html
@@ -0,0 +1,63 @@
+
+
+
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 00000000000..767ffa33e7d
--- /dev/null
+++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss
@@ -0,0 +1,80 @@
+/**
+ * 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 {
+ color: royalblue;
+}
+
+.close-button:hover {
+ color: royalblue;
+}
+
+.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 00000000000..1a1551ae351
--- /dev/null
+++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.spec.ts
@@ -0,0 +1,152 @@
+/**
+ * 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, TestBed } 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 { BrowserModule } from "@angular/platform-browser";
+import { Subject } from "rxjs";
+import { ElementRef } from "@angular/core";
+
+describe("JupyterNotebookPanelComponent", () => {
+ let component: JupyterNotebookPanelComponent;
+ let fixture: ComponentFixture;
+
+ let mockJupyterPanelService: any;
+ let mockNotebookMigrationService: any;
+
+ beforeEach(async () => {
+ mockJupyterPanelService = {
+ jupyterNotebookPanelVisible$: new Subject(),
+ setIframeRef: jasmine.createSpy("setIframeRef"),
+ closeJupyterNotebookPanel: jasmine.createSpy("closeJupyterNotebookPanel"),
+ minimizeJupyterNotebookPanel: jasmine.createSpy("minimizeJupyterNotebookPanel"),
+ };
+
+ mockNotebookMigrationService = {
+ getJupyterIframeURL: jasmine
+ .createSpy("getJupyterIframeURL")
+ .and.returnValue(Promise.resolve("http://localhost:8888")),
+ };
+
+ await TestBed.configureTestingModule({
+ declarations: [JupyterNotebookPanelComponent],
+ imports: [BrowserModule],
+ providers: [
+ { provide: JupyterPanelService, useValue: mockJupyterPanelService },
+ { provide: NotebookMigrationService, useValue: mockNotebookMigrationService },
+ ],
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(JupyterNotebookPanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ expect(component).toBeTruthy();
+ });
+
+ it("should be hidden by default", () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ expect(component.isVisible).toBeFalse();
+ });
+
+ it("should update visibility when service emits", async () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true);
+
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.isVisible).toBeTrue();
+ });
+
+ it("should fetch and sanitize URL when panel becomes visible", async () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ mockJupyterPanelService.jupyterNotebookPanelVisible$.next(true);
+
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(mockNotebookMigrationService.getJupyterIframeURL).toHaveBeenCalled();
+ expect(component.jupyterUrl.toString()).toContain("http://localhost:8888");
+ });
+
+ it("should call setIframeRef when iframe exists and visible", done => {
+ spyOn(component, "checkIframeRef").and.stub();
+ component.isVisible = true;
+
+ const mockIframe = document.createElement("iframe");
+ component.iframeRef = new ElementRef(mockIframe);
+
+ component.checkIframeRef();
+
+ setTimeout(() => {
+ expect(mockJupyterPanelService.setIframeRef).toHaveBeenCalledWith(mockIframe);
+ done();
+ }, 0);
+ });
+
+ it("should NOT call setIframeRef if not visible", done => {
+ spyOn(component, "checkIframeRef").and.stub();
+ component.isVisible = false;
+
+ const mockIframe = document.createElement("iframe");
+ component.iframeRef = new ElementRef(mockIframe);
+
+ component.checkIframeRef();
+
+ setTimeout(() => {
+ expect(mockJupyterPanelService.setIframeRef).not.toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it("should close panel via service", () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ component.closePanel();
+ expect(mockJupyterPanelService.closeJupyterNotebookPanel).toHaveBeenCalled();
+ });
+
+ it("should minimize panel and update visibility", () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ component.isVisible = true;
+
+ component.minimizePanel();
+
+ expect(component.isVisible).toBeFalse();
+ expect(mockJupyterPanelService.minimizeJupyterNotebookPanel).toHaveBeenCalled();
+ });
+
+ it("should clean up on destroy", () => {
+ spyOn(component, "checkIframeRef").and.stub();
+ const nextSpy = spyOn(component["destroy$"], "next");
+ const completeSpy = spyOn(component["destroy$"], "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 00000000000..bd96dfbe10a
--- /dev/null
+++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.ts
@@ -0,0 +1,103 @@
+/**
+ * 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 { 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";
+
+@Component({
+ selector: "texera-jupyter-notebook-panel",
+ templateUrl: "./jupyter-notebook-panel.component.html",
+ styleUrls: ["./jupyter-notebook-panel.component.scss"],
+ imports: [CommonModule, DragDropModule, NzButtonModule, NzIconModule, NzPopconfirmModule],
+})
+export class JupyterNotebookPanelComponent implements OnInit, AfterViewInit, OnDestroy {
+ @ViewChild("iframeRef", { static: false }) iframeRef!: ElementRef; // Use static: false
+
+ isVisible: boolean = false; // Initialize to false, meaning the panel is hidden by default
+ jupyterUrl: SafeResourceUrl = ""; // Store the notebook URL dynamically
+ private destroy$ = new Subject();
+
+ 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());
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(url => {
+ if (url) {
+ this.jupyterUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
+ this.checkIframeRef();
+ }
+ });
+ }
+
+ ngAfterViewInit(): void {
+ // Ensure iframe is handled after it's available in the DOM
+ this.checkIframeRef();
+ }
+
+ checkIframeRef(): void {
+ setTimeout(() => {
+ if (this.isVisible && 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
+ }
+
+ // Close the panel by invoking the service method
+ closePanel(): void {
+ this.jupyterPanelService.closeJupyterNotebookPanel();
+ }
+
+ // Minimize the jupyter notebook by invoking the service method
+ minimizePanel(): void {
+ this.isVisible = false;
+ this.jupyterPanelService.minimizeJupyterNotebookPanel();
+ }
+}