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 @@ + + +
+
+ Jupyter Notebook +
+ + + + +
+
+ +
+ +
+
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(); + } +}