Skip to content

Commit 5fdc82c

Browse files
authored
[ZEPPELIN-6761] Fix table Export dropdown blocked by pagination
### What is this PR for? Fixes the table result’s bottom Export button being unresponsive to hover and click. The button was absolutely positioned over the table’s pagination area. Although the pagination controls appeared on the right, their container spanned the full table width and intercepted pointer events intended for Export. This PR places Export and pagination in a shared, wrapping footer. They retain the existing left/right arrangement when space permits and wrap onto separate lines in narrow paragraphs, without overlapping. The table’s built-in pagination UI is replaced with an external nz-pagination control sharing the table’s page index and page size. nz-table still handles row pagination, and the existing sorting, filtering, aggregation, and export logic remains unchanged. ### What type of PR is it? Bug Fix ### What is the Jira issue? * https://issues.apache.org/jira/browse/ZEPPELIN-6761 ### How should this be tested? With a Zeppelin development server and the Shell interpreter available, run the existing Chromium E2E script from zeppelin-web-angular: ```bash npm run e2e:fast -- \ tests/notebook/paragraph/table-footer.spec.ts \ tests/notebook/paragraph/visualization-rendering.spec.ts ``` The new tests verify that: - The bottom Export menu is accessible in both full-width and narrow paragraphs, and the downloaded CSV contains exactly the rows displayed on the current page. - Pagination and displayed rows remain synchronized after changing the page size or applying a filter that reduces the number of pages. The existing visualization tests check table rendering and data preservation after switching between chart modes. For manual verification: 1. Run a paragraph that produces a table with more than ten rows. 2. Navigate to the next page and select Export → Export visible data as csv below the table. Verify that the downloaded CSV matches the displayed rows. 3. Reduce the paragraph width. Verify that Export and pagination wrap without overlapping and that both controls remain usable. ### Questions: * Does the license files need to update? No. * Is there breaking changes for older versions? No. * Does this needs documentation? No. Closes #5590 from miinhho/ZEPPELIN-6761-table-export. Signed-off-by: YONGJAE LEE <dev.yongjaelee@gmail.com>
1 parent 9799b26 commit 5fdc82c

7 files changed

Lines changed: 363 additions & 12 deletions

File tree

‎zeppelin-web-angular/e2e/models/notebook-visualization-page.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* limitations under the License.
1313
*/
1414

15-
import { Locator, Page } from '@playwright/test';
15+
import { Download, expect, Locator, Page } from '@playwright/test';
1616
import { BasePage } from './base-page';
1717

