From 1859e9bb7ed78850b4a35f8181b8c05cfb35f5a6 Mon Sep 17 00:00:00 2001 From: kimyenac Date: Tue, 4 Aug 2026 13:21:51 +0900 Subject: [PATCH 1/2] [ZEPPELIN-6533] Apply the notebook search term after the paragraph views exist The `term` query param is read in ngOnInit, which runs before @ViewChildren resolves and before the note arrives over the WebSocket, so a deep-link term was dropped: onParagraphSearch iterated a query list that did not exist yet, and nothing re-applied the term once the paragraphs rendered. Keep the term on the notebook component and (re)apply it in ngAfterViewInit and whenever the paragraph query list changes. The code editor keeps the term too, because Monaco loads asynchronously and would otherwise ignore a term that arrived before the editor was ready. Add a Playwright regression test that opens a notebook with a `term` query param and asserts every occurrence is highlighted. --- .../e2e/models/editor-search-page.ts | 9 ++++++++ .../notebook/search/editor-search.spec.ts | 17 +++++++++++++++ zeppelin-web-angular/e2e/utils.ts | 15 +++++++++++++ .../workspace/notebook/notebook.component.ts | 21 +++++++++++++++++-- .../code-editor/code-editor.component.ts | 4 ++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/zeppelin-web-angular/e2e/models/editor-search-page.ts b/zeppelin-web-angular/e2e/models/editor-search-page.ts index d47ba32fc47..c23da7a1738 100644 --- a/zeppelin-web-angular/e2e/models/editor-search-page.ts +++ b/zeppelin-web-angular/e2e/models/editor-search-page.ts @@ -22,6 +22,7 @@ export class EditorSearchPage extends BasePage { readonly replaceInput: Locator; readonly matchesCount: Locator; readonly matchHighlights: Locator; + readonly termHighlights: Locator; readonly nextMatchButton: Locator; readonly previousMatchButton: Locator; readonly toggleReplaceButton: Locator; @@ -42,6 +43,8 @@ export class EditorSearchPage extends BasePage { this.matchesCount = this.findWidget.locator('.matchesCount').first(); // Monaco decorates every match with .findMatch and the active one with .currentFindMatch. this.matchHighlights = this.editor.locator('.findMatch, .currentFindMatch'); + // The `term` query param highlights through Zeppelin's own decoration class, not Monaco's find widget. + this.termHighlights = this.editor.locator('.editor-search-highlight'); this.nextMatchButton = this.findWidget.locator('.button.next, [title^="Next Match"]').first(); this.previousMatchButton = this.findWidget.locator('.button.previous, [title^="Previous Match"]').first(); this.toggleReplaceButton = this.findWidget.locator('.button.toggle, [title^="Toggle Replace"]').first(); @@ -54,6 +57,12 @@ export class EditorSearchPage extends BasePage { await expect(this.editor).toBeVisible({ timeout: 15000 }); } + async openNotebookWithSearchTerm(noteId: string, term: string): Promise { + await this.page.goto(`/#/notebook/${noteId}?term=${encodeURIComponent(term)}`); + await waitForZeppelinReady(this.page); + await expect(this.editor).toBeVisible({ timeout: 15000 }); + } + async setEditorContent(content: string): Promise { await this.editor.click(); // Key off the browser, not the host: Monaco follows the browser UA's keymap, and diff --git a/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts index a7420914f1c..8e3f0ce85f4 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts @@ -17,6 +17,7 @@ import { createTestNotebook, PAGES, performLoginIfRequired, + setParagraphText, skipWhenAuthenticationIsStillRequired, waitForNotebookLinks, waitForZeppelinReady @@ -77,6 +78,22 @@ test.describe('Notebook editor search', () => { await expect(editorSearchPage.matchHighlights).toHaveCount(3); }); + test('highlights the term carried by a deep link when the notebook opens', async ({ page }) => { + const { noteId, paragraphId } = await createTestNotebook(page); + + await test.step('Given a paragraph containing the term three times', async () => { + await setParagraphText(page, noteId, paragraphId, 'alpha target beta target gamma target'); + }); + + await test.step('When the notebook is opened with the term in the query string', async () => { + await editorSearchPage.openNotebookWithSearchTerm(noteId, 'target'); + }); + + await test.step('Then every occurrence is highlighted', async () => { + await expect(editorSearchPage.termHighlights).toHaveCount(3); + }); + }); + test('replaces all matches in the editor search widget', async ({ page }) => { const { noteId } = await createTestNotebook(page); diff --git a/zeppelin-web-angular/e2e/utils.ts b/zeppelin-web-angular/e2e/utils.ts index cfc3c110e17..7421c5ca193 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -482,6 +482,21 @@ const createNotebookViaRest = async ( return { noteId, paragraphId }; }; +export const setParagraphText = async ( + page: Page, + noteId: string, + paragraphId: string, + text: string +): Promise => { + const response = await page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}`, { + data: { text }, + failOnStatusCode: false + }); + if (!response.ok()) { + throw new Error(`Update paragraph REST request failed: ${response.status()} ${await response.text()}`); + } +}; + interface CreateTestNotebookWithNameOptions { folderPath?: string | null; namePrefix?: string; diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts index 408ca1af281..552e0f8c8d4 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts @@ -11,6 +11,7 @@ */ import { + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -57,9 +58,10 @@ import { NotebookParagraphComponent } from './paragraph/paragraph.component'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) -export class NotebookComponent extends MessageListenersManager implements OnInit, OnDestroy { +export class NotebookComponent extends MessageListenersManager implements OnInit, AfterViewInit, OnDestroy { @ViewChildren(NotebookParagraphComponent) listOfNotebookParagraphComponent!: QueryList; private destroy$ = new Subject(); + private searchTerm = ''; note?: Exclude; permissions?: Permissions; selectId: string | null = null; @@ -272,7 +274,8 @@ export class NotebookComponent extends MessageListenersManager implements OnInit } onParagraphSearch(term: string) { - this.listOfNotebookParagraphComponent?.forEach(comp => comp.highlightMatches(term || '')); + this.searchTerm = term || ''; + this.highlightSearchTerm(); } saveParagraph(id: string) { @@ -485,6 +488,13 @@ export class NotebookComponent extends MessageListenersManager implements OnInit }); } + ngAfterViewInit(): void { + this.highlightSearchTerm(); + this.listOfNotebookParagraphComponent.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.highlightSearchTerm(); + }); + } + removeParagraphFromNgZ(): void { if (this.note && Array.isArray(this.note.paragraphs)) { this.note.paragraphs.forEach(p => { @@ -501,4 +511,11 @@ export class NotebookComponent extends MessageListenersManager implements OnInit this.destroy$.complete(); this.titleService.setTitle('Zeppelin'); } + + // The term can arrive before the paragraphs exist: the query param subscription emits during + // ngOnInit, and the paragraphs themselves are only rendered once the note arrives over the + // WebSocket. Keep the term and (re)apply it whenever the paragraph views change. + private highlightSearchTerm(): void { + this.listOfNotebookParagraphComponent?.forEach(comp => comp.highlightMatches(this.searchTerm)); + } } diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts index 093a34e11cc..ccb1a1669b7 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts @@ -66,6 +66,7 @@ export class NotebookParagraphCodeEditorComponent private editor?: IStandaloneCodeEditor; private monacoDisposables: IDisposable[] = []; private highlightDecorations: DecorationIdentifier[] = []; + private searchTerm = ''; height = 18; interpreterName?: string; @@ -217,6 +218,8 @@ export class NotebookParagraphCodeEditorComponent this.initEditorFocus(); this.initCompletionService(this.editor); this.setEditorValue(this.editor); + // A term requested before Monaco finished loading was only stored, not applied yet. + this.highlightMatches(this.searchTerm); setTimeout(() => { this.autoAdjustEditorHeight(); }); @@ -356,6 +359,7 @@ export class NotebookParagraphCodeEditorComponent } highlightMatches(term: string) { + this.searchTerm = term; if (!this.editor || !term) { // Remove previous highlights if term is empty this.highlightDecorations = this.editor?.deltaDecorations(this.highlightDecorations, []) || []; From 63b9d4da085603d6b95d8544a7453d108dcb44e6 Mon Sep 17 00:00:00 2001 From: kimyenac Date: Wed, 5 Aug 2026 10:11:20 +0900 Subject: [PATCH 2/2] [ZEPPELIN-6533][FOLLOWUP] Reapply the search term when a hidden code editor mounts The code editor sits behind an @if on config.editorHide, so a paragraph whose editor starts hidden receives the deep-linked term while no editor component exists, and the term was dropped. Showing the code later created a fresh editor that knew nothing about the term, and the notebook-level QueryList does not emit for a paragraph's inner view, so nothing replayed it. Retain the latest term on the paragraph and apply it through a ViewChild setter so it lands the moment the editor becomes available. Adds an E2E test covering the hidden-then-shown flow. --- .../e2e/models/editor-search-page.ts | 15 ++++++++++++++ .../notebook/search/editor-search.spec.ts | 20 +++++++++++++++++++ zeppelin-web-angular/e2e/utils.ts | 15 ++++++++++++++ .../notebook/paragraph/paragraph.component.ts | 14 +++++++++++-- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/zeppelin-web-angular/e2e/models/editor-search-page.ts b/zeppelin-web-angular/e2e/models/editor-search-page.ts index c23da7a1738..a7fbc2d60c8 100644 --- a/zeppelin-web-angular/e2e/models/editor-search-page.ts +++ b/zeppelin-web-angular/e2e/models/editor-search-page.ts @@ -23,6 +23,7 @@ export class EditorSearchPage extends BasePage { readonly matchesCount: Locator; readonly matchHighlights: Locator; readonly termHighlights: Locator; + readonly showHideCodeButton: Locator; readonly nextMatchButton: Locator; readonly previousMatchButton: Locator; readonly toggleReplaceButton: Locator; @@ -45,6 +46,9 @@ export class EditorSearchPage extends BasePage { this.matchHighlights = this.editor.locator('.findMatch, .currentFindMatch'); // The `term` query param highlights through Zeppelin's own decoration class, not Monaco's find widget. this.termHighlights = this.editor.locator('.editor-search-highlight'); + this.showHideCodeButton = page + .locator('zeppelin-notebook-paragraph-control a[nzTooltipTitle="Show/hide the code"]') + .first(); this.nextMatchButton = this.findWidget.locator('.button.next, [title^="Next Match"]').first(); this.previousMatchButton = this.findWidget.locator('.button.previous, [title^="Previous Match"]').first(); this.toggleReplaceButton = this.findWidget.locator('.button.toggle, [title^="Toggle Replace"]').first(); @@ -58,8 +62,19 @@ export class EditorSearchPage extends BasePage { } async openNotebookWithSearchTerm(noteId: string, term: string): Promise { + await this.navigateToNotebookWithSearchTerm(noteId, term); + await expect(this.editor).toBeVisible({ timeout: 15000 }); + } + + // Separate from openNotebookWithSearchTerm: a paragraph whose editor starts hidden renders no + // Monaco instance, so the caller cannot wait for the editor before acting. + async navigateToNotebookWithSearchTerm(noteId: string, term: string): Promise { await this.page.goto(`/#/notebook/${noteId}?term=${encodeURIComponent(term)}`); await waitForZeppelinReady(this.page); + } + + async showCode(): Promise { + await this.showHideCodeButton.click(); await expect(this.editor).toBeVisible({ timeout: 15000 }); } diff --git a/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts index 8e3f0ce85f4..3960559c1ff 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/search/editor-search.spec.ts @@ -17,6 +17,7 @@ import { createTestNotebook, PAGES, performLoginIfRequired, + setParagraphEditorHidden, setParagraphText, skipWhenAuthenticationIsStillRequired, waitForNotebookLinks, @@ -94,6 +95,25 @@ test.describe('Notebook editor search', () => { }); }); + test('highlights the term carried by a deep link when a hidden editor is shown', async ({ page }) => { + const { noteId, paragraphId } = await createTestNotebook(page); + + await test.step('Given a paragraph whose editor is hidden and contains the term three times', async () => { + await setParagraphText(page, noteId, paragraphId, 'alpha target beta target gamma target'); + await setParagraphEditorHidden(page, noteId, paragraphId, true); + }); + + await test.step('When the notebook is opened with the term and the code is shown again', async () => { + await editorSearchPage.navigateToNotebookWithSearchTerm(noteId, 'target'); + await expect(editorSearchPage.editor).toHaveCount(0); + await editorSearchPage.showCode(); + }); + + await test.step('Then every occurrence is highlighted', async () => { + await expect(editorSearchPage.termHighlights).toHaveCount(3); + }); + }); + test('replaces all matches in the editor search widget', async ({ page }) => { const { noteId } = await createTestNotebook(page); diff --git a/zeppelin-web-angular/e2e/utils.ts b/zeppelin-web-angular/e2e/utils.ts index 7421c5ca193..d4500d3f6e3 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -497,6 +497,21 @@ export const setParagraphText = async ( } }; +export const setParagraphEditorHidden = async ( + page: Page, + noteId: string, + paragraphId: string, + editorHide: boolean +): Promise => { + const response = await page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}/config`, { + data: { editorHide }, + failOnStatusCode: false + }); + if (!response.ok()) { + throw new Error(`Update paragraph config REST request failed: ${response.status()} ${await response.text()}`); + } +}; + interface CreateTestNotebookWithNameOptions { folderPath?: string | null; namePrefix?: string; diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts index 8f72c2bbf3d..186b4c595c8 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts @@ -84,8 +84,6 @@ export class NotebookParagraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit, AngularKeyboardEventHandler { @HostBinding('attr.tabindex') tabindex = '-1'; - @ViewChild(NotebookParagraphCodeEditorComponent, { static: false }) - notebookParagraphCodeEditorComponent?: NotebookParagraphCodeEditorComponent; @ViewChildren(NotebookParagraphResultComponent) notebookParagraphResultComponents!: QueryList; @Input() paragraph!: ParagraphItem; @@ -145,9 +143,11 @@ export class NotebookParagraphComponent @Output() readonly openSearchMenu = new EventEmitter(); private destroy$ = new Subject(); + private searchTerm = ''; private mode: Mode = 'command'; waitConfirmFromEdit = false; + notebookParagraphCodeEditorComponent?: NotebookParagraphCodeEditorComponent; private keyBinderService: KeyBinder; @@ -170,7 +170,17 @@ export class NotebookParagraphComponent } } + // The code editor sits behind an @if on `config.editorHide`, so it can mount long after the + // search term arrived, and it mounts as a fresh instance that knows nothing about the term. + // Setter injection replays the retained term the moment the editor becomes available. + @ViewChild(NotebookParagraphCodeEditorComponent, { static: false }) + set codeEditorComponent(component: NotebookParagraphCodeEditorComponent | undefined) { + this.notebookParagraphCodeEditorComponent = component; + component?.highlightMatches(this.searchTerm); + } + highlightMatches(searchText: string) { + this.searchTerm = searchText; this.notebookParagraphCodeEditorComponent?.highlightMatches(searchText); }