This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 5fdc82c0a3 [ZEPPELIN-6761] Fix table Export dropdown blocked by
pagination
5fdc82c0a3 is described below
commit 5fdc82c0a33176a57ff24330b31a39702793d19d
Author: Minho Jang <[email protected]>
AuthorDate: Fri Oct 30 01:25:39 2026 +0900
[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 <[email protected]>
---
.../e2e/models/notebook-visualization-page.ts | 40 ++++-
.../tests/notebook/paragraph/table-footer.spec.ts | 198 +++++++++++++++++++++
.../table/table-visualization.component.html | 25 ++-
.../table/table-visualization.component.less | 19 +-
.../table/table-visualization.component.spec.ts | 87 +++++++++
.../table/table-visualization.component.ts | 4 +
.../src/app/visualizations/visualization.module.ts | 2 +
7 files changed, 363 insertions(+), 12 deletions(-)
diff --git a/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts
b/zeppelin-web-angular/e2e/models/notebook-visualization-page.ts
index 6d7c6ec585..f90b578afd 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<Download> {
+ 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<void> {
+ await this.sizeChanger.click();
+ await this.page.getByText(`${size} / page`, { exact: true }).click();
+ }
+
+ async filterCity(term: string): Promise<void> {
+ 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 0000000000..6e303188fd
--- /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<string> => {
+ 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 f5213cc831..8948736afc 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.
-->
-<button class="export-dropdown" nz-button nz-dropdown nzSize="small"
[nzDropdownMenu]="menu">
- <span>Export</span>
- <i nz-icon nzType="down"></i>
-</button>
<nz-dropdown-menu #menu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item>
@@ -46,10 +42,12 @@
<nz-table
#table
nzSize="small"
- nzShowSizeChanger
[nzScroll]="{ x: '1300px' }"
[nzData]="rows"
[nzFooter]="aggregatesFooter"
+ [nzShowPagination]="false"
+ [(nzPageIndex)]="pageIndex"
+ [(nzPageSize)]="pageSize"
>
<thead>
<tr>
@@ -136,6 +134,23 @@
}
</tbody>
</nz-table>
+<div class="table-footer">
+ <button class="export-dropdown" aria-label="Export" nz-button nz-dropdown
nzSize="small" [nzDropdownMenu]="menu">
+ <span>Export</span>
+ <i nz-icon nzType="down"></i>
+ </button>
+ @if (rows.length > 0) {
+ <nz-pagination
+ class="footer-pagination"
+ nzSize="small"
+ nzShowSizeChanger
+ [nzPageSizeOptions]="[10, 20, 30, 40, 50]"
+ [nzTotal]="rows.length"
+ [(nzPageIndex)]="pageIndex"
+ [(nzPageSize)]="pageSize"
+ ></nz-pagination>
+ }
+</div>
<ng-template #aggregatesFooter>
<div class="aggregation-wrap">
@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 094ea9ac64..06607d62e3 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 0000000000..bc79f2f478
--- /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 63cf65d943..5d9ceba414 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<string, FilterOption>();
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 3b2e82b2ae..15d9af2469 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,