1818
export const TABLE_PARAGRAPH = `%sh
@@ -36,6 +36,13 @@ export class NotebookVisualizationPage extends BasePage {
3636
readonly settingTrigger: Locator;
3737
readonly pivotSetting: Locator;
3838
readonly scatterSetting: Locator;
39+
readonly pagination: Locator;
40+
readonly selectedPage: Locator;
41+
readonly nextPage: Locator;
42+
readonly tableExportButton: Locator;
43+
readonly noData: Locator;
44+
private readonly sizeChanger: Locator;
45+
private readonly cityFilterTrigger: Locator;
3946
private readonly resultDisplay: Locator;
4047

4148
constructor(page: Page) {
@@ -58,6 +65,37 @@ export class NotebookVisualizationPage extends BasePage {
5865
this.settingTrigger = this.resultDisplay.getByText('Setting', { exact: true });
5966
this.pivotSetting = this.resultDisplay.locator('zeppelin-visualization-pivot-setting');
6067
this.scatterSetting = this.resultDisplay.locator('zeppelin-visualization-scatter-setting');
68+
this.pagination = this.resultDisplay.locator('.ant-pagination');
69+
this.selectedPage = this.pagination.locator('.ant-pagination-item-active');
70+
this.nextPage = this.pagination.getByRole('listitem', { name: 'Next Page', exact: true });
71+
this.tableExportButton = this.resultDisplay.getByRole('button', { name: 'Export', exact: true });
72+
this.noData = this.resultDisplay.getByText('No Data', { exact: true });
73+
this.sizeChanger = this.pagination.locator('.ant-select-selector');
74+
// JUSTIFIED: ng-zorro's icon-only filter trigger has no accessible label.
75+
this.cityFilterTrigger = this.tableHeaders.filter({ hasText: 'city' }).locator('.filter-icon');
76+
}
77+
78+
async downloadVisibleCsv(): Promise<Download> {
79+
await this.tableExportButton.hover();
80+
const exportAction = this.page.getByText('Export visible data as csv', { exact: true });
81+
await expect(exportAction).toBeVisible();
82+
const download = this.page.waitForEvent('download');
83+
await exportAction.click();
84+
return download;
85+
}
86+
87+
async selectPageSize(size: number): Promise<void> {
88+
await this.sizeChanger.click();
89+
await this.page.getByText(`${size} / page`, { exact: true }).click();
90+
}
91+
92+
async filterCity(term: string): Promise<void> {
93+
await this.cityFilterTrigger.click();
94+
const search = this.page.getByPlaceholder('Search...', { exact: true });
95+
await search.fill(term);
96+
await search.press('Enter');
97+
await search.press('Escape');
98+
await expect(search).toBeHidden();
6199
}
62100

63101
modeRadio(mode: Locator): Locator {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
import { readFile } from 'node:fs/promises';
16+
import { Download, expect, Page, test } from '@playwright/test';
17+
import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page';
18+
import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page';
19+
import { NotebookVisualizationPage } from 'e2e/models/notebook-visualization-page';
20+
import {
21+
addPageAnnotationBeforeEach,
22+
createTestNotebook,
23+
PAGES,
24+
performLoginIfRequired,
25+
setParagraphText,
26+
waitForZeppelinReady
27+
} from '../../../utils';
28+
29+
const HEADERS = ['city', 'sales', 'cost'];
30+
const ROWS = Array.from({ length: 25 }, (_, index) => [`City${index + 1}`, String(index + 1), String((index + 1) * 2)]);
31+
const tableParagraph = (rows: string[][]): string =>
32+
`%sh\nprintf '%%table ${[HEADERS, ...rows].map(row => row.join('\\t')).join('\\n')}\\n'`;
33+
const TABLE_PARAGRAPH = tableParagraph(ROWS);
34+
const EMPTY_TABLE_PARAGRAPH = tableParagraph([]);
35+
36+
const readCsv = async (download: Download): Promise<string> => {
37+
const file = await download.path();
38+
if (!file) {
39+
throw new Error('The CSV download did not produce a local file');
40+
}
41+
return (await readFile(file, 'utf8'))
42+
.replace(/^\uFEFF/, '')
43+
.replace(/\r\n/g, '\n')
44+
.trimEnd();
45+
};
46+
47+
const openTable = async (page: Page, columns: number, rows = ROWS) => {
48+
await page.goto('/#/');
49+
await waitForZeppelinReady(page);
50+
await performLoginIfRequired(page);
51+
52+
const { noteId, paragraphId } = await createTestNotebook(page);
53+
await setParagraphText(page, noteId, paragraphId, tableParagraph(rows));
54+
await page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}/config`, {
55+
data: { colWidth: columns },
56+
failOnStatusCode: true
57+
});
58+
const paragraphPage = new NotebookParagraphPage(page);
59+
const visualizationPage = new NotebookVisualizationPage(page);
60+
await page.goto(`/#/notebook/${noteId}`);
61+
await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 30000 });
62+
await paragraphPage.runParagraph();
63+
// A streamed table can appear before the final result replaces it.
64+
await expect(paragraphPage.status).toHaveText('FINISHED', { timeout: 30000 });
65+
await expect(visualizationPage.tableCells).toHaveText(rows.slice(0, 10).flat());
66+
return { visualizationPage, paragraphPage, noteId, paragraphId };
67+
};
68+
69+
test.describe('Table footer interactions', () => {
70+
addPageAnnotationBeforeEach(PAGES.VISUALIZATIONS.TABLE);
71+
72+
for (const { label, columns } of [
73+
{ label: 'full-width', columns: 12 },
74+
{ label: 'narrow', columns: 2 }
75+
]) {
76+
test(`exports the displayed page from a ${label} paragraph`, async ({ page }) => {
77+
const { visualizationPage } = await test.step('Given a table result spanning three pages', () =>
78+
openTable(page, columns));
79+
80+
await test.step('When the user opens page two', async () => {
81+
await visualizationPage.nextPage.click();
82+
});
83+
84+
const expectedRows = ROWS.slice(10, 20);
85+
86+
await test.step('Then the table displays exactly the second page', async () => {
87+
await expect(visualizationPage.selectedPage).toHaveText('2');
88+
await expect(visualizationPage.tableCells).toHaveText(expectedRows.flat());
89+
});
90+
91+
await test.step('Then the bottom Export menu downloads only those rows', async () => {
92+
const csv = await readCsv(await visualizationPage.downloadVisibleCsv());
93+
expect(csv).toBe([HEADERS, ...expectedRows].map(row => row.join(',')).join('\n'));
94+
});
95+
});
96+
}
97+
98+
test('keeps page size and a filtered last page synchronized with the table', async ({ page }) => {
99+
const { visualizationPage } = await test.step('Given a table result spanning three pages', () =>
100+
openTable(page, 12));
101+
102+
await test.step('Given the user is on the last page', async () => {
103+
await visualizationPage.nextPage.click();
104+
await visualizationPage.nextPage.click();
105+
await expect(visualizationPage.selectedPage).toHaveText('3');
106+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(20).flat());
107+
});
108+
109+
await test.step('When a larger page size makes the current page invalid', async () => {
110+
await visualizationPage.selectPageSize(20);
111+
});
112+
113+
await test.step('Then pagination moves to the last valid page with the correct rows', async () => {
114+
await expect(visualizationPage.selectedPage).toHaveText('2');
115+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(20).flat());
116+
});
117+
118+
await test.step('When a smaller page size keeps that page valid', async () => {
119+
await visualizationPage.selectPageSize(10);
120+
});
121+
122+
await test.step('Then the page is retained and its displayed rows are updated', async () => {
123+
await expect(visualizationPage.selectedPage).toHaveText('2');
124+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(10, 20).flat());
125+
});
126+
127+
await test.step('Then the original fifty-row option still displays all rows on page one', async () => {
128+
await visualizationPage.selectPageSize(50);
129+
await expect(visualizationPage.selectedPage).toHaveText('1');
130+
await expect(visualizationPage.tableCells).toHaveText(ROWS.flat());
131+
await visualizationPage.selectPageSize(10);
132+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat());
133+
});
134+
135+
await test.step('When a filter leaves only the final row', async () => {
136+
await visualizationPage.filterCity('City25');
137+
});
138+
139+
await test.step('Then the table and pagination both return to the only remaining page', async () => {
140+
await expect(visualizationPage.tableCells).toHaveText(ROWS[24]);
141+
await expect(visualizationPage.selectedPage).toHaveText('1');
142+
});
143+
});
144+
145+
test('clears visible rows and exports for an empty filter, then restores the first page', async ({ page }) => {
146+
// Equal city values keep this pagination check independent of the filter trigger's header sorting.
147+
const rows = ROWS.map(([, sales, cost]) => ['City', sales, cost]);
148+
const { visualizationPage } = await openTable(page, 12, rows);
149+
150+
await visualizationPage.nextPage.click();
151+
await expect(visualizationPage.tableCells).toHaveText(rows.slice(10, 20).flat());
152+
await visualizationPage.filterCity('no-such-city');
153+
154+
await expect(visualizationPage.noData).toBeVisible();
155+
await expect(visualizationPage.tableCells).toHaveCount(0);
156+
await expect(visualizationPage.pagination).toBeHidden();
157+
expect(await readCsv(await visualizationPage.downloadVisibleCsv())).toBe('');
158+
159+
await visualizationPage.filterCity('');
160+
161+
await expect(visualizationPage.noData).toBeHidden();
162+
await expect(visualizationPage.selectedPage).toHaveText('1');
163+
await expect(visualizationPage.tableCells).toHaveText(rows.slice(0, 10).flat());
164+
});
165+
166+
test('clears visible rows and exports after an empty rerun, then restores new results', async ({ page }) => {
167+
const { visualizationPage, paragraphPage, noteId, paragraphId } = await openTable(page, 12);
168+
const keyboardPage = new NotebookKeyboardPage(page);
169+
170+
await setParagraphText(page, noteId, paragraphId, EMPTY_TABLE_PARAGRAPH);
171+
await expect
172+
.poll(async () => (await keyboardPage.getCodeEditorContent()).replace(/\s+/g, ''))
173+
.toBe(EMPTY_TABLE_PARAGRAPH.replace(/\s+/g, ''));
174+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat());
175+
await visualizationPage.nextPage.click();
176+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(10, 20).flat());
177+
await paragraphPage.runParagraph();
178+
179+
await expect(visualizationPage.noData).toBeVisible();
180+
await expect(visualizationPage.tableCells).toHaveCount(0);
181+
await expect(paragraphPage.status).toHaveText('FINISHED');
182+
await expect(visualizationPage.pagination).toBeHidden();
183+
expect(await readCsv(await visualizationPage.downloadVisibleCsv())).toBe('');
184+
185+
await setParagraphText(page, noteId, paragraphId, TABLE_PARAGRAPH);
186+
// Monaco clips long lines; the first row token proves the empty source has been replaced.
187+
await expect
188+
.poll(async () => (await keyboardPage.getCodeEditorContent()).replace(/\s+/g, ''))
189+
.toContain('City1\\t1\\t2\\n');
190+
await expect(visualizationPage.noData).toBeVisible();
191+
await paragraphPage.runParagraph();
192+
193+
await expect(visualizationPage.noData).toBeHidden();
194+
await expect(visualizationPage.selectedPage).toHaveText('1');
195+
await expect(visualizationPage.tableCells).toHaveText(ROWS.slice(0, 10).flat());
196+
await expect(paragraphPage.status).toHaveText('FINISHED');
197+
});
198+
});

