From 8c77b83df100946239b8e52d11843b1353857cc4 Mon Sep 17 00:00:00 2001 From: miinhho Date: Fri, 30 Oct 2026 19:33:21 +0900 Subject: [PATCH] [ZEPPELIN-6391] Support Jupyter notebook imports in the new UI --- .../e2e/models/note-import-modal.ts | 6 +- .../interfaces/message-notebook.interface.ts | 10 +- .../interfaces/notebook-wire-fields.spec.ts | 19 ++- .../note-import/note-import.component.html | 8 +- .../note-import/note-import.component.spec.ts | 113 ++++++++++++++++++ .../note-import/note-import.component.ts | 11 +- 6 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 zeppelin-web-angular/src/app/share/note-import/note-import.component.spec.ts diff --git a/zeppelin-web-angular/e2e/models/note-import-modal.ts b/zeppelin-web-angular/e2e/models/note-import-modal.ts index 92bfe0e8a01..97c1f1c2583 100644 --- a/zeppelin-web-angular/e2e/models/note-import-modal.ts +++ b/zeppelin-web-angular/e2e/models/note-import-modal.ts @@ -33,10 +33,12 @@ export class NoteImportModal extends BasePage { this.modalTitle = page.locator('.ant-modal-title', { hasText: 'Import New Note' }); this.closeButton = page.getByRole('button', { name: 'Close' }); this.importAsInput = page.locator('input[name="noteImportName"]'); - this.jsonFileTab = page.getByRole('tab', { name: 'Import From JSON File' }); + this.jsonFileTab = page.getByRole('tab', { name: 'Import From JSON/IPYNB File', exact: true }); this.urlTab = page.getByRole('tab', { name: 'Import From URL' }); this.uploadArea = page.locator('nz-upload[nztype="drag"]'); - this.uploadText = page.getByText('Click or drag JSON file to this area to upload'); + this.uploadText = page.getByText('Click or drag Zeppelin JSON or Jupyter IPYNB file to this area to upload', { + exact: true + }); this.fileSizeLimit = page.locator('.ant-upload-hint strong'); this.urlInput = page.locator('input[name="importUrl"]'); this.importNoteButton = page.getByRole('button', { name: 'Import Note' }); diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts index 65b2b0e3a8f..a2f8c0d30fe 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts @@ -78,8 +78,16 @@ export type ImportNoteData = Omit, 'paragraphs' | 'ver version?: string; }; +export interface JupyterImportNoteData { + cells: unknown[]; + metadata: Record; + nbformat?: number; + nbformat_minor?: number; + name?: string; +} + export interface ImportNote { - note: ImportNoteData; + note: ImportNoteData | JupyterImportNoteData; } export interface NoteAngularObjects { diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts index 55d7217ac10..31cdd6fe989 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts @@ -12,7 +12,7 @@ import { assertType, expectTypeOf, it } from 'vitest'; -import { EditorSettingReceived, ImportNote, Note } from './message-notebook.interface'; +import { EditorSettingReceived, ImportNote, ImportNoteData, Note } from './message-notebook.interface'; import { AngularObjectRemove, ImportParagraphItem, ParagraphItem } from './message-paragraph.interface'; it('separates received wire fields from backward-compatible import input', () => { @@ -56,10 +56,19 @@ it('separates received wire fields from backward-compatible import input', () => }); expectTypeOf().toHaveProperty('apps').toEqualTypeOf(); - expectTypeOf().toHaveProperty('version').toEqualTypeOf(); - expectTypeOf() - .toHaveProperty('progress') - .toEqualTypeOf(); + expectTypeOf().toHaveProperty('version').toEqualTypeOf(); + expectTypeOf().toHaveProperty('progress').toEqualTypeOf(); +}); + +it('accepts Jupyter import input without Zeppelin fields or kernel metadata', () => { + assertType({ + note: { + cells: [{ cell_type: 'markdown', source: ['# Imported'], metadata: {} }], + metadata: {}, + nbformat: 4, + nbformat_minor: 5 + } + }); }); it('accepts the personalized GET_NOTE response without a version', () => { diff --git a/zeppelin-web-angular/src/app/share/note-import/note-import.component.html b/zeppelin-web-angular/src/app/share/note-import/note-import.component.html index 3843554d4c3..70d4cbe7ba9 100644 --- a/zeppelin-web-angular/src/app/share/note-import/note-import.component.html +++ b/zeppelin-web-angular/src/app/share/note-import/note-import.component.html @@ -20,14 +20,14 @@ - - + +

-

Click or drag JSON file to this area to upload

+

Click or drag Zeppelin JSON or Jupyter IPYNB file to this area to upload

- JSON file size cannot exceed + File size cannot exceed {{ wsMaxLimit | humanizeBytes }}

diff --git a/zeppelin-web-angular/src/app/share/note-import/note-import.component.spec.ts b/zeppelin-web-angular/src/app/share/note-import/note-import.component.spec.ts new file mode 100644 index 00000000000..ed89626e070 --- /dev/null +++ b/zeppelin-web-angular/src/app/share/note-import/note-import.component.spec.ts @@ -0,0 +1,113 @@ +/* + * Licensed 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 { HttpBackend } from '@angular/common/http'; +import { ChangeDetectorRef } from '@angular/core'; +import { ConfigurationService, MessageService } from '@zeppelin/services'; +import { NzModalRef } from 'ng-zorro-antd/modal'; +import { NEVER } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; + +import { NoteImportComponent } from './note-import.component'; + +const createComponent = () => { + const importNote = vi.fn(); + const component = new NoteImportComponent( + { importNote, receive: () => NEVER } as unknown as MessageService, + {} as ConfigurationService, + { markForCheck: vi.fn() } as unknown as ChangeDetectorRef, + {} as NzModalRef, + {} as HttpBackend + ); + return { component, importNote }; +}; + +const jupyterNote = () => ({ + cells: [{ cell_type: 'markdown', source: ['# Imported notebook'], metadata: {} }], + metadata: { title: 'Original Jupyter title', kernelspec: { display_name: 'Python 3' } }, + nbformat: 4, + nbformat_minor: 5 +}); + +describe('NoteImportComponent', () => { + it.each(['file', 'url'])('admits Jupyter %s input without replacing its title with the kernel name', source => { + const { component, importNote } = createComponent(); + const note = jupyterNote(); + + component.processImportJson(source === 'file' ? JSON.stringify(note) : note); + + expect(component.errorText).toBeUndefined(); + expect(importNote).toHaveBeenCalledExactlyOnceWith(note); + expect(component.noteImportName).toBeUndefined(); + }); + + it('uses Import As in preference to a Jupyter name and title', () => { + const { component, importNote } = createComponent(); + const note = { ...jupyterNote(), name: 'Original name' }; + component.noteImportName = 'Folder/Chosen name'; + + component.processImportJson(note); + + expect(importNote).toHaveBeenCalledExactlyOnceWith({ ...note, name: 'Folder/Chosen name' }); + }); + + it('leaves default naming to the server when kernel metadata and title are absent', () => { + const { component, importNote } = createComponent(); + const note = { ...jupyterNote(), metadata: {} }; + const expectedNote = structuredClone(note); + + component.processImportJson(note); + + expect(component.errorText).toBeUndefined(); + expect(importNote).toHaveBeenCalledExactlyOnceWith(expectedNote); + expect(component.noteImportName).toBeUndefined(); + }); + + it('preserves the original Zeppelin name unless Import As overrides it', () => { + const { component, importNote } = createComponent(); + const note = { name: 'Original Zeppelin name', paragraphs: [{ text: '%md Existing note' }] }; + + component.processImportJson(JSON.stringify(note)); + + expect(component.noteImportName).toBe(note.name); + expect(importNote).toHaveBeenCalledExactlyOnceWith(note); + }); + + it.each([ + null, + [], + { name: 'Not a notebook' }, + { paragraphs: [] }, + { cells: [], metadata: {} }, + { cells: 'not an array', metadata: {} }, + { cells: [{}] }, + { cells: [{}], metadata: null }, + { cells: [{}], metadata: [] }, + { cells: 'invalid', paragraphs: [{ text: '%md Not the server-selected format' }] } + ])('rejects invalid or empty notebook input: %j', note => { + const { component, importNote } = createComponent(); + + component.processImportJson(note); + + expect(component.errorText).toBe('Invalid JSON'); + expect(importNote).not.toHaveBeenCalled(); + }); + + it('reports malformed JSON without sending an import request', () => { + const { component, importNote } = createComponent(); + + component.processImportJson('{'); + + expect(component.errorText).toBe('JSON parse exception'); + expect(importNote).not.toHaveBeenCalled(); + }); +}); diff --git a/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts b/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts index 1471630718d..f6a083279f0 100644 --- a/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts +++ b/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts @@ -21,8 +21,15 @@ import { MessageListener, MessageListenersManager } from '@zeppelin/core'; import { ImportNote, MessageReceiveDataTypeMap, OP } from '@zeppelin/sdk'; import { isRecord } from '@zeppelin/utility'; -const isImportNote = (value: unknown): value is ImportNote['note'] => - isRecord(value) && Array.isArray(value.paragraphs) && value.paragraphs.length > 0; +const isImportNote = (value: unknown): value is ImportNote['note'] => { + if (!isRecord(value)) { + return false; + } + if (value.cells != null) { + return Array.isArray(value.cells) && value.cells.length > 0 && isRecord(value.metadata); + } + return Array.isArray(value.paragraphs) && value.paragraphs.length > 0; +}; @Component({ selector: 'zeppelin-note-import',