diff --git a/zeppelin-web-angular/e2e/models/editor-search-page.ts b/zeppelin-web-angular/e2e/models/editor-search-page.ts index d47ba32fc47..a7fbc2d60c8 100644 --- a/zeppelin-web-angular/e2e/models/editor-search-page.ts +++ b/zeppelin-web-angular/e2e/models/editor-search-page.ts @@ -22,6 +22,8 @@ export class EditorSearchPage extends BasePage { readonly replaceInput: Locator; readonly matchesCount: Locator; readonly matchHighlights: Locator; + readonly termHighlights: Locator; + readonly showHideCodeButton: Locator; readonly nextMatchButton: Locator; readonly previousMatchButton: Locator; readonly toggleReplaceButton: Locator; @@ -42,6 +44,11 @@ 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.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(); @@ -54,6 +61,23 @@ export class EditorSearchPage extends BasePage { await expect(this.editor).toBeVisible({ timeout: 15000 }); } + 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 }); + } + 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..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,8 @@ import { createTestNotebook, PAGES, performLoginIfRequired, + setParagraphEditorHidden, + setParagraphText, skipWhenAuthenticationIsStillRequired, waitForNotebookLinks, waitForZeppelinReady @@ -77,6 +79,41 @@ 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('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 cfc3c110e17..d4500d3f6e3 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -482,6 +482,36 @@ 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()}`); + } +}; + +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/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, []) || []; 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); }