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-7314-b1d51ae2438d7d5e78ce5d3a226eec8c87c21e9d in repository https://gitbox.apache.org/repos/asf/texera.git
commit 3db59556471eb846ffbd41726dde97b25b73f4cc Author: Ryan Zhang <[email protected]> AuthorDate: Wed Aug 5 11:49:41 2026 -0700 feat(python-notebook-migration, frontend): add the notebook import modal component (#7314) ### What changes were proposed in this PR? Adds `NotebookImportModalComponent`, the modal that starts a conversion in the Python notebook to Texera workflow migration tool. This PR is the modal on its own, as a standalone component with its own tests; #5273 wires it into the workspace menu. **Modal component (`notebook-import-modal.component.{ts,html,scss}`)** - Standalone component supplied as `nzContent` and opened with `NzModalService`. It reads its inputs from the modal data and reports back through the injected `NzModalRef`, matching the other modals opened from the workspace menu (`ResultExportationComponent`, `ShareAccessComponent`, `MarkdownDescriptionComponent`). - Presents the upload form: a `.ipynb` file picker and a LiteLLM model dropdown, with a diagram and a short explanation at the top. - Loads the model list via `notebookMigrationService.getAvailableModels()`, with three dropdown states: a loading state while the list is fetched, the selectable list once it arrives, and a disabled "no models available" placeholder when the list is empty. - Both the file and the model are required before submit. - On submit it hands the selected file and model back to the opener and closes only when the opener approves the import; on cancel it closes without doing anything. The overwrite confirmation and the generation flow stay with the opener, so the modal holds no workflow state. **Assets and licensing** - `tool_popup_diagram.png`: the diagram shown at the top of the modal. It uses only openly licensed artwork, the Project Jupyter logo (BSD 3-Clause) and Google Material Symbols icons (`psychology` and `smart_toy`, Apache 2.0) to represent the LLM. It contains no company or vendor logos. - `frontend/LICENSE-binary`: adds attributions for the Material Symbols icons (Apache 2.0) and the Project Jupyter logo (BSD 3-Clause) covering the diagram. The modal is not opened from anywhere yet. The toolbar button that opens it, the generation pipeline, and the loading overlay shown during conversion are in a later PR. ### Demo <img width="1512" height="838" alt="image" src="https://github.com/user-attachments/assets/bc7c1e5a-f90f-431b-91e9-076b7002fc8a" /> Import notebook popup ### Any related issues, documentation, discussions? Closes #7313 Parent issue #4301 ### How was this PR tested? - Added unit spec `notebook-import-modal.component.spec.ts`. #### Manual testing `npx ng test` for the modal spec (10 tests, all pass). The spec covers the three model dropdown states (loading, populated, empty), the selected file display, the upload guard, and the submit and cancel paths, including that submit closes the modal only when the opener approves and otherwise leaves it open. The modal is not wired into the UI in this PR, so it was verified through its unit spec rather than end to end. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Co-authored-by: Meng Wang <[email protected]> --- frontend/LICENSE-binary | 10 ++ .../notebook-import-modal.component.html | 138 +++++++++++++++ .../notebook-import-modal.component.scss | 84 +++++++++ .../notebook-import-modal.component.spec.ts | 187 +++++++++++++++++++++ .../notebook-import-modal.component.ts | 113 +++++++++++++ .../notebook_migration_tool/tool_popup_diagram.png | Bin 0 -> 61744 bytes 6 files changed, 532 insertions(+) diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary index c5a64b7287..fcd134da14 100644 --- a/frontend/LICENSE-binary +++ b/frontend/LICENSE-binary @@ -224,6 +224,11 @@ Angular / npm packages: - [email protected] - [email protected] +Third-party assets: + - Google Material Symbols icons (psychology, smart_toy) + frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png + https://github.com/google/material-design-icons + -------------------------------------------------------------------------------- Dependencies under the MIT License -------------------------------------------------------------------------------- @@ -358,6 +363,11 @@ Angular / npm packages: - [email protected] - [email protected] +Third-party assets: + - Project Jupyter logo + frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png + https://github.com/jupyter/design + -------------------------------------------------------------------------------- Dependencies under the ISC License -------------------------------------------------------------------------------- diff --git a/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.html b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.html new file mode 100644 index 0000000000..4e6a3d6037 --- /dev/null +++ b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.html @@ -0,0 +1,138 @@ +<!-- + 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. +--> + +<form + class="import-modal-form" + [formGroup]="importForm" + nz-form> + <div class="import-modal-diagram"> + <img + ngSrc="assets/notebook_migration_tool/tool_popup_diagram.png" + alt="Notebook to Workflow" + width="1132" + height="290" /> + </div> + + <nz-alert + class="import-modal-warning" + nzType="warning" + nzShowIcon + nzMessage="Generating overwrites your current workflow; the previous version is kept in version history."></nz-alert> + + <nz-form-item> + <p class="import-modal-text"> + This tool converts a Python Jupyter Notebook into a Texera workflow using LLM capabilities. After you submit a + notebook, the LLM service will generate a corresponding Texera workflow. The conversion time depends on the + notebook’s complexity and can take 1–5 minutes. Once the process is complete, the workflow workspace will reload + with: + </p> + <ol class="import-modal-list"> + <li> + The generated workflow ready to use (Note: you will still need to upload the dataset and connect it to the + workflow). + </li> + <li>A floating Jupyter window containing the uploaded notebook for reference.</li> + </ol> + <p class="import-modal-text"> + Feel free to navigate away from this tab while you wait for the workflow to generate. Please do not close the + window. + </p> + </nz-form-item> + + <nz-form-item> + <nz-form-label [nzNoColon]="true"> + <span class="import-modal-label"> Upload Python Jupyter Notebook </span> + </nz-form-label> + <nz-form-control> + <div class="import-modal-upload-row"> + <nz-upload + nzAccept=".ipynb" + [nzBeforeUpload]="beforeUpload" + [nzShowUploadList]="false"> + <button + nz-button + type="button" + title="Upload notebook" + aria-label="Upload notebook"> + <i + nz-icon + nzType="upload"></i> + </button> + </nz-upload> + + <span *ngIf="importForm.get('file')?.value?.name"> + Selected file: {{ importForm.get('file')?.value?.name }} + </span> + </div> + </nz-form-control> + </nz-form-item> + + <nz-form-item> + <nz-form-label [nzNoColon]="true"> + <span class="import-modal-label"> Select Model Type </span> + </nz-form-label> + + <nz-form-control> + <ng-container *ngIf="models$ | async as models; else loadingTpl"> + <nz-select + *ngIf="models.length > 0; else noModelsTpl" + class="import-modal-select" + formControlName="model" + nzPlaceHolder="Select a model"> + <nz-option + *ngFor="let model of models" + [nzValue]="model.name" + [nzLabel]="model.name"></nz-option> + </nz-select> + <ng-template #noModelsTpl> + <nz-select + class="import-modal-select" + nzPlaceHolder="No models available" + [nzDisabled]="true"></nz-select> + </ng-template> + </ng-container> + + <ng-template #loadingTpl> + <nz-select + class="import-modal-select" + nzPlaceHolder="Loading models..." + [nzLoading]="true" + [nzDisabled]="true"></nz-select> + </ng-template> + </nz-form-control> + </nz-form-item> +</form> + +<div class="import-modal-footer"> + <button + nz-button + type="button" + [disabled]="isSubmitting" + (click)="onCancel()"> + Cancel + </button> + <button + nz-button + type="button" + nzType="primary" + [disabled]="!importForm.valid || isSubmitting" + (click)="onSubmit()"> + Submit + </button> +</div> diff --git a/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.scss b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.scss new file mode 100644 index 0000000000..2f89848982 --- /dev/null +++ b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.scss @@ -0,0 +1,84 @@ +/** + * 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-modal { + // Tighten the default form-item spacing so the modal fits without scrolling. + &-form nz-form-item { + margin-bottom: 12px; + } + + &-diagram { + text-align: center; + margin-bottom: 12px; + + img { + max-width: 100%; + height: auto; + border-radius: 8px; + } + } + + &-text { + margin: 0; + font-size: 15px; + line-height: 1.4; + font-weight: 500; + user-select: text; + } + + &-list { + margin: 4px 0 0; + padding-left: 20px; + font-size: 15px; + line-height: 1.4; + font-weight: 500; + user-select: text; + } + + &-label { + font-size: 14px; + font-weight: 700; + } + + &-upload-row { + display: inline-flex; + align-items: center; + gap: 8px; + + button { + white-space: normal; + } + } + + &-select { + width: 50%; + } + + &-warning { + display: block; + margin-bottom: 12px; + } + + &-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + } +} diff --git a/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.spec.ts b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.spec.ts new file mode 100644 index 0000000000..eb6d199bd1 --- /dev/null +++ b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.spec.ts @@ -0,0 +1,187 @@ +/** + * 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 { HttpClientTestingModule } from "@angular/common/http/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal"; +import { of, Subject } from "rxjs"; +import { NzUploadFile } from "ng-zorro-antd/upload"; + +import { NotebookImportModalComponent } from "./notebook-import-modal.component"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { commonTestProviders } from "../../../common/testing/test-utils"; + +describe("NotebookImportModalComponent", () => { + let fixture: ComponentFixture<NotebookImportModalComponent>; + let component: NotebookImportModalComponent; + let notebookMigrationService: NotebookMigrationService; + let modalRef: { close: ReturnType<typeof vi.fn> }; + // The opener-supplied gate; tests set its resolved value to drive close vs stay-open. + let requestImport: ReturnType<typeof vi.fn>; + + // Configures the modal with the given models$ stream, then creates and renders it. + async function createWith(models$: unknown): Promise<void> { + await TestBed.configureTestingModule({ + imports: [NotebookImportModalComponent, HttpClientTestingModule, NoopAnimationsModule], + providers: [ + { provide: NzModalRef, useValue: modalRef }, + { provide: NZ_MODAL_DATA, useValue: { requestImport } }, + ...commonTestProviders, + ], + }).compileComponents(); + + notebookMigrationService = TestBed.inject(NotebookMigrationService); + vi.spyOn(notebookMigrationService, "getAvailableModels").mockReturnValue(models$ as any); + + fixture = TestBed.createComponent(NotebookImportModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + beforeEach(() => { + modalRef = { close: vi.fn() }; + requestImport = vi.fn().mockResolvedValue(true); + }); + + it("renders the warning, diagram, and a usable model select once models load", async () => { + await createWith(of([{ name: "gpt-4" }])); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector(".import-modal-warning")).not.toBeNull(); + expect(root.querySelector("img[alt='Notebook to Workflow']")).not.toBeNull(); + expect(root.querySelector("nz-select")).not.toBeNull(); + expect(root.textContent).toContain("Select a model"); + }); + + it("shows the disabled 'no models available' select when the list is empty", async () => { + await createWith(of([])); + expect((fixture.nativeElement as HTMLElement).textContent).toContain("No models available"); + }); + + it("shows the loading select while models have not resolved yet", async () => { + // A subject that never emits keeps the async pipe pending, so the loading branch renders. + await createWith(new Subject()); + expect((fixture.nativeElement as HTMLElement).textContent).toContain("Loading models..."); + }); + + it("shows the selected file name once a file is on the form", async () => { + await createWith(of([{ name: "gpt-4" }])); + component.importForm.patchValue({ file: { name: "demo.ipynb" } }); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).toContain("Selected file: demo.ipynb"); + }); + + it("beforeUpload stores the file on the form and prevents auto-upload", async () => { + await createWith(of([{ name: "gpt-4" }])); + const file = { name: "x.ipynb" } as NzUploadFile; + + const result = component.beforeUpload(file); + + expect(result).toBe(false); + expect(component.importForm.get("file")?.value).toBe(file); + }); + + it("Submit is disabled until the form has both a file and a model", async () => { + await createWith(of([{ name: "gpt-4" }])); + const submit = () => + (fixture.nativeElement as HTMLElement).querySelector( + ".import-modal-footer button[nzType='primary']" + ) as HTMLButtonElement; + + // Empty form -> disabled. + expect(submit().disabled).toBe(true); + + component.importForm.setValue({ file: { name: "x.ipynb" }, model: "gpt-4" }); + fixture.detectChanges(); + expect(submit().disabled).toBe(false); + }); + + it("onSubmit asks the opener to import and closes when it proceeds", async () => { + requestImport.mockResolvedValue(true); + await createWith(of([{ name: "gpt-4" }])); + const file = { name: "x.ipynb" } as NzUploadFile; + component.importForm.setValue({ file, model: "gpt-4" }); + + await component.onSubmit(); + + expect(requestImport).toHaveBeenCalledWith(file, "gpt-4"); + expect(modalRef.close).toHaveBeenCalled(); + }); + + it("onSubmit keeps the modal open when the opener declines", async () => { + // e.g. the user backed out of the overwrite confirmation. + requestImport.mockResolvedValue(false); + await createWith(of([{ name: "gpt-4" }])); + component.importForm.setValue({ file: { name: "x.ipynb" } as NzUploadFile, model: "gpt-4" }); + + await component.onSubmit(); + + expect(requestImport).toHaveBeenCalled(); + expect(modalRef.close).not.toHaveBeenCalled(); + }); + + it("ignores a second submit while the first is still pending", async () => { + // A pending requestImport models the opener still showing its overwrite confirmation. + let resolveRequest!: (proceed: boolean) => void; + requestImport.mockReturnValue(new Promise<boolean>(resolve => (resolveRequest = resolve))); + await createWith(of([{ name: "gpt-4" }])); + component.importForm.setValue({ file: { name: "x.ipynb" } as NzUploadFile, model: "gpt-4" }); + + const first = component.onSubmit(); + const second = component.onSubmit(); // double-click while the first is in flight + + expect(requestImport).toHaveBeenCalledTimes(1); + expect(component.isSubmitting).toBe(true); + + resolveRequest(true); + await Promise.all([first, second]); + }); + + it("re-enables submit after the opener declines, allowing another attempt", async () => { + requestImport.mockResolvedValue(false); + await createWith(of([{ name: "gpt-4" }])); + component.importForm.setValue({ file: { name: "x.ipynb" } as NzUploadFile, model: "gpt-4" }); + + await component.onSubmit(); + expect(component.isSubmitting).toBe(false); + + // The guard has cleared, so a second attempt is allowed and calls the opener again. + await component.onSubmit(); + expect(requestImport).toHaveBeenCalledTimes(2); + }); + + it("onSubmit does nothing while the form is invalid", async () => { + await createWith(of([{ name: "gpt-4" }])); + component.importForm.setValue({ file: null, model: "" }); + + await component.onSubmit(); + + expect(requestImport).not.toHaveBeenCalled(); + expect(modalRef.close).not.toHaveBeenCalled(); + }); + + it("onCancel closes the modal without importing", async () => { + await createWith(of([{ name: "gpt-4" }])); + + component.onCancel(); + + expect(modalRef.close).toHaveBeenCalledWith(); + expect(requestImport).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.ts b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.ts new file mode 100644 index 0000000000..70ee5745d1 --- /dev/null +++ b/frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.ts @@ -0,0 +1,113 @@ +/** + * 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, inject } from "@angular/core"; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms"; +import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal"; +import { NzUploadComponent, NzUploadFile } from "ng-zorro-antd/upload"; +import { Observable } from "rxjs"; +import { AsyncPipe, NgIf, NgFor, NgOptimizedImage } from "@angular/common"; +import { NzFormModule } from "ng-zorro-antd/form"; +import { NzSelectModule } from "ng-zorro-antd/select"; +import { NzAlertModule } from "ng-zorro-antd/alert"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; + +// Passed in via nzData. The modal delegates "may I proceed?" to the opener so the +// opener can keep the overwrite-confirm and generation logic (and the workflow state it +// needs) without the modal knowing about them. Resolve true to close the modal (the +// import has started), false to keep it open with the user's selection intact. +export interface NotebookImportModalData { + requestImport: (file: NzUploadFile, model: string) => Promise<boolean>; +} + +/** + * The "AI Generate Workflow from Python Notebook" modal body. It owns the upload form and + * the three model-dropdown states (loading / has models / none). On Submit it delegates the + * decision to proceed to its opener via the requestImport callback (passed in through + * nzData); the opener runs the overwrite-confirm and generation pipeline and the modal + * closes itself only when that resolves true. Mirrors the component-as-nzContent pattern + * used by the other modals opened from the menu (ResultExportationComponent, ...). + */ +@Component({ + selector: "texera-notebook-import-modal", + templateUrl: "./notebook-import-modal.component.html", + styleUrls: ["./notebook-import-modal.component.scss"], + imports: [ + NgIf, + NgFor, + AsyncPipe, + NgOptimizedImage, + ReactiveFormsModule, + NzFormModule, + NzSelectModule, + NzAlertModule, + NzUploadComponent, + NzButtonComponent, + NzIconDirective, + ], +}) +export class NotebookImportModalComponent { + private readonly fb = inject(FormBuilder); + private readonly modalRef = inject(NzModalRef); + private readonly notebookMigrationService = inject(NotebookMigrationService); + private readonly data: NotebookImportModalData = inject(NZ_MODAL_DATA); + + public readonly importForm: FormGroup = this.fb.group({ + file: [null, Validators.required], + model: ["", Validators.required], + }); + + // Drives the three model-dropdown states: pending (loading), a non-empty list (selectable), + // and an empty list (no models available, e.g. the fetch failed or the feature is off). + public readonly models$: Observable<{ name: string }[]> = this.notebookMigrationService.getAvailableModels(); + + public beforeUpload = (file: NzUploadFile) => { + this.importForm.patchValue({ file }); + this.importForm.get("file")?.markAsDirty(); + this.importForm.get("file")?.updateValueAndValidity(); + return false; // prevent auto upload + }; + + public onCancel(): void { + this.modalRef.close(); + } + + // Guards against a second submit while the opener callback (which may show an + // overwrite confirmation) is still pending, so a double-click cannot start two imports. + public isSubmitting = false; + + public async onSubmit(): Promise<void> { + if (this.isSubmitting || !this.importForm.valid) return; + const file: NzUploadFile = this.importForm.get("file")?.value; + const model: string = this.importForm.get("model")?.value; + this.isSubmitting = true; + try { + // Ask the opener whether to proceed; close only if it does, so cancelling the + // overwrite-confirm leaves this modal open with the selection preserved. + if (await this.data.requestImport(file, model)) { + this.modalRef.close(); + } + } finally { + // Re-enable submit if the modal is still open (import declined or it threw). + this.isSubmitting = false; + } + } +} diff --git a/frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png b/frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png new file mode 100644 index 0000000000..d17ed923fd Binary files /dev/null and b/frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png differ
