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

Reply via email to