‎zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,6 @@
1010
~ limitations under the License.
1111
-->
1212

13-
<button class="export-dropdown" nz-button nz-dropdown nzSize="small" [nzDropdownMenu]="menu">
14-
<span>Export</span>
15-
<i nz-icon nzType="down"></i>
16-
</button>
1713
<nz-dropdown-menu #menu="nzDropdownMenu">
1814
<ul nz-menu>
1915
<li nz-menu-item>
@@ -46,10 +42,12 @@
4642
<nz-table
4743
#table
4844
nzSize="small"
49-
nzShowSizeChanger
5045
[nzScroll]="{ x: '1300px' }"
5146
[nzData]="rows"
5247
[nzFooter]="aggregatesFooter"
48+
[nzShowPagination]="false"
49+
[(nzPageIndex)]="pageIndex"
50+
[(nzPageSize)]="pageSize"
5351
>
5452
<thead>
5553
<tr>
@@ -136,6 +134,23 @@
136134
}
137135
</tbody>
138136
</nz-table>
137+
<div class="table-footer">
138+
<button class="export-dropdown" aria-label="Export" nz-button nz-dropdown nzSize="small" [nzDropdownMenu]="menu">
139+
<span>Export</span>
140+
<i nz-icon nzType="down"></i>
141+
</button>
142+
@if (rows.length > 0) {
143+
<nz-pagination
144+
class="footer-pagination"
145+
nzSize="small"
146+
nzShowSizeChanger
147+
[nzPageSizeOptions]="[10, 20, 30, 40, 50]"
148+
[nzTotal]="rows.length"
149+
[(nzPageIndex)]="pageIndex"
150+
[(nzPageSize)]="pageSize"
151+
></nz-pagination>
152+
}
153+
</div>
139154
<ng-template #aggregatesFooter>
140155
<div class="aggregation-wrap">
141156
@for (col of columns; track col) {

‎zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.less‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,23 @@
2121
}
2222
}
2323

24-
.export-dropdown {
25-
margin-bottom: 16px;
26-
position: absolute;
27-
bottom: 0;
28-
left: 0;
24+
.table-footer {
25+
display: flex;
26+
flex-wrap: wrap;
27+
align-items: center;
28+
gap: 8px;
29+
margin: 16px 0;
30+
31+
.footer-pagination {
32+
max-width: 100%;
33+
margin-left: auto;
34+
flex-wrap: wrap;
35+
row-gap: 8px;
36+
}
2937
}
3038

3139
.themeMixin({
3240
display: block;
33-
position: relative;
3441
::ng-deep .filter-icon svg {
3542
transition: transform 300ms;
3643
}

0 commit comments

Comments
 (0)