This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new ccfc7d3f10 [ZEPPELIN-6391] Support Jupyter notebook imports in the new
UI
ccfc7d3f10 is described below
commit ccfc7d3f103e9e9de2fcd40f9ed9d9e5069015fd
Author: Minho Jang <[email protected]>
AuthorDate: Mon Nov 9 00:58:35 2026 +0900
[ZEPPELIN-6391] Support Jupyter notebook imports in the new UI
### What is this PR for?
The new UI rejects Jupyter notebooks because its import validation only
accepts Zeppelin's paragraphs format. Its file picker also does not include
.ipynb files.
This PR enables Jupyter notebook imports from local files and URLs using
the existing server-side conversion. It extends the SDK import payload type,
updates the import dialog, and adds regression tests.
An explicit Import As name takes precedence. Otherwise, existing
server-side title and default-name handling is preserved.
### What type of PR is it?
Improvement
### Todos
* [x] - Support Jupyter input in the import dialog and SDK contract.
* [x] - Add regression tests and update the import modal locators.
### What is the Jira issue?
https://issues.apache.org/jira/browse/ZEPPELIN-6391
### How should this be tested?
From `zeppelin-web-angular/`, with frontend dependencies installed:
```bash
npm run test:shell --
src/app/share/note-import/note-import.component.spec.ts
projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts
npm run typecheck:sdk-contracts
```
These checks cover input admission, naming precedence, rejection of invalid
input, and the SDK payload contract. They do not exercise server-side
conversion.
With a Zeppelin backend running at `http://127.0.0.1:8080` and configured
to allow the frontend origin `http://localhost:4200`, build the frontend
libraries and run the existing import-modal browser checks:
```bash
npm run build:projects
npm run e2e:fast -- e2e/tests/share/note-import/note-import-modal.spec.ts
```
### Screenshots
<img width="800" height="400" alt="zeppelin-6391-note-import-modal"
src="https://github.com/user-attachments/assets/d439baa4-959c-41e6-9790-15e5b1f973e4"
/>
### Questions:
- Does the license files need to update? No.
- Is there breaking changes for older versions? No.
- Does this needs documentation? The import dialog is updated to describe
both supported formats.
Closes #5598 from miinhho/ZEPPELIN-6391-ipynb-import.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../e2e/models/note-import-modal.ts | 6 +-
.../src/interfaces/message-notebook.interface.ts | 10 +-
.../src/interfaces/notebook-wire-fields.spec.ts | 19 +++-
.../share/note-import/note-import.component.html | 8 +-
.../note-import/note-import.component.spec.ts | 113 +++++++++++++++++++++
.../app/share/note-import/note-import.component.ts | 11 +-
6 files changed, 153 insertions(+), 14 deletions(-)
diff --git a/zeppelin-web-angular/e2e/models/note-import-modal.ts
b/zeppelin-web-angular/e2e/models/note-import-modal.ts
index 92bfe0e8a0..97c1f1c258 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 65b2b0e3a8..a2f8c0d30f 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<NonNullable<Note['note']>,
'paragraphs' | 'ver
version?: string;
};
+export interface JupyterImportNoteData {
+ cells: unknown[];
+ metadata: Record<string, unknown>;
+ 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 55d7217ac1..31cdd6fe98 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<ImportParagraphItem>().toHaveProperty('apps').toEqualTypeOf<unknown[]
| undefined>();
-
expectTypeOf<ImportNote['note']>().toHaveProperty('version').toEqualTypeOf<string
| undefined>();
- expectTypeOf<ImportNote['note']['paragraphs'][number]>()
- .toHaveProperty('progress')
- .toEqualTypeOf<number | undefined>();
+
expectTypeOf<ImportNoteData>().toHaveProperty('version').toEqualTypeOf<string |
undefined>();
+
expectTypeOf<ImportNoteData['paragraphs'][number]>().toHaveProperty('progress').toEqualTypeOf<number
| undefined>();
+});
+
+it('accepts Jupyter import input without Zeppelin fields or kernel metadata',
() => {
+ assertType<ImportNote>({
+ 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 3843554d4c..70d4cbe7ba 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 @@
</form>
<nz-tabs>
- <nz-tab nzTitle="Import From JSON File">
- <nz-upload nzType="drag" [nzBeforeUpload]="beforeUpload"
nzAccept="application/json, .zpln">
+ <nz-tab nzTitle="Import From JSON/IPYNB File">
+ <nz-upload nzType="drag" [nzBeforeUpload]="beforeUpload"
nzAccept="application/json, .json, .zpln, .ipynb">
<p class="ant-upload-drag-icon">
<i nz-icon nzType="inbox"></i>
</p>
- <p class="ant-upload-text">Click or drag JSON file to this area to
upload</p>
+ <p class="ant-upload-text">Click or drag Zeppelin JSON or Jupyter IPYNB
file to this area to upload</p>
<p class="ant-upload-hint">
- JSON file size cannot exceed
+ File size cannot exceed
<strong class="tips warning">{{ wsMaxLimit | humanizeBytes }}</strong>
</p>
</nz-upload>
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 0000000000..ed89626e07
--- /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 1471630718..f6a083279f 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',