Skip to content

Commit e90160e

Browse files
authored
ui: search export fixes (#27407)
* fix(search-export): stable pagination for numeric sort fields and redesign export scope UX * add backend related changes * fix checkstyle * remove backend code * nit * nit
1 parent ac08e49 commit e90160e

22 files changed

Lines changed: 359 additions & 123 deletions

File tree

openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts

Lines changed: 114 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313

1414
import { expect, Page } from '@playwright/test';
1515
import { redirectToHomePage } from '../../utils/common';
16+
import {
17+
countCsvResponseRows,
18+
getExportCount,
19+
getExportModalContent,
20+
openExportScopeModal,
21+
} from '../../utils/explore';
1622
import { test } from '../fixtures/pages';
1723

1824
const navigateToExplorePage = async (page: Page) => {
@@ -21,22 +27,11 @@ const navigateToExplorePage = async (page: Page) => {
2127
await expect(page.getByTestId('explore-page')).toBeVisible();
2228
};
2329

24-
const getExportModalContent = (page: Page) =>
25-
page.getByTestId('export-scope-modal').locator('.ant-modal-content');
26-
27-
const openExportScopeModal = async (page: Page) => {
28-
await page.getByTestId('export-search-results-button').click();
29-
await expect(getExportModalContent(page)).toBeVisible();
30-
// Wait for count fetch to complete and OK button to be enabled
31-
await expect(
32-
getExportModalContent(page).getByRole('button', { name: 'Export' })
33-
).toBeEnabled();
34-
};
35-
3630
test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => {
3731
test.beforeEach(async ({ page }) => {
3832
await navigateToExplorePage(page);
3933
});
34+
4035
test('Export button opens scope modal with correct options', async ({
4136
page,
4237
}) => {
@@ -58,23 +53,27 @@ test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => {
5853
await expect(modalContent.getByText('Export Scope')).toBeVisible();
5954
});
6055

61-
await test.step('Modal shows Visible results and All matching assets options', async () => {
56+
await test.step('Modal shows tab-specific scope and All assets options', async () => {
6257
const modalContent = getExportModalContent(page);
6358

64-
await expect(modalContent.getByText('Visible results')).toBeVisible();
65-
await expect(modalContent.getByText('All matching assets')).toBeVisible();
59+
await expect(
60+
modalContent.getByTestId('export-scope-visible-card')
61+
).toBeVisible();
62+
await expect(
63+
modalContent.getByTestId('export-scope-all-card')
64+
).toBeVisible();
6665
});
6766

68-
await test.step('All matching assets is selected by default', async () => {
67+
await test.step('All assets is selected by default', async () => {
6968
await expect(
7069
getExportModalContent(page).locator('input[value="all"]')
7170
).toBeChecked();
7271
});
7372

74-
await test.step('Selecting Visible results checks the visible radio', async () => {
73+
await test.step('Selecting the tab-scope card checks the visible radio', async () => {
7574
const modalContent = getExportModalContent(page);
7675

77-
await modalContent.getByText('Visible results').click();
76+
await modalContent.locator('input[value="visible"]').click();
7877
await expect(
7978
modalContent.locator('input[value="visible"]')
8079
).toBeChecked();
@@ -89,12 +88,10 @@ test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => {
8988
});
9089
});
9190

92-
test('All matching assets export calls API with dataAsset index', async ({
93-
page,
94-
}) => {
91+
test('All assets export calls API with dataAsset index', async ({ page }) => {
9592
await openExportScopeModal(page);
9693

97-
await test.step('All matching assets radio is pre-selected', async () => {
94+
await test.step('All assets radio is pre-selected', async () => {
9895
await expect(
9996
getExportModalContent(page).locator('input[value="all"]')
10097
).toBeChecked();
@@ -116,64 +113,80 @@ test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => {
116113
});
117114
});
118115

119-
test('Visible results export calls API with size param', async ({ page }) => {
116+
test('Search mode visible export downloads CSV with tab-specific row count', async ({
117+
page,
118+
}) => {
119+
test.slow();
120+
121+
await page.goto('/explore/tables?search=sample_data');
122+
await expect(page.getByTestId('explore-page')).toBeVisible();
123+
124+
const countApiPromise = page.waitForResponse(
125+
(response) =>
126+
response.url().includes('/api/v1/search/query') &&
127+
response.status() === 200
128+
);
129+
120130
await openExportScopeModal(page);
131+
await countApiPromise;
121132

122-
await test.step('Select Visible results scope', async () => {
123-
const modalContent = getExportModalContent(page);
133+
const modalContent = getExportModalContent(page);
124134

125-
await modalContent.getByText('Visible results').click();
126-
await expect(
127-
modalContent.locator('input[value="visible"]')
128-
).toBeChecked();
129-
});
135+
await modalContent.locator('input[value="visible"]').click();
130136

131-
await test.step('Clicking Export calls /search/export with size param', async () => {
132-
const exportApiPromise = page.waitForRequest(
133-
(req) =>
134-
req.url().includes('/api/v1/search/export') && req.method() === 'GET'
135-
);
137+
const expectedCount =
138+
await test.step('Read displayed count from Visible Results card', () =>
139+
getExportCount(page, 'export-scope-visible-count'));
136140

137-
await getExportModalContent(page)
138-
.getByRole('button', { name: 'Export' })
139-
.click();
141+
const exportResponsePromise = page.waitForResponse(
142+
(response) =>
143+
response.url().includes('/api/v1/search/export') &&
144+
response.status() === 200
145+
);
140146

141-
const request = await exportApiPromise;
142-
const url = request.url();
147+
await modalContent.getByRole('button', { name: 'Export' }).click();
148+
149+
await test.step('CSV row count matches the displayed tab count', async () => {
150+
const csvText = await (await exportResponsePromise).text();
143151

144-
expect(url).toContain('index=');
145-
expect(url).toContain('size=');
152+
expect(countCsvResponseRows(csvText)).toBe(expectedCount);
146153
});
147154
});
148155

149-
test('Visible results export on page 2 sends correct from offset', async ({
156+
test('Browse mode visible export downloads CSV with current page row count', async ({
150157
page,
151158
}) => {
152159
test.slow();
153160

154-
// Navigate to page 2 via URL so parsedSearch.page = 2
155-
await page.goto(`${page.url().replace(/\?.*/, '')}?page=2&size=15`);
156-
await expect(page.getByTestId('explore-page')).toBeVisible();
161+
const countApiPromise = page.waitForResponse(
162+
(response) =>
163+
response.url().includes('/api/v1/search/query') &&
164+
response.status() === 200
165+
);
157166

158167
await openExportScopeModal(page);
159-
await getExportModalContent(page).getByText('Visible results').click();
168+
await countApiPromise;
160169

161-
await test.step('Export request includes from= offset matching page 2', async () => {
162-
const exportApiPromise = page.waitForRequest(
163-
(req) =>
164-
req.url().includes('/api/v1/search/export') && req.method() === 'GET'
165-
);
170+
const modalContent = getExportModalContent(page);
166171

167-
await getExportModalContent(page)
168-
.getByRole('button', { name: 'Export' })
169-
.click();
172+
await modalContent.locator('input[value="visible"]').click();
170173

171-
const request = await exportApiPromise;
172-
const url = request.url();
174+
const expectedCount =
175+
await test.step('Read displayed count from Visible Results card', () =>
176+
getExportCount(page, 'export-scope-visible-count'));
177+
178+
const exportResponsePromise = page.waitForResponse(
179+
(response) =>
180+
response.url().includes('/api/v1/search/export') &&
181+
response.status() === 200
182+
);
183+
184+
await modalContent.getByRole('button', { name: 'Export' }).click();
173185

174-
// page=2, size=15 → from=15
175-
expect(url).toContain('from=15');
176-
expect(url).toContain('size=');
186+
await test.step('CSV row count matches the displayed page count', async () => {
187+
const csvText = await (await exportResponsePromise).text();
188+
189+
expect(countCsvResponseRows(csvText)).toBe(expectedCount);
177190
});
178191
});
179192

@@ -191,10 +204,19 @@ test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => {
191204
});
192205
});
193206

207+
const countApiPromise = page.waitForResponse(
208+
(response) =>
209+
response.url().includes('/api/v1/search/query') &&
210+
response.status() === 200
211+
);
212+
194213
await openExportScopeModal(page);
214+
await countApiPromise;
215+
216+
const modalContent = getExportModalContent(page);
195217

196218
await test.step('Export button becomes disabled and shows loading after click', async () => {
197-
const exportButton = getExportModalContent(page).getByRole('button', {
219+
const exportButton = modalContent.getByRole('button', {
198220
name: 'Export',
199221
});
200222

@@ -233,41 +255,47 @@ test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => {
233255
});
234256
});
235257

236-
test('Export downloads CSV and closes modal', async ({ page }) => {
237-
test.slow();
238-
239-
await page.route('**/api/v1/search/export?*', async (route) => {
258+
test('Export is disabled when all matching assets exceed limit', async ({
259+
page,
260+
}) => {
261+
await page.route('**/api/v1/search/query?*', async (route) => {
240262
await route.fulfill({
241263
status: 200,
242-
contentType: 'text/csv',
243-
headers: {
244-
'Content-Disposition': 'attachment; filename="search_export.csv"',
245-
},
246-
body: 'Entity Type,Service Name,Service Type,FQN,Name,Display Name,Description,Owners,Tags,Glossary Terms,Domains,Tier\ntable,mysql,Mysql,sample_data.ecommerce_db.shopify.dim_address,dim_address,dim_address,,,,,,',
264+
contentType: 'application/json',
265+
body: JSON.stringify({
266+
took: 1,
267+
hits: {
268+
total: { value: 200001, relation: 'eq' },
269+
hits: [],
270+
},
271+
aggregations: {},
272+
}),
247273
});
248274
});
249275

250-
await openExportScopeModal(page);
276+
await page.getByTestId('export-search-results-button').click();
251277

252-
await test.step('Export button shows loading state while downloading', async () => {
253-
await page.route('**/api/v1/search/export?*', async (route) => {
254-
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
255-
await route.fulfill({
256-
status: 200,
257-
contentType: 'text/csv',
258-
body: 'Entity Type\ntable',
259-
});
260-
});
278+
const modalContent = getExportModalContent(page);
279+
const exportButton = modalContent.getByRole('button', { name: 'Export' });
261280

262-
const exportButton = getExportModalContent(page).getByRole('button', {
263-
name: 'Export',
264-
});
281+
await test.step('Limit alert is shown in modal', async () => {
282+
await expect(
283+
modalContent.getByText(
284+
'Export is limited to 200,000 assets. Please refine your filters or choose visible results.'
285+
)
286+
).toBeVisible();
287+
});
265288

266-
await exportButton.click();
267-
await expect(exportButton).toHaveClass(/ant-btn-loading/);
289+
await test.step('Export button remains disabled', async () => {
290+
await expect(exportButton).toBeDisabled();
268291
});
292+
});
293+
294+
test('Export downloads CSV with correct filename and closes modal', async ({
295+
page,
296+
}) => {
297+
test.slow();
269298

270-
// Re-open modal for download verification after loading state test
271299
await openExportScopeModal(page);
272300

273301
await test.step('Clicking Export triggers CSV download with correct filename', async () => {

openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,29 @@ export const navigateToExploreAndSelectEntity = async (
306306

307307
await openEntitySummaryPanel(page, entityName, endpoint, fullyQualifiedName);
308308
};
309+
310+
export const getExportModalContent = (page: Page) =>
311+
page.getByTestId('export-scope-modal').locator('.ant-modal-content');
312+
313+
export const openExportScopeModal = async (page: Page) => {
314+
await page.getByTestId('export-search-results-button').click();
315+
const modalContent = getExportModalContent(page);
316+
await expect(modalContent).toBeVisible();
317+
await expect(
318+
modalContent.getByRole('button', { name: 'Export' })
319+
).toBeEnabled();
320+
};
321+
322+
export const countCsvResponseRows = (csvText: string): number =>
323+
csvText.split('\n').filter((line: string) => line.trim().length > 0).length -
324+
1;
325+
326+
export const getExportCount = async (
327+
page: Page,
328+
testId: string
329+
): Promise<number> => {
330+
const text = await page.getByTestId(testId).textContent();
331+
const match = text?.match(/(\d[\d,]*)/);
332+
333+
return match ? parseInt(match[1].replace(/,/g, ''), 10) : 0;
334+
};

0 commit comments

Comments
 (0)