diff --git a/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts b/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts index 6d7c6ec585e..f90b578afd5 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,13 @@ 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; + readonly noData: Locator; + private readonly sizeChanger: Locator; + private readonly cityFilterTrigger: Locator; private readonly resultDisplay: Locator; constructor(page: Page) { @@ -58,6 +65,37 @@ 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', 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'); + } + + 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 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 new file mode 100644 index 00000000000..6e303188fd8 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/table-footer.spec.ts @@ -0,0 +1,198 @@ +/* + * 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 { 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 { + 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 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 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, tableParagraph(rows)); + 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, paragraphPage, noteId, paragraphId }; +}; + +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 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)); + + 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 a larger page size makes the current page invalid', async () => { + await visualizationPage.selectPageSize(20); + }); + + 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'); + }); + + 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'); + }); + }); + + 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 f5213cc831c..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 @@ -10,10 +10,6 @@ ~ limitations under the License. --> -
  • @@ -46,10 +42,12 @@ @@ -136,6 +134,23 @@ } +
    @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.spec.ts b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.spec.ts new file mode 100644 index 00000000000..bc79f2f4784 --- /dev/null +++ b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.spec.ts @@ -0,0 +1,87 @@ +/* + * 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 { ChangeDetectorRef } from '@angular/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Visualization } from '@zeppelin/visualization'; + +import { TableVisualizationComponent } from './table-visualization.component'; + +const createComponent = () => { + const rows = Array.from({ length: 25 }, (_, index) => ({ id: String(index + 1) })); + const visualization = { transformed: { columns: ['id'], rows } }; + const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef; + const component = new TableVisualizationComponent(visualization as unknown as Visualization, cdr); + component.render(); + return { component, visualization }; +}; + +describe('TableVisualizationComponent page range', () => { + it('moves to the last available page when a rerun shrinks the result', () => { + const { component, visualization } = createComponent(); + component.pageIndex = 3; + visualization.transformed.rows = visualization.transformed.rows.slice(0, 15); + + component.render(); + + expect(component.pageIndex).toBe(2); + expect(component.rows).toEqual(visualization.transformed.rows); + }); + + it('resets an empty rerun to page one and keeps it there when data returns', () => { + const { component, visualization } = createComponent(); + const rows = visualization.transformed.rows; + component.pageIndex = 3; + visualization.transformed.rows = []; + + component.render(); + + expect(component.pageIndex).toBe(1); + expect(component.rows).toEqual([]); + + visualization.transformed.rows = rows; + component.render(); + + expect(component.pageIndex).toBe(1); + expect(component.rows).toEqual(rows); + }); + + it('resets a zero-match filter and restores the first page after the filter is cleared', () => { + const { component, visualization } = createComponent(); + component.pageIndex = 2; + component.onTermChange('id', 'no-such-value'); + + component.onSearch(); + + expect(component.rows).toEqual([]); + expect(component.pageIndex).toBe(1); + + component.onTermChange('id', ''); + component.onSearch(); + + expect(component.rows).toEqual(visualization.transformed.rows); + expect(component.pageIndex).toBe(1); + }); + + it('retains a valid page at an exact page-size boundary after a rerun', () => { + const { component, visualization } = createComponent(); + component.pageIndex = 2; + visualization.transformed.rows = visualization.transformed.rows.slice(0, 20); + + component.render(); + + expect(component.pageIndex).toBe(2); + expect(component.rows).toEqual(visualization.transformed.rows); + }); +}); 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..5d9ceba4141 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']; @@ -215,6 +217,8 @@ export class TableVisualizationComponent implements OnInit { }); this.rows = filter(this.tableData.rows, row => terms.every(term => term(row))); this.rows = orderBy(this.rows, sortKeys, sortTypes); + // Keep the table on a valid page even when the empty result removes the paginator. + this.pageIndex = Math.min(this.pageIndex, Math.max(1, Math.ceil(this.rows.length / this.pageSize))); this.cdr.markForCheck(); } 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,