From 8f682f71b4744e8e884e71436784839f35a96eef Mon Sep 17 00:00:00 2001 From: miinhho Date: Mon, 26 Oct 2026 21:14:19 +0900 Subject: [PATCH 1/2] [ZEPPELIN-6761] Fix table Export dropdown blocked by pagination --- .../e2e/models/notebook-visualization-page.ts | 37 +++++- .../notebook/paragraph/table-footer.spec.ts | 114 ++++++++++++++++++ .../table/table-visualization.component.html | 24 +++- .../table/table-visualization.component.less | 19 ++- .../table/table-visualization.component.ts | 2 + .../visualizations/visualization.module.ts | 2 + 6 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts diff --git a/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts b/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts index 6d7c6ec585e..9b8e8106ef7 100644 --- a/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts +++ b/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts @@ -12,7 +12,7 @@ * limitations under the License. */ -import { Locator, Page } from '@playwright/test'; +import { Download, expect, Locator, Page } from '@playwright/test'; import { BasePage } from './base-page'; export const TABLE_PARAGRAPH = `%sh @@ -36,6 +36,12 @@ export class NotebookVisualizationPage extends BasePage { readonly settingTrigger: Locator; readonly pivotSetting: Locator; readonly scatterSetting: Locator; + readonly pagination: Locator; + readonly selectedPage: Locator; + readonly nextPage: Locator; + readonly tableExportButton: Locator; + private readonly sizeChanger: Locator; + private readonly cityFilterTrigger: Locator; private readonly resultDisplay: Locator; constructor(page: Page) { @@ -58,6 +64,35 @@ export class NotebookVisualizationPage extends BasePage { this.settingTrigger = this.resultDisplay.getByText('Setting', { exact: true }); this.pivotSetting = this.resultDisplay.locator('zeppelin-visualization-pivot-setting'); this.scatterSetting = this.resultDisplay.locator('zeppelin-visualization-scatter-setting'); + this.pagination = this.resultDisplay.locator('.ant-pagination'); + this.selectedPage = this.pagination.locator('.ant-pagination-item-active'); + this.nextPage = this.pagination.getByRole('listitem', { name: 'Next Page', exact: true }); + this.tableExportButton = this.resultDisplay.getByRole('button', { name: /^Export\b/, exact: true }); + this.sizeChanger = this.pagination.locator('.ant-select-selector'); + // JUSTIFIED: ng-zorro's icon-only filter trigger has no accessible label. + this.cityFilterTrigger = this.tableHeaders.filter({ hasText: 'city' }).locator('.filter-icon'); + } + + async downloadVisibleCsv(): Promise { + await this.tableExportButton.hover(); + const exportAction = this.page.getByText('Export visible data as csv', { exact: true }); + await expect(exportAction).toBeVisible(); + const download = this.page.waitForEvent('download'); + await exportAction.click(); + return download; + } + + async selectPageSize(size: number): Promise { + await this.sizeChanger.click(); + await this.page.getByText(`${size} / page`, { exact: true }).click(); + } + + async filterCity(term: string): Promise { + await this.cityFilterTrigger.click(); + const search = this.page.getByPlaceholder('Search...', { exact: true }); + await search.fill(term); + await search.press('Enter'); + await this.cityFilterTrigger.click(); } modeRadio(mode: Locator): Locator { diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts new file mode 100644 index 00000000000..391c36c259e --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts @@ -0,0 +1,114 @@ +/* + * 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 { readFile } from 'node:fs/promises'; +import { expect, Page, test } from '@playwright/test'; +import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page'; +import { NotebookVisualizationPage } from 'e2e/models/notebook-visualization-page'; +import { + addPageAnnotationBeforeEach, + createTestNotebook, + PAGES, + performLoginIfRequired, + setParagraphText, + waitForZeppelinReady +} from '../../../utils'; + +const HEADERS = ['city', 'sales', 'cost']; +const ROWS = Array.from({ length: 25 }, (_, index) => [`City${index + 1}`, String(index + 1), String((index + 1) * 2)]); +const TABLE_PARAGRAPH = `%sh\nprintf '%%table ${HEADERS.join('\\t')}\\n${ROWS.map(row => row.join('\\t')).join('\\n')}\\n'`; + +const openTable = async (page: Page, columns: number): Promise => { + await page.goto('/#/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + + const { noteId, paragraphId } = await createTestNotebook(page); + await setParagraphText(page, noteId, paragraphId, TABLE_PARAGRAPH); + await page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}/config`, { + data: { colWidth: columns }, + failOnStatusCode: true + }); + const paragraphPage = new NotebookParagraphPage(page); + const visualizationPage = new NotebookVisualizationPage(page); + await page.goto(`/#/notebook/${noteId}`); + await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 30000 }); + await paragraphPage.runParagraph(); + // A streamed table can appear before the final result replaces it. + await expect(paragraphPage.status).toHaveText('FINISHED', { timeout: 30000 }); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat()); + return visualizationPage; +}; + +test.describe('Table footer interactions', () => { + addPageAnnotationBeforeEach(PAGES.VISUALIZATIONS.TABLE); + + for (const { label, columns } of [ + { label: 'full-width', columns: 12 }, + { label: 'narrow', columns: 2 } + ]) { + test(`exports the displayed page from a ${label} paragraph`, async ({ page }) => { + const visualizationPage = await test.step('Given a table result spanning three pages', () => + openTable(page, columns)); + + await test.step('When the user opens page two', async () => { + await visualizationPage.nextPage.click(); + }); + + const expectedRows = ROWS.slice(10, 20); + + await test.step('Then the table displays exactly the second page', async () => { + await expect(visualizationPage.selectedPage).toHaveText('2'); + await expect(visualizationPage.tableCells).toHaveText(expectedRows.flat()); + }); + + await test.step('Then the bottom Export menu downloads only those rows', async () => { + const download = await visualizationPage.downloadVisibleCsv(); + const file = await download.path(); + if (!file) { + throw new Error('The CSV download did not produce a local file'); + } + const csv = (await readFile(file, 'utf8')) + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n') + .trimEnd(); + expect(csv).toBe([HEADERS, ...expectedRows].map(row => row.join(',')).join('\n')); + }); + }); + } + + test('keeps page size and a filtered last page synchronized with the table', async ({ page }) => { + const visualizationPage = await test.step('Given a table result spanning three pages', () => openTable(page, 12)); + + await test.step('When the user selects twenty rows per page', async () => { + await visualizationPage.selectPageSize(20); + }); + + await test.step('Then the first page contains twenty rows and the next contains the remaining five', async () => { + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 20).flat()); + await visualizationPage.nextPage.click(); + await expect(visualizationPage.selectedPage).toHaveText('2'); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(20).flat()); + }); + + await test.step('When a filter leaves only the final row', async () => { + await visualizationPage.filterCity('City25'); + }); + + await test.step('Then the table and pagination both return to the only remaining page', async () => { + await expect(visualizationPage.tableCells).toHaveText(ROWS[24]); + await expect(visualizationPage.selectedPage).toHaveText('1'); + }); + }); +}); diff --git a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html index f5213cc831c..7c1d795b8e9 100644 --- a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html +++ b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html @@ -10,10 +10,6 @@ ~ limitations under the License. --> -
  • @@ -46,10 +42,12 @@ @@ -136,6 +134,22 @@ } +
    @for (col of columns; track col) { diff --git a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.less b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.less index 094ea9ac640..06607d62e30 100644 --- a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.less +++ b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.less @@ -21,16 +21,23 @@ } } -.export-dropdown { - margin-bottom: 16px; - position: absolute; - bottom: 0; - left: 0; +.table-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 16px 0; + + .footer-pagination { + max-width: 100%; + margin-left: auto; + flex-wrap: wrap; + row-gap: 8px; + } } .themeMixin({ display: block; - position: relative; ::ng-deep .filter-icon svg { transition: transform 300ms; } diff --git a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts index 63cf65d9438..0f31c675186 100644 --- a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts +++ b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts @@ -58,6 +58,8 @@ export class TableVisualizationComponent implements OnInit { // eslint-disable-next-line @typescript-eslint/no-explicit-any rows: any[] = []; columns: string[] = []; + pageIndex = 1; + pageSize = 10; colOptions = new Map(); types: ColType[] = ['string', 'number', 'date']; aggregations: readonly AggregationType[] = ['count', 'sum', 'min', 'max', 'avg']; diff --git a/zeppelin-web-angular/src/app/visualizations/visualization.module.ts b/zeppelin-web-angular/src/app/visualizations/visualization.module.ts index 3b2e82b2ae6..15d9af24690 100644 --- a/zeppelin-web-angular/src/app/visualizations/visualization.module.ts +++ b/zeppelin-web-angular/src/app/visualizations/visualization.module.ts @@ -24,6 +24,7 @@ import { NzGridModule } from 'ng-zorro-antd/grid'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMenuModule } from 'ng-zorro-antd/menu'; +import { NzPaginationModule } from 'ng-zorro-antd/pagination'; import { NzRadioModule } from 'ng-zorro-antd/radio'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzTagModule } from 'ng-zorro-antd/tag'; @@ -60,6 +61,7 @@ const VisualizationComponents = [ FormsModule, DragDropModule, NzTableModule, + NzPaginationModule, NzCardModule, NzTagModule, NzFormModule, From eb482d33a33beb128af778dafee8d7396310ba84 Mon Sep 17 00:00:00 2001 From: miinhho Date: Wed, 28 Oct 2026 00:08:28 +0900 Subject: [PATCH 2/2] [ZEPPELIN-6761] Preserve table pagination behavior in footer --- .../e2e/models/notebook-visualization-page.ts | 7 +- .../notebook/paragraph/table-footer.spec.ts | 126 +++++++++++++++--- .../table/table-visualization.component.html | 3 +- .../table-visualization.component.spec.ts | 87 ++++++++++++ .../table/table-visualization.component.ts | 2 + 5 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.spec.ts diff --git a/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts b/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts index 9b8e8106ef7..f90b578afd5 100644 --- a/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts +++ b/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts @@ -40,6 +40,7 @@ export class NotebookVisualizationPage extends BasePage { readonly selectedPage: Locator; readonly nextPage: Locator; readonly tableExportButton: Locator; + readonly noData: Locator; private readonly sizeChanger: Locator; private readonly cityFilterTrigger: Locator; private readonly resultDisplay: Locator; @@ -67,7 +68,8 @@ export class NotebookVisualizationPage extends BasePage { this.pagination = this.resultDisplay.locator('.ant-pagination'); this.selectedPage = this.pagination.locator('.ant-pagination-item-active'); this.nextPage = this.pagination.getByRole('listitem', { name: 'Next Page', exact: true }); - this.tableExportButton = this.resultDisplay.getByRole('button', { name: /^Export\b/, exact: true }); + this.tableExportButton = this.resultDisplay.getByRole('button', { name: 'Export', exact: true }); + this.noData = this.resultDisplay.getByText('No Data', { exact: true }); this.sizeChanger = this.pagination.locator('.ant-select-selector'); // JUSTIFIED: ng-zorro's icon-only filter trigger has no accessible label. this.cityFilterTrigger = this.tableHeaders.filter({ hasText: 'city' }).locator('.filter-icon'); @@ -92,7 +94,8 @@ export class NotebookVisualizationPage extends BasePage { const search = this.page.getByPlaceholder('Search...', { exact: true }); await search.fill(term); await search.press('Enter'); - await this.cityFilterTrigger.click(); + await search.press('Escape'); + await expect(search).toBeHidden(); } modeRadio(mode: Locator): Locator { diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts index 391c36c259e..6e303188fd8 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts @@ -13,7 +13,8 @@ */ import { readFile } from 'node:fs/promises'; -import { expect, Page, test } from '@playwright/test'; +import { Download, expect, Page, test } from '@playwright/test'; +import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page'; import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page'; import { NotebookVisualizationPage } from 'e2e/models/notebook-visualization-page'; import { @@ -27,15 +28,29 @@ import { const HEADERS = ['city', 'sales', 'cost']; const ROWS = Array.from({ length: 25 }, (_, index) => [`City${index + 1}`, String(index + 1), String((index + 1) * 2)]); -const TABLE_PARAGRAPH = `%sh\nprintf '%%table ${HEADERS.join('\\t')}\\n${ROWS.map(row => row.join('\\t')).join('\\n')}\\n'`; +const tableParagraph = (rows: string[][]): string => + `%sh\nprintf '%%table ${[HEADERS, ...rows].map(row => row.join('\\t')).join('\\n')}\\n'`; +const TABLE_PARAGRAPH = tableParagraph(ROWS); +const EMPTY_TABLE_PARAGRAPH = tableParagraph([]); -const openTable = async (page: Page, columns: number): Promise => { +const readCsv = async (download: Download): Promise => { + const file = await download.path(); + if (!file) { + throw new Error('The CSV download did not produce a local file'); + } + return (await readFile(file, 'utf8')) + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n') + .trimEnd(); +}; + +const openTable = async (page: Page, columns: number, rows = ROWS) => { await page.goto('/#/'); await waitForZeppelinReady(page); await performLoginIfRequired(page); const { noteId, paragraphId } = await createTestNotebook(page); - await setParagraphText(page, noteId, paragraphId, TABLE_PARAGRAPH); + await setParagraphText(page, noteId, paragraphId, tableParagraph(rows)); await page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}/config`, { data: { colWidth: columns }, failOnStatusCode: true @@ -47,8 +62,8 @@ const openTable = async (page: Page, columns: number): Promise { @@ -59,7 +74,7 @@ test.describe('Table footer interactions', () => { { label: 'narrow', columns: 2 } ]) { test(`exports the displayed page from a ${label} paragraph`, async ({ page }) => { - const visualizationPage = await test.step('Given a table result spanning three pages', () => + const { visualizationPage } = await test.step('Given a table result spanning three pages', () => openTable(page, columns)); await test.step('When the user opens page two', async () => { @@ -74,34 +89,49 @@ test.describe('Table footer interactions', () => { }); await test.step('Then the bottom Export menu downloads only those rows', async () => { - const download = await visualizationPage.downloadVisibleCsv(); - const file = await download.path(); - if (!file) { - throw new Error('The CSV download did not produce a local file'); - } - const csv = (await readFile(file, 'utf8')) - .replace(/^\uFEFF/, '') - .replace(/\r\n/g, '\n') - .trimEnd(); + const csv = await readCsv(await visualizationPage.downloadVisibleCsv()); expect(csv).toBe([HEADERS, ...expectedRows].map(row => row.join(',')).join('\n')); }); }); } test('keeps page size and a filtered last page synchronized with the table', async ({ page }) => { - const visualizationPage = await test.step('Given a table result spanning three pages', () => openTable(page, 12)); + const { visualizationPage } = await test.step('Given a table result spanning three pages', () => + openTable(page, 12)); + + await test.step('Given the user is on the last page', async () => { + await visualizationPage.nextPage.click(); + await visualizationPage.nextPage.click(); + await expect(visualizationPage.selectedPage).toHaveText('3'); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(20).flat()); + }); - await test.step('When the user selects twenty rows per page', async () => { + await test.step('When a larger page size makes the current page invalid', async () => { await visualizationPage.selectPageSize(20); }); - await test.step('Then the first page contains twenty rows and the next contains the remaining five', async () => { - await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 20).flat()); - await visualizationPage.nextPage.click(); + await test.step('Then pagination moves to the last valid page with the correct rows', async () => { await expect(visualizationPage.selectedPage).toHaveText('2'); await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(20).flat()); }); + await test.step('When a smaller page size keeps that page valid', async () => { + await visualizationPage.selectPageSize(10); + }); + + await test.step('Then the page is retained and its displayed rows are updated', async () => { + await expect(visualizationPage.selectedPage).toHaveText('2'); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(10, 20).flat()); + }); + + await test.step('Then the original fifty-row option still displays all rows on page one', async () => { + await visualizationPage.selectPageSize(50); + await expect(visualizationPage.selectedPage).toHaveText('1'); + await expect(visualizationPage.tableCells).toHaveText(ROWS.flat()); + await visualizationPage.selectPageSize(10); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat()); + }); + await test.step('When a filter leaves only the final row', async () => { await visualizationPage.filterCity('City25'); }); @@ -111,4 +141,58 @@ test.describe('Table footer interactions', () => { await expect(visualizationPage.selectedPage).toHaveText('1'); }); }); + + test('clears visible rows and exports for an empty filter, then restores the first page', async ({ page }) => { + // Equal city values keep this pagination check independent of the filter trigger's header sorting. + const rows = ROWS.map(([, sales, cost]) => ['City', sales, cost]); + const { visualizationPage } = await openTable(page, 12, rows); + + await visualizationPage.nextPage.click(); + await expect(visualizationPage.tableCells).toHaveText(rows.slice(10, 20).flat()); + await visualizationPage.filterCity('no-such-city'); + + await expect(visualizationPage.noData).toBeVisible(); + await expect(visualizationPage.tableCells).toHaveCount(0); + await expect(visualizationPage.pagination).toBeHidden(); + expect(await readCsv(await visualizationPage.downloadVisibleCsv())).toBe(''); + + await visualizationPage.filterCity(''); + + await expect(visualizationPage.noData).toBeHidden(); + await expect(visualizationPage.selectedPage).toHaveText('1'); + await expect(visualizationPage.tableCells).toHaveText(rows.slice(0, 10).flat()); + }); + + test('clears visible rows and exports after an empty rerun, then restores new results', async ({ page }) => { + const { visualizationPage, paragraphPage, noteId, paragraphId } = await openTable(page, 12); + const keyboardPage = new NotebookKeyboardPage(page); + + await setParagraphText(page, noteId, paragraphId, EMPTY_TABLE_PARAGRAPH); + await expect + .poll(async () => (await keyboardPage.getCodeEditorContent()).replace(/\s+/g, '')) + .toBe(EMPTY_TABLE_PARAGRAPH.replace(/\s+/g, '')); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat()); + await visualizationPage.nextPage.click(); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(10, 20).flat()); + await paragraphPage.runParagraph(); + + await expect(visualizationPage.noData).toBeVisible(); + await expect(visualizationPage.tableCells).toHaveCount(0); + await expect(paragraphPage.status).toHaveText('FINISHED'); + await expect(visualizationPage.pagination).toBeHidden(); + expect(await readCsv(await visualizationPage.downloadVisibleCsv())).toBe(''); + + await setParagraphText(page, noteId, paragraphId, TABLE_PARAGRAPH); + // Monaco clips long lines; the first row token proves the empty source has been replaced. + await expect + .poll(async () => (await keyboardPage.getCodeEditorContent()).replace(/\s+/g, '')) + .toContain('City1\\t1\\t2\\n'); + await expect(visualizationPage.noData).toBeVisible(); + await paragraphPage.runParagraph(); + + await expect(visualizationPage.noData).toBeHidden(); + await expect(visualizationPage.selectedPage).toHaveText('1'); + await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat()); + await expect(paragraphPage.status).toHaveText('FINISHED'); + }); }); diff --git a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html index 7c1d795b8e9..8948736afca 100644 --- a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html +++ b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html @@ -135,7 +135,7 @@