diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts index 25dc7bb22b61..72db12c77875 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts @@ -540,7 +540,7 @@ test.describe( } }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); } }); @@ -596,7 +596,7 @@ test.describe( await test.step('Draft and In Review entities appear when searched by their name and non-Approved status', async () => { for (const entry of otherEntries) { - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); await showAdvancedSearchDialog(page); await fillStaticListRule(page, { @@ -628,7 +628,7 @@ test.describe( } }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); } ); @@ -712,7 +712,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Not Contains – table is NOT visible when filtering by a word that IS in the description', async ({ @@ -755,7 +755,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Not Contains – table IS visible (word absent from description)', async ({ @@ -798,7 +798,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Is not null – table with a description is visible', async ({ @@ -840,7 +840,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Is null – table with a description is NOT visible', async ({ @@ -882,7 +882,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test.describe('Description Status filter', () => { @@ -926,7 +926,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Description Status == Incomplete – table with description is NOT visible', async ({ @@ -969,7 +969,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); }); } @@ -1103,7 +1103,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags == tag2 returns table2 and hides table1', async ({ @@ -1153,7 +1153,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags != tag1 excludes table1 from results', async ({ @@ -1196,7 +1196,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags Contains tag1 name returns table1', async ({ page }) => { @@ -1228,7 +1228,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags Not contains tag1 name excludes table1', async ({ @@ -1271,7 +1271,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags Any in [tag1, tag2] returns both tables', async ({ @@ -1305,7 +1305,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags Not in [tag1] excludes table1', async ({ page }) => { @@ -1346,7 +1346,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags Is not null returns table with a column tag', async ({ @@ -1388,7 +1388,7 @@ test.describe( ).toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); test('Column Tags Is null excludes tables that have column tags', async ({ @@ -1430,7 +1430,7 @@ test.describe( ).not.toBeVisible(); }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); }); } ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DomainFilterQueryFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DomainFilterQueryFilter.spec.ts index 39b5795d3471..5dda7869b55b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DomainFilterQueryFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DomainFilterQueryFilter.spec.ts @@ -36,6 +36,7 @@ import { verifyActiveDomainIsDefault, } from '../../utils/domain'; import { assignTier, waitForAllLoadersToDisappear } from '../../utils/entity'; +import { clickUpdateButtonIfVisible } from '../../utils/explore'; import { sidebarClick } from '../../utils/sidebar'; const test = base.extend<{ page: Page }>({ @@ -465,12 +466,13 @@ test.describe('Domain Filter - User Behavior Tests', () => { await waitForAllLoadersToDisappear(page); const tier1Option = page.getByTestId('tier.tier1'); await tier1Option.waitFor({ state: 'visible' }); - await tier1Option.click(); + // Arm before selecting: immediate-apply fires the query on the click const quickFilterApplyRes = page.waitForResponse( '/api/v1/search/query?*index=dataAsset*' ); - await page.getByTestId('update-btn').click(); + await tier1Option.click(); + await clickUpdateButtonIfVisible(page); await quickFilterApplyRes; await waitForAllLoadersToDisappear(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreFilterComposition.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreFilterComposition.spec.ts new file mode 100644 index 000000000000..a9d89b788bf9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreFilterComposition.spec.ts @@ -0,0 +1,613 @@ +/* + * Copyright 2025 Collate. + * 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 test, { expect, Page } from '@playwright/test'; +import { Operation } from 'fast-json-patch'; +import { SidebarItem } from '../../constant/sidebar'; +import { Domain } from '../../support/domain/Domain'; +import { DashboardClass } from '../../support/entity/DashboardClass'; +import { TableClass } from '../../support/entity/TableClass'; +import { TopicClass } from '../../support/entity/TopicClass'; +import { Glossary } from '../../support/glossary/Glossary'; +import { GlossaryTerm } from '../../support/glossary/GlossaryTerm'; +import { TagClass } from '../../support/tag/TagClass'; +import { createNewPage, redirectToHomePage, uuid } from '../../utils/common'; +import { + searchAndExpectEntityNotVisible, + searchAndExpectEntityVisible, +} from '../../utils/domain'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { clickUpdateButtonIfVisible } from '../../utils/explore'; +import { sidebarClick } from '../../utils/sidebar'; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +const TIER_FIELD = 'tier.tagFQN'; +const TAG_FIELD = 'tags.tagFQN'; +const CERTIFICATION_FIELD = 'certification.tagLabel.tagFQN'; +const DOMAIN_FIELD = 'domains.displayName.keyword'; +const TIER1_KEY = 'tier.tier1'; +const TIER2_KEY = 'tier.tier2'; +const PERSONAL_TAG_KEY = 'personaldata.personal'; + +const tierOneTable = new TableClass(); +const tierTwoTable = new TableClass(); +const tierOneDashboard = new DashboardClass(); +const tierTwoTopic = new TopicClass(); +const untieredTable = new TableClass(); +const assetDomain = new Domain(); +const compositionGlossary = new Glossary(`pwCompositionGlossary${uuid()}`); +const compositionTerm = new GlossaryTerm( + compositionGlossary, + undefined, + `pwCompositionTerm${uuid()}` +); +const goldCertification = new TagClass({ classification: 'Certification' }); +const silverCertification = new TagClass({ classification: 'Certification' }); + +const classificationTagPatch = (tagFQN: string, index: number): Operation => ({ + op: 'add', + path: `/tags/${index}`, + value: { + tagFQN, + source: 'Classification', + labelType: 'Manual', + }, +}); + +const glossaryTermTagPatch = (index: number): Operation => ({ + op: 'add', + path: `/tags/${index}`, + value: { + tagFQN: compositionTerm.responseData.fullyQualifiedName, + source: 'Glossary', + labelType: 'Manual', + }, +}); + +// Certification is a first-class entity field — patch /certification, not /tags +const certificationPatch = (tagFQN: string): Operation => ({ + op: 'add', + path: '/certification', + value: { + tagLabel: { + tagFQN, + source: 'Classification', + labelType: 'Manual', + state: 'Confirmed', + }, + }, +}); + +const domainPatch = (): Operation => ({ + op: 'add', + path: '/domains/0', + value: { + id: assetDomain.responseData.id, + type: 'domain', + }, +}); + +// tagFQN-style aggregation buckets are lowercase-normalized, so option +// testids and chip keys are the lowercased FQN / display name +const lowercaseKey = (value: string) => value.toLowerCase(); + +/** + * Facet options are aggregated once when the dropdown opens, so a freshly + * indexed fixture can miss the first fetch. Retry by closing and reopening + * the dropdown (each open re-fetches the facet aggregation). + */ +const ensureFilterOptionVisible = async ( + page: Page, + label: string, + optionKey: string, + searchText?: string +) => { + const menu = page.getByTestId('drop-down-menu'); + const option = menu.getByTestId(optionKey); + + await expect(async () => { + const isMenuOpen = await menu.isVisible().catch(() => false); + if (!isMenuOpen) { + await page.getByTestId(`search-dropdown-${label}`).click(); + await menu.waitFor({ state: 'visible' }); + } + if (searchText) { + await menu.getByTestId('search-input').fill(searchText); + } + try { + await option.waitFor({ state: 'visible', timeout: 5_000 }); + } catch (error) { + await page.keyboard.press('Escape'); + throw error; + } + }).toPass({ timeout: 90_000, intervals: [2_000, 5_000, 10_000] }); +}; + +/** + * Clicks a dropdown option in immediate-apply mode: arm the search request + * BEFORE the click (the click itself fires the query) and return the + * response so callers can assert on the generated query_filter. + */ +const selectOptionAndWaitForQuery = async ( + page: Page, + label: string, + optionKey: string, + searchText?: string +) => { + await ensureFilterOptionVisible(page, label, optionKey, searchText); + const option = page.getByTestId('drop-down-menu').getByTestId(optionKey); + + // searchParams.get decodes '+' as space (axios encodes spaces as '+'), + // so multi-word option keys like domain display names still match + const queryRes = page.waitForResponse((response) => { + let isMatch = false; + if (response.url().includes('/api/v1/search/query')) { + const queryFilter = + new URL(response.url()).searchParams.get('query_filter') ?? ''; + isMatch = queryFilter.includes(optionKey); + } + + return isMatch; + }); + await option.click(); + await clickUpdateButtonIfVisible(page); + const response = await queryRes; + await waitForAllLoadersToDisappear(page); + + return response; +}; + +const getQueryFilterFromResponseUrl = (url: string) => { + const queryFilter = new URL(url).searchParams.get('query_filter'); + + return queryFilter ? JSON.parse(queryFilter) : {}; +}; + +const collectShouldArrays = ( + node: unknown, + found: Array>> +) => { + if (Array.isArray(node)) { + node.forEach((item) => collectShouldArrays(item, found)); + } else if (node && typeof node === 'object') { + const record = node as Record; + if (Array.isArray(record.should)) { + found.push(record.should as Array>); + } + Object.values(record).forEach((value) => collectShouldArrays(value, found)); + } + + return found; +}; + +const getShouldClausesForField = (queryFilter: unknown, fieldKey: string) => + collectShouldArrays(queryFilter, []).filter((should) => + should.some((clause) => JSON.stringify(clause).includes(`"${fieldKey}"`)) + ); + +/** + * searchAndExpectEntity* helpers leave the page in search mode, where facet + * aggregations are scoped to the stale search term and the active tab can + * switch to an entity-specific one (dropping dataAsset-only facets such as + * Certification). Return to browse mode before further facet interactions. + */ +const clearGlobalSearch = async (page: Page) => { + const queryRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('searchBox').fill(''); + await page.getByTestId('searchBox').press('Enter'); + await queryRes; + await waitForAllLoadersToDisappear(page); +}; + +const applyTierUnion = async (page: Page) => { + await selectOptionAndWaitForQuery(page, 'Tier', TIER1_KEY); + const unionResponse = await selectOptionAndWaitForQuery( + page, + 'Tier', + TIER2_KEY + ); + await page.keyboard.press('Escape'); + + return unionResponse; +}; + +test.beforeAll( + 'Setup tagged fixtures across asset types', + async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await tierOneTable.create(apiContext); + await tierTwoTable.create(apiContext); + await tierOneDashboard.create(apiContext); + await tierTwoTopic.create(apiContext); + await untieredTable.create(apiContext); + await assetDomain.create(apiContext); + await compositionGlossary.create(apiContext); + await compositionTerm.create(apiContext); + await goldCertification.create(apiContext); + await silverCertification.create(apiContext); + + await tierOneTable.patch({ + apiContext, + patchData: [ + classificationTagPatch('Tier.Tier1', 0), + classificationTagPatch('PersonalData.Personal', 1), + certificationPatch(goldCertification.responseData.fullyQualifiedName), + domainPatch(), + ], + }); + await tierTwoTable.patch({ + apiContext, + patchData: [ + classificationTagPatch('Tier.Tier2', 0), + glossaryTermTagPatch(1), + ], + }); + await tierOneDashboard.patch({ + apiContext, + patchData: [ + classificationTagPatch('Tier.Tier1', 0), + glossaryTermTagPatch(1), + certificationPatch(silverCertification.responseData.fullyQualifiedName), + ], + }); + await tierTwoTopic.patch({ + apiContext, + patchData: [ + classificationTagPatch('Tier.Tier2', 0), + certificationPatch(goldCertification.responseData.fullyQualifiedName), + domainPatch(), + ], + }); + + await afterAction(); + } +); + +test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await tierOneTable.delete(apiContext); + await tierTwoTable.delete(apiContext); + await tierOneDashboard.delete(apiContext); + await tierTwoTopic.delete(apiContext); + await untieredTable.delete(apiContext); + await assetDomain.delete(apiContext); + await compositionTerm.delete(apiContext); + await compositionGlossary.delete(apiContext); + await goldCertification.delete(apiContext); + await silverCertification.delete(apiContext); + await afterAction(); +}); + +test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + const queryRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await sidebarClick(page, SidebarItem.EXPLORE); + await queryRes; + await waitForAllLoadersToDisappear(page); +}); + +test('Tier1 OR Tier2 union shows assets with either tier across asset types', async ({ + page, +}) => { + test.slow(); + + const unionResponse = await applyTierUnion(page); + + await test.step('Query has ONE tier clause with both values ORed', async () => { + const queryFilter = getQueryFilterFromResponseUrl(unionResponse.url()); + const tierShouldClauses = getShouldClausesForField(queryFilter, TIER_FIELD); + + expect(tierShouldClauses).toHaveLength(1); + expect(tierShouldClauses[0]).toEqual( + expect.arrayContaining([ + { term: { [TIER_FIELD]: TIER1_KEY } }, + { term: { [TIER_FIELD]: TIER2_KEY } }, + ]) + ); + }); + + await test.step('Both tier chips stack in the QUERY bar', async () => { + await expect( + page.getByTestId(`query-chip-${TIER_FIELD}-${TIER1_KEY}`) + ).toBeVisible(); + await expect( + page.getByTestId(`query-chip-${TIER_FIELD}-${TIER2_KEY}`) + ).toBeVisible(); + }); + + await test.step('Assets with either tier are visible, untiered are not', async () => { + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityVisible(page, tierTwoTable); + await searchAndExpectEntityVisible(page, tierOneDashboard); + await searchAndExpectEntityVisible(page, tierTwoTopic); + await searchAndExpectEntityNotVisible(page, untieredTable); + }); +}); + +test('removing one tier chip narrows the union to the remaining tier', async ({ + page, +}) => { + test.slow(); + + await applyTierUnion(page); + + await test.step('Remove the Tier1 chip from the QUERY bar', async () => { + const removeRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId(`remove-chip-${TIER_FIELD}-${TIER1_KEY}`).click(); + await removeRes; + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByTestId(`query-chip-${TIER_FIELD}-${TIER1_KEY}`) + ).not.toBeVisible(); + await expect( + page.getByTestId(`query-chip-${TIER_FIELD}-${TIER2_KEY}`) + ).toBeVisible(); + }); + + await test.step('Only Tier2 assets remain visible', async () => { + await searchAndExpectEntityVisible(page, tierTwoTable); + await searchAndExpectEntityVisible(page, tierTwoTopic); + await searchAndExpectEntityNotVisible(page, tierOneTable); + await searchAndExpectEntityNotVisible(page, tierOneDashboard); + }); +}); + +test('tier and tag filters AND across fields', async ({ page }) => { + test.slow(); + + await test.step('Apply Tier1 filter', async () => { + await selectOptionAndWaitForQuery(page, 'Tier', TIER1_KEY); + await page.keyboard.press('Escape'); + }); + + const tagResponse = + await test.step('Apply PersonalData.Personal tag filter', async () => { + const response = await selectOptionAndWaitForQuery( + page, + 'Tag', + PERSONAL_TAG_KEY, + 'PersonalData' + ); + await page.keyboard.press('Escape'); + + return response; + }); + + await test.step('Query has separate must clauses for tier and tag', async () => { + const queryFilter = getQueryFilterFromResponseUrl(tagResponse.url()); + const tierShouldClauses = getShouldClausesForField(queryFilter, TIER_FIELD); + const tagShouldClauses = getShouldClausesForField(queryFilter, TAG_FIELD); + + expect(tierShouldClauses).toHaveLength(1); + expect(tagShouldClauses).toHaveLength(1); + expect(tierShouldClauses[0]).toEqual([ + { term: { [TIER_FIELD]: TIER1_KEY } }, + ]); + expect(tagShouldClauses[0]).toEqual([ + { term: { [TAG_FIELD]: PERSONAL_TAG_KEY } }, + ]); + }); + + await test.step('Only assets matching BOTH filters are visible', async () => { + await expect( + page.getByTestId(`query-chip-${TIER_FIELD}-${TIER1_KEY}`) + ).toBeVisible(); + await expect( + page.getByTestId(`query-chip-${TAG_FIELD}-${PERSONAL_TAG_KEY}`) + ).toBeVisible(); + + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityNotVisible(page, tierOneDashboard); + await searchAndExpectEntityNotVisible(page, tierTwoTable); + }); +}); + +test('asset-type union composes with tier union (AND across, OR within)', async ({ + page, +}) => { + test.slow(); + + await test.step('Select Table + Topic asset types', async () => { + await selectOptionAndWaitForQuery(page, 'Data Assets', 'table'); + await selectOptionAndWaitForQuery(page, 'Data Assets', 'topic'); + await page.keyboard.press('Escape'); + + await expect( + page.getByTestId('query-chip-entityType.keyword-table') + ).toBeVisible(); + await expect( + page.getByTestId('query-chip-entityType.keyword-topic') + ).toBeVisible(); + }); + + await applyTierUnion(page); + + await test.step('Tiered tables and topics are visible, tiered dashboard is filtered out by type', async () => { + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityVisible(page, tierTwoTable); + await searchAndExpectEntityVisible(page, tierTwoTopic); + await searchAndExpectEntityNotVisible(page, tierOneDashboard); + await searchAndExpectEntityNotVisible(page, untieredTable); + }); +}); + +test('glossary term filter spans asset types and ANDs with tier', async ({ + page, +}) => { + test.slow(); + + const termKey = lowercaseKey(compositionTerm.responseData.fullyQualifiedName); + + await test.step('Filter by the glossary term via the Tag facet', async () => { + await selectOptionAndWaitForQuery( + page, + 'Tag', + termKey, + compositionTerm.responseData.name + ); + await page.keyboard.press('Escape'); + + await expect( + page.getByTestId(`query-chip-${TAG_FIELD}-${termKey}`) + ).toBeVisible(); + }); + + await test.step('Term-tagged table and dashboard are visible, untagged table is not', async () => { + await searchAndExpectEntityVisible(page, tierTwoTable); + await searchAndExpectEntityVisible(page, tierOneDashboard); + await searchAndExpectEntityNotVisible(page, tierOneTable); + }); + + const tierResponse = + await test.step('Stack a Tier2 filter on top of the term', async () => { + await clearGlobalSearch(page); + const response = await selectOptionAndWaitForQuery( + page, + 'Tier', + TIER2_KEY + ); + await page.keyboard.press('Escape'); + + return response; + }); + + await test.step('Term and tier AND across fields', async () => { + const queryFilter = getQueryFilterFromResponseUrl(tierResponse.url()); + + expect(getShouldClausesForField(queryFilter, TAG_FIELD)).toHaveLength(1); + expect(getShouldClausesForField(queryFilter, TIER_FIELD)).toHaveLength(1); + + await searchAndExpectEntityVisible(page, tierTwoTable); + await searchAndExpectEntityNotVisible(page, tierOneDashboard); + }); +}); + +test('domain filter spans asset types and ANDs with an asset-type filter', async ({ + page, +}) => { + test.slow(); + + const domainKey = lowercaseKey(assetDomain.responseData.displayName); + + await test.step('Filter by domain', async () => { + await selectOptionAndWaitForQuery( + page, + 'Domains', + domainKey, + assetDomain.responseData.displayName + ); + await page.keyboard.press('Escape'); + + await expect( + page.getByTestId(`query-chip-${DOMAIN_FIELD}-${domainKey}`) + ).toBeVisible(); + }); + + await test.step('Domain assets across types are visible, others are not', async () => { + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityVisible(page, tierTwoTopic); + await searchAndExpectEntityNotVisible(page, tierTwoTable); + }); + + await test.step('Narrow the domain to tables only', async () => { + await clearGlobalSearch(page); + await selectOptionAndWaitForQuery(page, 'Data Assets', 'table'); + await page.keyboard.press('Escape'); + + await expect( + page.getByTestId('query-chip-entityType.keyword-table') + ).toBeVisible(); + + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityNotVisible(page, tierTwoTopic); + }); +}); + +test('certification union shows assets certified with either level', async ({ + page, +}) => { + test.slow(); + + const goldKey = lowercaseKey( + goldCertification.responseData.fullyQualifiedName + ); + const silverKey = lowercaseKey( + silverCertification.responseData.fullyQualifiedName + ); + + await test.step('Filter by the gold certification', async () => { + await selectOptionAndWaitForQuery( + page, + 'Certification', + goldKey, + goldCertification.responseData.name + ); + await page.keyboard.press('Escape'); + + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityVisible(page, tierTwoTopic); + await searchAndExpectEntityNotVisible(page, tierOneDashboard); + }); + + const unionResponse = + await test.step('Add the silver certification to the union', async () => { + await clearGlobalSearch(page); + const response = await selectOptionAndWaitForQuery( + page, + 'Certification', + silverKey, + silverCertification.responseData.name + ); + await page.keyboard.press('Escape'); + + return response; + }); + + await test.step('Query has ONE certification clause with both levels ORed', async () => { + const queryFilter = getQueryFilterFromResponseUrl(unionResponse.url()); + const certificationShouldClauses = getShouldClausesForField( + queryFilter, + CERTIFICATION_FIELD + ); + + expect(certificationShouldClauses).toHaveLength(1); + expect(certificationShouldClauses[0]).toEqual( + expect.arrayContaining([ + { term: { [CERTIFICATION_FIELD]: goldKey } }, + { term: { [CERTIFICATION_FIELD]: silverKey } }, + ]) + ); + }); + + await test.step('Assets certified with either level are visible, uncertified are not', async () => { + await expect( + page.getByTestId(`query-chip-${CERTIFICATION_FIELD}-${goldKey}`) + ).toBeVisible(); + await expect( + page.getByTestId(`query-chip-${CERTIFICATION_FIELD}-${silverKey}`) + ).toBeVisible(); + + await searchAndExpectEntityVisible(page, tierOneTable); + await searchAndExpectEntityVisible(page, tierTwoTopic); + await searchAndExpectEntityVisible(page, tierOneDashboard); + await searchAndExpectEntityNotVisible(page, tierTwoTable); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQueryBar.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQueryBar.spec.ts new file mode 100644 index 000000000000..d3bf523f2082 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQueryBar.spec.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2025 Collate. + * 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 test, { expect } from '@playwright/test'; +import { SidebarItem } from '../../constant/sidebar'; +import { TableClass } from '../../support/entity/TableClass'; +import { createNewPage, redirectToHomePage } from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { sidebarClick } from '../../utils/sidebar'; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +const table = new TableClass(); + +test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await table.create(apiContext); + await afterAction(); +}); + +test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await table.delete(apiContext); + await afterAction(); +}); + +test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + const queryRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await sidebarClick(page, SidebarItem.EXPLORE); + await queryRes; + await waitForAllLoadersToDisappear(page); +}); + +test('query bar is persistent and shows the browse placeholder when empty', async ({ + page, +}) => { + await expect(page.getByTestId('explore-query-filter-chips')).toBeVisible(); + await expect(page.getByTestId('query-bar-empty-text')).toBeVisible(); +}); + +test('filter survives a tree click and both stack as removable chips', async ({ + page, +}) => { + test.slow(); + + await test.step('Apply Data Assets → Table filter', async () => { + await page.getByTestId('search-dropdown-Data Assets').click(); + const applyRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('table-checkbox').check(); + await applyRes; + await page.keyboard.press('Escape'); + + await expect( + page.getByTestId('query-chip-entityType.keyword-table') + ).toBeVisible(); + }); + + await test.step('Click Databases in the tree — Type chip must survive', async () => { + const browseRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('explore-tree-title-Databases').click(); + await browseRes; + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('browse-chip-entityType')).toBeVisible(); + await expect( + page.getByTestId('query-chip-entityType.keyword-table') + ).toBeVisible(); + + expect(page.url()).toContain('browsePath'); + expect(page.url()).toContain('quickFilter'); + }); + + await test.step('Removing the browse chip keeps the filter chip', async () => { + const removeRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('remove-browse-chip-entityType').click(); + await removeRes; + + await expect(page.getByTestId('browse-chip-entityType')).not.toBeVisible(); + await expect( + page.getByTestId('query-chip-entityType.keyword-table') + ).toBeVisible(); + }); + + await test.step('Clear All empties the whole query', async () => { + await page.getByTestId('clear-all-chips').click(); + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('query-bar-empty-text')).toBeVisible(); + await expect( + page.getByTestId('query-chip-entityType.keyword-table') + ).not.toBeVisible(); + }); +}); + +test('selecting an asset type grays out incompatible tree categories', async ({ + page, +}) => { + await page.getByTestId('search-dropdown-Data Assets').click(); + const applyRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('table-checkbox').check(); + await applyRes; + await page.keyboard.press('Escape'); + + const databasesNode = page + .getByTestId('explore-tree-title-Databases') + .locator('xpath=ancestor::*[contains(@class, "ant-tree-treenode")]'); + const dashboardsNode = page + .getByTestId('explore-tree-title-Dashboards') + .locator('xpath=ancestor::*[contains(@class, "ant-tree-treenode")]'); + + await expect(databasesNode).not.toHaveClass(/ant-tree-treenode-disabled/); + await expect(dashboardsNode).toHaveClass(/ant-tree-treenode-disabled/); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts index e73a7cb9b7db..70ea6e208459 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts @@ -108,10 +108,15 @@ test.describe('search dropdown quick filters - index readiness', () => { filter.key }*${(filter.value ?? '').replaceAll(' ', '+').toLowerCase()}*`; - const queryRes = page.waitForResponse(querySearchURL); - await page.click('[data-testid="update-btn"]'); - await queryRes; - await page.click('[data-testid="clear-filters"]'); + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + const queryRes = page.waitForResponse(querySearchURL); + await updateButton.click(); + await queryRes; + } else { + await waitForAllLoadersToDisappear(page); + } + await page.click('[data-testid="clear-all-chips"]'); } }); }); @@ -233,9 +238,14 @@ test('Filter by column entity type shows only column results', async ({ await dataAssetDropdownRequest; await columnCheckbox.check(); - await page.getByTestId('update-btn').click(); - await page.getByTestId('search-dropdown-Data Assets').click(); + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + // Legacy mode: apply, then reopen the dropdown to confirm persistence. + await updateButton.click(); + await page.getByTestId('search-dropdown-Data Assets').click(); + } + // Immediate-apply leaves the dropdown open with the box already checked. await expect(page.getByTestId('tablecolumn-checkbox')).toBeChecked(); await expect(page.getByTestId('search-dropdown-Data Assets')).toContainText( '(1)' @@ -313,11 +323,14 @@ test.describe('Tier filter - aggregation-based options', () => { }); await test.step('Apply filter and verify asset is visible in results', async () => { - const queryRes = page.waitForResponse( - `/api/v1/search/query?*index=dataAsset*query_filter=*tier.tagFQN*` - ); - await page.getByTestId('update-btn').click(); - await queryRes; + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + const queryRes = page.waitForResponse( + `/api/v1/search/query?*index=dataAsset*query_filter=*tier.tagFQN*` + ); + await updateButton.click(); + await queryRes; + } await waitForAllLoadersToDisappear(page); await expect( @@ -357,11 +370,14 @@ test.describe('Filter persistence after bug fixes', () => { { key: 'tags.tagFQN', label: 'Tag', value: 'PersonalData.Personal' }, true ); - const queryRes = page.waitForResponse( - '/api/v1/search/query?*index=dataAsset*' - ); - await page.getByTestId('update-btn').click(); - await queryRes; + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + const queryRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await updateButton.click(); + await queryRes; + } await waitForAllLoadersToDisappear(page); }); @@ -391,11 +407,14 @@ test.describe('Filter persistence after bug fixes', () => { { key: 'tags.tagFQN', label: 'Tag', value: 'PersonalData.Personal' }, true ); - const queryRes = page.waitForResponse( - '/api/v1/search/query?*index=dataAsset*' - ); - await page.getByTestId('update-btn').click(); - await queryRes; + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + const queryRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await updateButton.click(); + await queryRes; + } await waitForAllLoadersToDisappear(page); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts index 5d7f90797130..25ccecd3e467 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts @@ -16,7 +16,11 @@ import { DATA_ASSETS_SORT } from '../../constant/explore'; import { SidebarItem } from '../../constant/sidebar'; import { performAdminLogin } from '../../utils/admin'; import { redirectToHomePage } from '../../utils/common'; -import { selectSortOrder, verifyEntitiesAreSorted } from '../../utils/explore'; +import { + clickUpdateButtonIfVisible, + selectSortOrder, + verifyEntitiesAreSorted, +} from '../../utils/explore'; import { sidebarClick } from '../../utils/sidebar'; test.describe( @@ -52,12 +56,13 @@ test.describe( .waitFor({ state: 'visible' }); await page.getByTestId(`${filter.toLowerCase()}-checkbox`).check(); - await page.getByTestId('update-btn').click(); + await clickUpdateButtonIfVisible(page); + await page.keyboard.press('Escape'); await selectSortOrder(page, 'Name'); await verifyEntitiesAreSorted(page); - const clearFilters = page.getByTestId('clear-filters'); + const clearFilters = page.getByTestId('clear-all-chips'); await expect(clearFilters).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts index 06ff9ffe7fc9..5506615abbe1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts @@ -13,6 +13,7 @@ import { APIRequestContext, test } from '@playwright/test'; import { createNewPage, redirectToHomePage } from '../../utils/common'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { clickUpdateButtonIfVisible } from '../../utils/explore'; import { createApiEndpointEntity, createContainerEntity, @@ -199,12 +200,12 @@ test.describe('API Endpoint Entity Summary Panel - Nested columns with duplicate await page.getByTestId('search-dropdown-Service').click(); await page.getByTestId('search-input').fill(apiService.service.name); await serviceSearchResponse; - await page.getByTestId(apiService.service.name).click(); - const filteredSearchResponse = page.waitForResponse( '**/api/v1/search/query*' ); - await page.getByTestId('update-btn').click(); + await page.getByTestId(apiService.service.name).click(); + + await clickUpdateButtonIfVisible(page); await filteredSearchResponse; await waitForAllLoadersToDisappear(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts index 3213eb9e5a72..82a31e4a3219 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts @@ -16,6 +16,7 @@ import { performAdminLogin } from '../../utils/admin'; import { clickOutside, redirectToExplorePage } from '../../utils/common'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { + clickUpdateButtonIfVisible, countCsvResponseRows, getExportCountFromModal, getExportModalContent, @@ -215,16 +216,16 @@ test.describe('Search Export', { tag: ['@Features', '@Discovery'] }, () => { await page.getByTestId('search-input').fill('sample_data'); await serviceAggregatePromise; - await page.getByTestId('sample_data').click(); - await expect(page.getByTestId('sample_data-checkbox')).toBeChecked(); - const filteredQueryPromise = page.waitForResponse( (response) => response.url().includes('/api/v1/search/query') && response.status() === 200 ); - await page.getByTestId('update-btn').click(); + await page.getByTestId('sample_data').click(); + await expect(page.getByTestId('sample_data-checkbox')).toBeChecked(); + + await clickUpdateButtonIfVisible(page); await filteredQueryPromise; await waitForAllLoadersToDisappear(page); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchSeparation/SearchSeparationSuite.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchSeparation/SearchSeparationSuite.ts index 7c2689ad1446..570129c4ee10 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchSeparation/SearchSeparationSuite.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchSeparation/SearchSeparationSuite.ts @@ -43,7 +43,10 @@ import { checkExploreSearchFilter, waitForAllLoadersToDisappear, } from '../../../utils/entity'; -import { searchAndClickOnOption } from '../../../utils/explore'; +import { + clickUpdateButtonIfVisible, + searchAndClickOnOption, +} from '../../../utils/explore'; import { sidebarClick } from '../../../utils/sidebar'; const TIER_FQN = 'Tier.Tier1'; @@ -379,8 +382,10 @@ async function checkExploreFilterWithServiceBase( serviceName: string ): Promise { await sidebarClick(page, SidebarItem.EXPLORE); + await waitForAllLoadersToDisappear(page); await page.getByTestId('search-dropdown-Service').click(); + const serviceApplyRes = page.waitForResponse('/api/v1/search/query?*'); await searchAndClickOnOption( page, { @@ -390,16 +395,20 @@ async function checkExploreFilterWithServiceBase( }, true ); - await page.click('[data-testid="update-btn"]'); + await clickUpdateButtonIfVisible(page); + await serviceApplyRes; await waitForAllLoadersToDisappear(page); await page.getByTestId(`search-dropdown-${filterLabel}`).click(); + const filterApplyRes = page.waitForResponse('/api/v1/search/query?*'); await searchAndClickOnOption( page, { label: filterLabel, key: filterKey, value: filterValue }, true ); - await page.click('[data-testid="update-btn"]'); + await clickUpdateButtonIfVisible(page); + await filterApplyRes; + await page.keyboard.press('Escape'); await waitForAllLoadersToDisappear(page); await expect( @@ -420,23 +429,29 @@ async function checkExploreFilterWithTagBase( uniqueTagFqn: string ): Promise { await sidebarClick(page, SidebarItem.EXPLORE); + await waitForAllLoadersToDisappear(page); await page.getByTestId('search-dropdown-Tag').click(); + const tagApplyRes = page.waitForResponse('/api/v1/search/query?*'); await searchAndClickOnOption( page, { label: 'Tag', key: 'tags.tagFQN', value: uniqueTagFqn }, true ); - await page.click('[data-testid="update-btn"]'); + await clickUpdateButtonIfVisible(page); + await tagApplyRes; await waitForAllLoadersToDisappear(page); await page.getByTestId(`search-dropdown-${filterLabel}`).click(); + const filterApplyRes = page.waitForResponse('/api/v1/search/query?*'); await searchAndClickOnOption( page, { label: filterLabel, key: filterKey, value: filterValue }, true ); - await page.click('[data-testid="update-btn"]'); + await clickUpdateButtonIfVisible(page); + await filterApplyRes; + await page.keyboard.press('Escape'); await waitForAllLoadersToDisappear(page); await expect( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts index 7b8a5e82bb23..386561666bcc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts @@ -20,6 +20,7 @@ import { getEncodedFqn, waitForAllLoadersToDisappear, } from '../../utils/entity'; +import { clickUpdateButtonIfVisible } from '../../utils/explore'; import { getJsonTreeObject } from '../../utils/exploreDiscovery'; import { sidebarClick } from '../../utils/sidebar'; @@ -280,7 +281,7 @@ test.describe('Explore Assets Discovery', () => { .getByTestId(user.responseData.displayName) ).not.toBeAttached(); - await page.getByTestId('close-btn').click(); + await page.keyboard.press('Escape'); // The domain should not be visible in the domains filter when the deleted switch is off await page.click('[data-testid="search-dropdown-Domains"]'); @@ -303,7 +304,7 @@ test.describe('Explore Assets Discovery', () => { .getByTestId(domain.responseData.displayName) ).not.toBeAttached(); - await page.getByTestId('close-btn').click(); + await page.keyboard.press('Escape'); }); test('Should display domain and owner of deleted asset in suggestions when showDeleted is on', async ({ @@ -335,19 +336,23 @@ test.describe('Explore Assets Discovery', () => { page.getByTestId('drop-down-menu').getByTestId(ownerSearchText) ).toBeAttached(); + // Arm before the option click: immediate-apply fires the query on the click + const fetchWithOwner = page.waitForResponse( + `/api/v1/search/query?*deleted=true*ownerDisplayName*${ownerSearchText}*` + ); await page .getByTestId('drop-down-menu') .getByTestId(ownerSearchText) .click(); - - const fetchWithOwner = page.waitForResponse( - `/api/v1/search/query?*deleted=true*ownerDisplayName*${ownerSearchText}*` - ); - await page.getByTestId('update-btn').click(); + await clickUpdateButtonIfVisible(page); await fetchWithOwner; await waitForAllLoadersToDisappear(page); + // Close the Owners dropdown before opening the next — immediate-apply keeps + // it open after selection, and a stale open menu has its own search-input + await page.keyboard.press('Escape'); + // The domain should be visible in the domains filter when the deleted switch is on const domainSearchText = domain.responseData.displayName.toLowerCase(); await page.click('[data-testid="search-dropdown-Domains"]'); @@ -365,22 +370,25 @@ test.describe('Explore Assets Discovery', () => { page.getByTestId('drop-down-menu').getByTestId(domainSearchText) ).toBeAttached(); - await page - .getByTestId('drop-down-menu') - .getByTestId(domainSearchText) - .click(); - + // Arm before the option click: immediate-apply fires the query on the click const fetchWithDomain = page.waitForResponse( `/api/v1/search/query?*deleted=true*domains.displayName.keyword*${getEncodedFqn( domainSearchText, true )}*` ); - await page.getByTestId('update-btn').click(); + await page + .getByTestId('drop-down-menu') + .getByTestId(domainSearchText) + .click(); + await clickUpdateButtonIfVisible(page); await fetchWithDomain; await waitForAllLoadersToDisappear(page); + // Close the Domains dropdown before opening the Data Assets one + await page.keyboard.press('Escape'); + // Only the table option should be visible for the data assets filter when the deleted switch is on // with the owner and domain filter applied await page.click('[data-testid="search-dropdown-Data Assets"]'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts index 5b2013a21e7d..0130bd652e39 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts @@ -764,13 +764,25 @@ test.describe('Input Output Ports', () => { await test.step('Verify input ports list', async () => { await expect(page.getByTestId('input-ports-list')).toBeVisible(); - const table1Name = get(tables[0], 'entityResponseData.name'); - const table2Name = get(tables[1], 'entityResponseData.name'); - const table3Name = get(tables[2], 'entityResponseData.name'); + const table1Name = get( + tables[0], + 'entityResponseData.fullyQualifiedName', + '' + ); + const table2Name = get( + tables[1], + 'entityResponseData.fullyQualifiedName', + '' + ); + const table3Name = get( + tables[2], + 'entityResponseData.fullyQualifiedName', + '' + ); - await expect(page.locator(`text=${table1Name}`).first()).toBeVisible(); - await expect(page.locator(`text=${table2Name}`).first()).toBeVisible(); - await expect(page.locator(`text=${table3Name}`).first()).toBeVisible(); + await expect(page.getByTestId(table1Name)).toBeVisible(); + await expect(page.getByTestId(table2Name)).toBeVisible(); + await expect(page.getByTestId(table3Name)).toBeVisible(); }); }); @@ -801,20 +813,11 @@ test.describe('Input Output Ports', () => { await test.step('Verify output ports list', async () => { await expect(page.getByTestId('output-ports-list')).toBeVisible(); - const dashboard1Name = get( - dashboards[0], - 'entityResponseData.displayName' - ); - const dashboard2Name = get( - dashboards[1], - 'entityResponseData.displayName' - ); - await expect( - page.locator(`text=${dashboard1Name}`).first() + page.getByTestId(dashboards[0].entityResponseData.fullyQualifiedName) ).toBeVisible(); await expect( - page.locator(`text=${dashboard2Name}`).first() + page.getByTestId(dashboards[1].entityResponseData.fullyQualifiedName) ).toBeVisible(); }); }); @@ -1138,29 +1141,24 @@ test.describe('Input Output Ports', () => { }); await test.step('Verify input port nodes are visible', async () => { - const table1Name = get(tables[0], 'entityResponseData.name'); - const table2Name = get(tables[1], 'entityResponseData.name'); + const table1Name = + tables[0].entityResponseData.fullyQualifiedName ?? ''; + const table2Name = + tables[1].entityResponseData.fullyQualifiedName ?? ''; - await expect(page.locator(`text=${table1Name}`).first()).toBeVisible(); - await expect(page.locator(`text=${table2Name}`).first()).toBeVisible(); + await expect(page.getByTestId(table1Name)).toBeVisible(); + await expect(page.getByTestId(table2Name)).toBeVisible(); }); await test.step('Verify output port nodes are visible', async () => { - const dashboard1Name = get( - dashboards[0], - 'entityResponseData.displayName' - ); - const dashboard2Name = get( - dashboards[1], - 'entityResponseData.displayName' - ); + const dashboard1Name = + dashboards[0].entityResponseData.fullyQualifiedName ?? ''; - await expect( - page.locator(`text=${dashboard1Name}`).first() - ).toBeVisible(); - await expect( - page.locator(`text=${dashboard2Name}`).first() - ).toBeVisible(); + const dashboard2Name = + dashboards[1].chartsResponseData.fullyQualifiedName; + + await expect(page.getByTestId(dashboard1Name)).toBeVisible(); + await expect(page.getByTestId(dashboard2Name)).toBeVisible(); }); }); @@ -1189,8 +1187,8 @@ test.describe('Input Output Ports', () => { await test.step('Verify only input port is shown', async () => { await expect(page.getByTestId('ports-lineage-view')).toBeVisible(); - const tableName = get(tables[0], 'entityResponseData.name'); - await expect(page.locator(`text=${tableName}`).first()).toBeVisible(); + const tableName = tables[0].entityResponseData.fullyQualifiedName ?? ''; + await expect(page.getByTestId(tableName)).toBeVisible(); }); }); @@ -1219,13 +1217,9 @@ test.describe('Input Output Ports', () => { await test.step('Verify only output port is shown', async () => { await expect(page.getByTestId('ports-lineage-view')).toBeVisible(); - const dashboardName = get( - dashboards[0], - 'entityResponseData.displayName' - ); - await expect( - page.locator(`text=${dashboardName}`).first() - ).toBeVisible(); + const dashboardName = + dashboards[0].entityResponseData.fullyQualifiedName ?? ''; + await expect(page.getByTestId(dashboardName)).toBeVisible(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 5774aebdefa6..24cb7a7b6d95 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -504,7 +504,7 @@ export const verifyAllConditions = async ( searchCriteria: searchCriteria, index: 1, }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); } // Check for Must Not conditions @@ -516,7 +516,7 @@ export const verifyAllConditions = async ( searchCriteria: searchCriteria, index: 1, }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); } // Don't run null path if it's present in skipConditions @@ -533,7 +533,7 @@ export const verifyAllConditions = async ( searchCriteria: undefined, index: 1, }); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); } } }; @@ -565,10 +565,10 @@ export const checkAddRuleOrGroupWithOperator = async ( index: 1, }); - if (!isGroupTest) { - await page.getByTestId('advanced-search-add-rule').nth(1).click(); - } else { + if (isGroupTest) { await page.getByTestId('advanced-search-add-group').first().click(); + } else { + await page.getByTestId('advanced-search-add-rule').nth(1).click(); } await fillRule(page, { @@ -653,7 +653,7 @@ export const runRuleGroupTests = async ( }, isGroupTest ); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); } }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts index 1a97beb3c2b8..8ef4ee98bce7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts @@ -539,7 +539,7 @@ export const clearAdvancedSearchFilters = async (page: Page) => { const clearResponse = page.waitForResponse( '/api/v1/search/query?*index=dataAsset*' ); - await page.getByTestId('clear-filters').click(); + await page.getByTestId('advance-search-clear-btn').click(); await clearResponse; await waitForAllLoadersToDisappear(page); }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index c5231a48b77d..73c3f179beb3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -2258,19 +2258,13 @@ export const checkExploreSearchFilter = async ( await page.fill('[data-testid="search-input"]', entityTypeId); await page.getByTestId(entityTypeId).click(); await entitySearchResponse; - await page.getByTestId('update-btn').click(); + // Immediate-apply commits on selection; legacy mode needs the Update click + const typeUpdateButton = page.getByTestId('update-btn'); + if (await typeUpdateButton.isVisible().catch(() => false)) { + await typeUpdateButton.click(); + } + await page.keyboard.press('Escape'); } - await page.getByTestId(`search-dropdown-${filterLabel}`).click(); - await searchAndClickOnOption( - page, - { - label: filterLabel, - key: filterKey, - value: filterValue, - }, - true - ); - const rawFilterValue = (filterValue ?? '').replaceAll(' ', '+').toLowerCase(); const escapedValue = JSON.stringify(rawFilterValue).slice(1, -1); const filterValueForSearchURL = /["%]/.test(filterValue ?? '') @@ -2316,7 +2310,23 @@ export const checkExploreSearchFilter = async ( { timeout: 30_000 } ); - await page.click('[data-testid="update-btn"]'); + // Arm the wait before selecting: immediate-apply fires the query on the + // option click; legacy mode fires it on the Update click below. + await page.getByTestId(`search-dropdown-${filterLabel}`).click(); + await searchAndClickOnOption( + page, + { + label: filterLabel, + key: filterKey, + value: filterValue, + }, + true + ); + + const filterUpdateButton = page.getByTestId('update-btn'); + if (await filterUpdateButton.isVisible().catch(() => false)) { + await filterUpdateButton.click(); + } await queryRes; await waitForAllLoadersToDisappear(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts index ba0925f2a3f5..242490597099 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts @@ -24,6 +24,18 @@ export interface Bucket { doc_count: number; } +/** + * Explore quick filters apply immediately; the Update button only exists in + * legacy (non immediate-apply) consumers. Click it when present so shared + * flows work in both modes. + */ +export const clickUpdateButtonIfVisible = async (page: Page) => { + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + await updateButton.click(); + } +}; + export const searchAndClickOnOption = async ( page: Page, filter: { key: string; label: string; value?: string }, @@ -86,10 +98,16 @@ export const selectNullOption = async ( await searchAndClickOnOption(page, filter, true); } - const queryRes = page.waitForResponse(querySearchURL); - await page.click('[data-testid="update-btn"]'); + // Immediate-apply commits on selection (no Update button); legacy mode commits + // on the Update click. Only wait on the Update-triggered response in legacy + // mode, otherwise the query has already fired and we just let loaders settle. + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + const queryRes = page.waitForResponse(querySearchURL); + await updateButton.click(); + await queryRes; + } await waitForAllLoadersToDisappear(page); - await queryRes; const queryParams = page.url().split('?')[1]; const queryParamsObj = new URLSearchParams(queryParams); @@ -99,7 +117,7 @@ export const selectNullOption = async ( expect(queryParamValue).toEqual(queryFilter); if (clearFilter) { - await page.click(`[data-testid="clear-filters"]`); + await page.click(`[data-testid="clear-all-chips"]`); } }; @@ -134,7 +152,16 @@ export const selectDataAssetFilter = async ( .fill(filterValue.toLowerCase()); await dataAssetDropdownRequest; await page.getByTestId(`${filterValue.toLowerCase()}-checkbox`).check(); - await page.getByTestId('update-btn').click(); + + // Legacy mode commits + closes on Update; immediate-apply commits on check but + // leaves the dropdown open, so close it via its trigger to match the helper's + // post-condition (results interactable for callers). + const updateButton = page.getByTestId('update-btn'); + if (await updateButton.isVisible().catch(() => false)) { + await updateButton.click(); + } else { + await page.getByRole('button', { name: 'Data Assets' }).click(); + } }; export const validateBucketsForIndex = async (page: Page, index: string) => { @@ -172,11 +199,11 @@ export const expandServiceInExploreTree = async ( const serviceNameRes = page.waitForResponse( '/api/v1/search/query?q=&index=database&from=0&size=0*mysql*' ); + // Tree rows carry count badges, so match by testid instead of exact text await page - .locator('div') - .filter({ hasText: /^mysql$/ }) - .locator('svg') - .first() + .locator('.ant-tree-treenode') + .filter({ has: page.getByTestId('explore-tree-title-mysql') }) + .locator('.ant-tree-switcher svg') .click(); await serviceNameRes; } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts index 755d5d33d04f..77859a5b7c73 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts @@ -36,6 +36,7 @@ import { } from './common'; import { customFormatDateTime, getEpochMillisForFutureDays } from './dateTime'; import { waitForAllLoadersToDisappear } from './entity'; +import { clickUpdateButtonIfVisible } from './explore'; import { settingClick, SettingOptionsType, sidebarClick } from './sidebar'; export const visitUserListPage = async (page: Page) => { @@ -661,14 +662,21 @@ export const checkStewardServicesPermissions = async (page: Page) => { await dataAssetDropdownRequest; await page.locator('[data-testid="table-checkbox"]').scrollIntoViewIfNeeded(); - await page.click('[data-testid="table-checkbox"]'); + // Arm before the option click: immediate-apply fires the query on the click const getSearchResultResponse = page.waitForResponse( '/api/v1/search/query?q=*' ); - await page.click('[data-testid="update-btn"]'); + await page.click('[data-testid="table-checkbox"]'); + await clickUpdateButtonIfVisible(page); await getSearchResultResponse; + await waitForAllLoadersToDisappear(page); + + // Close the dropdown by toggling its trigger — pressing Escape would also + // close the auto-opened summary panel (ExploreV1 has a document-level + // Escape handler), removing the entity-link this step needs to click. + await page.click('[data-testid="search-dropdown-Data Assets"]'); // Click on the entity link in the drawer title await page.click('.summary-panel-container [data-testid="entity-link"]'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts index e967183cb980..f06396a24f1a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts @@ -114,6 +114,15 @@ export interface ExploreProps { quickFilters?: QueryFilterInterface; isElasticSearchIssue?: boolean; + + // Browse-tree location (from the `browsePath` URL param) and its ES filter. + // It ANDs with quickFilters; a tree click updates both in one navigation. + browseFields?: ExploreQuickFilterField[]; + browseQueryFilter?: QueryFilterInterface; + onTreeSelect?: (payload: { + browseFields: ExploreQuickFilterField[]; + quickFilter?: QueryFilterInterface; + }) => void; } export interface ExploreQuickFilterField { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.component.tsx new file mode 100644 index 000000000000..87daa5d64921 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.component.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Collate. + * 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 { Button } from '@openmetadata/ui-core-components'; +import { FilterFunnel01, XCircle, XClose } from '@untitledui/icons'; +import { isEmpty } from 'lodash'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { EntityFields } from '../../../enums/AdvancedSearch.enum'; +import { getEntityNameLabel } from '../../../utils/EntityNameUtils'; +import { getCanonicalEntityType } from '../../../utils/ExploreUtils'; +import { translateWithNestedKeys } from '../../../utils/i18next/LocalUtil'; +import './explore-query-filter-chips.less'; +import { + ExploreQueryFilterChipsProps, + QueryFilterChip, +} from './ExploreQueryFilterChips.interface'; + +const ENTITY_TYPE_KEYS: ReadonlySet = new Set([ + EntityFields.ENTITY_TYPE, + EntityFields.ENTITY_TYPE_KEYWORD, +]); + +const ExploreQueryFilterChips = ({ + fields, + browseFields = [], + onRemoveValue, + onRemoveBrowseLevel, + onClearAll, + emptyText, +}: ExploreQueryFilterChipsProps) => { + const { t } = useTranslation(); + + // The browse path is one chip per level — "In Databases / Service redshift + // prod / Database dev / Schema dbt_jaffle". + const browseLevelLabels: Record = useMemo( + () => ({ + [EntityFields.ENTITY_TYPE]: t('label.in'), + [EntityFields.SERVICE_TYPE]: t('label.service-type'), + [EntityFields.SERVICE]: t('label.service'), + [EntityFields.DATABASE_DISPLAY_NAME]: t('label.database'), + [EntityFields.DATABASE_SCHEMA_DISPLAY_NAME]: t('label.schema'), + }), + [t] + ); + + // The Type chip reads "Type Table" — the dropdown's own label and raw + // bucket keys ("table", "tableColumn") are too technical for the query bar. + const chips: QueryFilterChip[] = useMemo( + () => + fields.flatMap((field) => { + const isEntityTypeField = ENTITY_TYPE_KEYS.has(field.key); + + return (field.value ?? []).map((option) => ({ + field, + label: isEntityTypeField + ? t('label.type') + : translateWithNestedKeys(field.label, field.labelKeyOptions), + option: isEntityTypeField + ? { + ...option, + label: getEntityNameLabel(getCanonicalEntityType(option.key)), + } + : option, + })); + }), + [fields, t] + ); + + const hasActiveQuery = !isEmpty(chips) || !isEmpty(browseFields); + + if (!hasActiveQuery && !emptyText) { + return null; + } + + return ( +
+ + + {t('label.query')} + + + {!hasActiveQuery && ( + + {emptyText} + + )} + + {browseFields.map((field) => { + // A category root keeps its human title in `label`; deeper levels are + // single-value picks whose value label is the location name. + const isCategoryLevel = field.key === EntityFields.ENTITY_TYPE; + const chipValue = isCategoryLevel + ? field.label + : (field.value ?? []).map((option) => option.label).join(', '); + + return ( + + + {browseLevelLabels[field.key] ?? field.key} + + + {chipValue} + + {onRemoveBrowseLevel && ( + + )} + + ); + })} + + {chips.map(({ field, label, option }) => ( + + + {label} + + + {option.label} + + + + ))} + + {hasActiveQuery && onClearAll && ( + + )} +
+ ); +}; + +export default ExploreQueryFilterChips; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.interface.ts new file mode 100644 index 000000000000..6a4b3010c3b2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.interface.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Collate. + * 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 { SearchDropdownOption } from '../../SearchDropdown/SearchDropdown.interface'; +import { ExploreQuickFilterField } from '../ExplorePage.interface'; + +export interface ExploreQueryFilterChipsProps { + fields: ExploreQuickFilterField[]; + // Browse-tree location levels, rendered as chips before the filter chips. + browseFields?: ExploreQuickFilterField[]; + // Remove a single selected value from a filter field. + onRemoveValue: (field: ExploreQuickFilterField, optionKey: string) => void; + // Remove a browse level (and implicitly everything below it). + onRemoveBrowseLevel?: (levelKey: string) => void; + // Clear every active filter at once. + onClearAll?: () => void; + // Shown when nothing is active — keeps the QUERY bar persistent. + emptyText?: string; +} + +export interface QueryFilterChip { + field: ExploreQuickFilterField; + label: string; + option: SearchDropdownOption; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.test.tsx new file mode 100644 index 000000000000..70dbb7c87565 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2025 Collate. + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { ExploreQuickFilterField } from '../ExplorePage.interface'; +import ExploreQueryFilterChips from './ExploreQueryFilterChips.component'; + +const dataAssetField: ExploreQuickFilterField = { + key: 'entityType.keyword', + label: 'label.data-asset-plural', + value: [ + { key: 'table', label: 'Table' }, + { key: 'tableColumn', label: 'Column' }, + ], +}; + +const serviceField: ExploreQuickFilterField = { + key: 'service.displayName.keyword', + label: 'label.service', + value: [{ key: 'redshift prod', label: 'redshift prod' }], +}; + +const emptyField: ExploreQuickFilterField = { + key: 'tier.tagFQN', + label: 'label.tier', + value: [], +}; + +describe('ExploreQueryFilterChips', () => { + it('renders nothing when no filter has a selected value', () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders one removable chip per selected value across fields', () => { + render( + + ); + + expect( + screen.getByTestId('query-chip-entityType.keyword-table') + ).toBeInTheDocument(); + expect( + screen.getByTestId('query-chip-entityType.keyword-tableColumn') + ).toBeInTheDocument(); + expect( + screen.getByTestId('query-chip-service.displayName.keyword-redshift prod') + ).toBeInTheDocument(); + + // Entity-type chip values render through getEntityNameLabel + // (table -> label.table, tableColumn -> label.column). + expect(screen.getByText('label.table')).toBeInTheDocument(); + expect(screen.getByText('label.column')).toBeInTheDocument(); + }); + + it('calls onRemoveValue with the field and option key when a chip is removed', () => { + const onRemoveValue = jest.fn(); + + render( + + ); + + fireEvent.click( + screen.getByTestId('remove-chip-entityType.keyword-tableColumn') + ); + + expect(onRemoveValue).toHaveBeenCalledWith(dataAssetField, 'tableColumn'); + }); + + it('renders the persistent empty state when emptyText is provided', () => { + render( + + ); + + expect(screen.getByTestId('query-bar-empty-text')).toHaveTextContent( + 'Browsing your whole data estate' + ); + expect(screen.queryByTestId('clear-all-chips')).not.toBeInTheDocument(); + }); + + it('renders browse-location chips before filter chips with level labels', () => { + const browseFields = [ + { + key: 'entityType', + label: 'Databases', + value: [ + { key: 'table', label: 'table' }, + { key: 'tableColumn', label: 'tableColumn' }, + ], + }, + { + key: 'service.displayName.keyword', + label: 'service.displayName.keyword', + value: [{ key: 'redshift prod', label: 'redshift prod' }], + }, + ]; + + render( + + ); + + const categoryChip = screen.getByTestId('browse-chip-entityType'); + const serviceChip = screen.getByTestId( + 'browse-chip-service.displayName.keyword' + ); + + expect(categoryChip).toHaveTextContent('label.in'); + expect(categoryChip).toHaveTextContent('Databases'); + expect(serviceChip).toHaveTextContent('label.service'); + expect(serviceChip).toHaveTextContent('redshift prod'); + expect( + screen.getByTestId('query-chip-entityType.keyword-table') + ).toBeInTheDocument(); + }); + + it('removes a browse level through onRemoveBrowseLevel', () => { + const onRemoveBrowseLevel = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByTestId('remove-browse-chip-serviceType')); + + expect(onRemoveBrowseLevel).toHaveBeenCalledWith('serviceType'); + }); + + it('renders and fires the clear-all action only when provided', () => { + const onClearAll = jest.fn(); + const { rerender } = render( + + ); + + expect(screen.queryByTestId('clear-all-chips')).not.toBeInTheDocument(); + + rerender( + + ); + + fireEvent.click(screen.getByTestId('clear-all-chips')); + + expect(onClearAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/explore-query-filter-chips.less b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/explore-query-filter-chips.less new file mode 100644 index 000000000000..dc20f3c90a75 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQueryFilterChips/explore-query-filter-chips.less @@ -0,0 +1,89 @@ +/* + * Copyright 2025 Collate. + * 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 (reference) '../../../styles/variables.less'; + +.explore-query-filter-chips { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 6px 0 6px 8px; + + &__query-label { + display: inline-flex; + align-items: center; + gap: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 12px; + font-weight: 600; + color: @grey-4; + } + + &__empty { + color: @text-grey-muted; + font-size: 12px; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid @border-color; + border-radius: 8px; + background-color: @white; + font-size: 12px; + line-height: 20px; + + // Browse-location chips read as "where you are" — tinted with the brand + // color to stand apart from plain filter chips. + &--browse { + border-color: @primary-1; + background-color: @primary-50; + + .explore-query-filter-chips__chip-label, + .explore-query-filter-chips__chip-value { + color: @primary-color; + } + + .explore-query-filter-chips__chip-label { + font-weight: 400; + } + } + } + + &__chip-label { + color: @text-grey-muted; + } + + &__chip-value { + color: @text-color; + font-weight: 500; + } + + &__remove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: @grey-4; + cursor: pointer; + + &:hover { + color: @text-color; + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts index fcd454fb180e..c2667045c513 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts @@ -31,4 +31,8 @@ export interface ExploreQuickFiltersProps { showSelectedCounts?: boolean; // flag to show counts instead of labels for selected filters optionPageSize?: number; additionalActions?: ReactNode; + // Apply each selection to the query immediately (no Update button). Opt-in for the Explore page. + immediateApply?: boolean; + // Helper text shown at the bottom of each filter dropdown when immediateApply is enabled. + helperText?: string; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx index 7484d17b7302..a852a5ed3426 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx @@ -11,7 +11,13 @@ * limitations under the License. */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { EntityFields } from '../../enums/AdvancedSearch.enum'; import { SearchIndex } from '../../enums/search.enum'; @@ -115,6 +121,7 @@ jest.mock('../SearchDropdown/SearchDropdown', () => ({ })); jest.mock('../../utils/ExploreUtils', () => ({ + ...jest.requireActual('../../utils/ExplorePureUtils'), getAggregationOptions: jest.fn(), })); @@ -647,6 +654,66 @@ describe('ExploreQuickFilters component', () => { }); }); + describe('Facet query filter (own field excluded)', () => { + const tierField: ExploreQuickFilterField = { + label: 'Tier', + key: 'tier.tagFQN', + value: [ + { key: 'Tier.Tier1', label: 'Tier.Tier1' }, + { key: 'Tier.Tier2', label: 'Tier.Tier2' }, + ], + }; + const tagField: ExploreQuickFilterField = { + label: 'Tag', + key: 'tags.tagFQN', + value: undefined, + }; + + it('keeps sibling-field selections when fetching options for another facet', async () => { + mockUseCustomLocation.mockReturnValue({ search: '' }); + mockGetAggregationOptions.mockResolvedValue( + mockAdvancedFieldDefaultOptions + ); + + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('onGetInitialOptions-tags.tagFQN')); + }); + + const filterArg = (getAggregationOptions as jest.Mock).mock.calls.at( + -1 + )[3] as string; + + expect(filterArg).toContain('Tier.Tier1'); + expect(filterArg).toContain('Tier.Tier2'); + }); + + it('excludes the facet own selections so the full option list stays visible', async () => { + mockUseCustomLocation.mockReturnValue({ search: '' }); + mockGetAggregationOptions.mockResolvedValue( + mockAdvancedFieldDefaultOptions + ); + + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('onGetInitialOptions-tier.tagFQN')); + }); + + const filterArg = (getAggregationOptions as jest.Mock).mock.calls.at( + -1 + )[3] as string; + + expect(filterArg).not.toContain('Tier.Tier1'); + expect(filterArg).not.toContain('Tier.Tier2'); + }); + }); + describe('Combined query filter', () => { it('should combine quickFilter, queryFilter, and defaultQueryFilter', async () => { mockUseCustomLocation.mockReturnValue({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx index 49187d213c68..dc6cd41d5445 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx @@ -13,7 +13,7 @@ import { Space } from 'antd'; import { AxiosError } from 'axios'; -import { isEqual, uniqWith } from 'lodash'; +import { isEmpty, isEqual, uniqWith } from 'lodash'; import Qs from 'qs'; import { FC, useCallback, useMemo, useState } from 'react'; import { EntityFields } from '../../enums/AdvancedSearch.enum'; @@ -22,11 +22,16 @@ import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import { useSearchStore } from '../../hooks/useSearchStore'; import { QueryFilterInterface } from '../../pages/ExplorePage/ExplorePage.interface'; import { getOptionsFromAggregationBucket } from '../../utils/AdvancedSearchPureUtils'; +import { getEntityNameLabel } from '../../utils/EntityNameUtils'; import { getCombinedQueryFilterObject, getQuickFilterWithDeletedFlag, } from '../../utils/ExplorePage/ExplorePageUtils'; -import { getAggregationOptions } from '../../utils/ExploreUtils'; +import { + getAggregationOptions, + getCanonicalEntityType, + getExploreQueryFilterMust, +} from '../../utils/ExploreUtils'; import { translateWithNestedKeys } from '../../utils/i18next/LocalUtil'; import { showErrorToast } from '../../utils/ToastUtils'; import SearchDropdown from '../SearchDropdown/SearchDropdown'; @@ -34,6 +39,20 @@ import { SearchDropdownOption } from '../SearchDropdown/SearchDropdown.interface import { useAdvanceSearch } from './AdvanceSearchProvider/AdvanceSearchProvider.component'; import { ExploreSearchIndex } from './ExplorePage.interface'; import { ExploreQuickFiltersProps } from './ExploreQuickFilters.interface'; + +const ENTITY_TYPE_FILTER_KEYS: ReadonlySet = new Set([ + EntityFields.ENTITY_TYPE, + EntityFields.ENTITY_TYPE_KEYWORD, +]); + +const formatEntityTypeLabel = (value: string): string => + getEntityNameLabel(getCanonicalEntityType(value)); + +const getOptionLabelFormatter = ( + key: string +): ((value: string) => string) | undefined => + ENTITY_TYPE_FILTER_KEYS.has(key) ? formatEntityTypeLabel : undefined; + const ExploreQuickFilters: FC = ({ fields, index, @@ -45,6 +64,8 @@ const ExploreQuickFilters: FC = ({ showSelectedCounts = false, optionPageSize, additionalActions, + immediateApply = false, + helperText, }) => { const location = useCustomLocation(); const [options, setOptions] = useState(); @@ -56,7 +77,7 @@ const ExploreQuickFilters: FC = ({ [fields] ); - const { showDeleted, quickFilter, searchText } = useMemo(() => { + const { showDeleted, searchText } = useMemo(() => { const parsed = Qs.parse( location.search.startsWith('?') ? location.search.substring(1) @@ -65,7 +86,6 @@ const ExploreQuickFilters: FC = ({ return { showDeleted: parsed.showDeleted === 'true', - quickFilter: parsed.quickFilter ?? '', searchText: (parsed.search as string) ?? '', }; }, [location.search]); @@ -76,15 +96,40 @@ const ExploreQuickFilters: FC = ({ [index] ); - const getAdvancedSearchQuickFilters = useCallback(() => { - return getQuickFilterWithDeletedFlag(quickFilter as string, showDeleted); - }, [quickFilter, showDeleted]); + const hasSelectedFieldValues = useMemo( + () => fields.some((field) => !isEmpty(field.value)), + [fields] + ); - const updatedQuickFilters = getAdvancedSearchQuickFilters(); - const combinedQueryFilter = getCombinedQueryFilterObject( - updatedQuickFilters as QueryFilterInterface, - queryFilter as unknown as QueryFilterInterface, - defaultQueryFilter as unknown as QueryFilterInterface + // Facet options exclude the facet's own field (but keep every other + // constraint): unselecting Column must reveal the other asset types still + // available in the current browse location, and selecting values must not + // shrink the list to just the selection. + const getFacetQueryFilter = useCallback( + (key: string) => { + const isEntityTypeKey = ENTITY_TYPE_FILTER_KEYS.has(key); + const otherFieldsMust = getExploreQueryFilterMust( + fields.filter( + (field) => + !isEmpty(field.value) && + field.key !== key && + !(isEntityTypeKey && ENTITY_TYPE_FILTER_KEYS.has(field.key)) + ) + ); + const otherFieldsFilter = isEmpty(otherFieldsMust) + ? '' + : JSON.stringify({ query: { bool: { must: otherFieldsMust } } }); + + return getCombinedQueryFilterObject( + getQuickFilterWithDeletedFlag( + otherFieldsFilter, + showDeleted + ) as QueryFilterInterface, + queryFilter as unknown as QueryFilterInterface, + defaultQueryFilter as unknown as QueryFilterInterface + ); + }, + [fields, showDeleted, queryFilter, defaultQueryFilter] ); const fetchDefaultOptions = async ( @@ -105,13 +150,21 @@ const ExploreQuickFilters: FC = ({ // Use field-specific searchKey if provided, otherwise use the key const searchKeyToUse = fieldSearchKey ?? key; - let buckets = aggregations?.[key]?.buckets; + // The page aggregations reflect the full current query — including this + // facet's own selection — so they are only reusable when nothing scopes + // the facet differently from the page. + const canUsePageAggregations = + !hasSelectedFieldValues && isEmpty(defaultQueryFilter); + + let buckets = canUsePageAggregations + ? aggregations?.[key]?.buckets + : undefined; if (!buckets) { const res = await getAggregationOptions( searchIndexToUse, searchKeyToUse, '', - JSON.stringify(combinedQueryFilter), + JSON.stringify(getFacetQueryFilter(key)), independent, showDeleted, optionPageSize, @@ -119,10 +172,16 @@ const ExploreQuickFilters: FC = ({ searchText ); - buckets = res.data.aggregations[`sterms#${searchKeyToUse}`].buckets; + buckets = + res.data.aggregations[`sterms#${searchKeyToUse}`]?.buckets ?? []; } - setOptions(uniqWith(getOptionsFromAggregationBucket(buckets), isEqual)); + setOptions( + uniqWith( + getOptionsFromAggregationBucket(buckets, getOptionLabelFormatter(key)), + isEqual + ) + ); }; const getInitialOptions = async ( @@ -182,7 +241,7 @@ const ExploreQuickFilters: FC = ({ searchIndexToUse, searchKeyToUse, value, - JSON.stringify(combinedQueryFilter), + JSON.stringify(getFacetQueryFilter(key)), independent, showDeleted, undefined, @@ -190,8 +249,17 @@ const ExploreQuickFilters: FC = ({ searchText ); - const buckets = res.data.aggregations[`sterms#${searchKeyToUse}`].buckets; - setOptions(uniqWith(getOptionsFromAggregationBucket(buckets), isEqual)); + const buckets = + res.data.aggregations[`sterms#${searchKeyToUse}`]?.buckets ?? []; + setOptions( + uniqWith( + getOptionsFromAggregationBucket( + buckets, + getOptionLabelFormatter(key) + ), + isEqual + ) + ); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -212,8 +280,10 @@ const ExploreQuickFilters: FC = ({ highlight dropdownClassName={field.dropdownClassName} hasNullOption={hasNullOption} + helperText={helperText} hideCounts={field.hideCounts ?? false} hideSearchBar={field.hideSearchBar ?? false} + immediateApply={immediateApply} independent={independent} index={displayIndex as ExploreSearchIndex} isSuggestionsLoading={isOptionsLoading} @@ -224,7 +294,6 @@ const ExploreQuickFilters: FC = ({ selectedKeys={field.value ?? []} showSelectedCounts={showSelectedCounts} singleSelect={field.singleSelect} - triggerButtonSize="middle" onChange={(updatedValues) => { onFieldValueSelect({ ...field, value: updatedValues }); }} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts index b770956f530f..213a62b77b0d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts @@ -25,10 +25,23 @@ export type ExploreTreeNode = { totalCount?: number; type?: string | null; tooltip?: string; + disabled?: boolean; }; export type ExploreTreeProps = { + // Static governance leaves (Glossary/Tag/Metric…) still apply a plain quick + // filter through this callback. onFieldValueSelect: (field: ExploreQuickFilterField[]) => void; + // Hierarchical selections (category/serviceType/service/database/schema) + // report the browse location; entity-type leaves additionally report the + // type they refine to, so filters and location update in one navigation. + onTreeSelect: (payload: { + browseFields: ExploreQuickFilterField[]; + typeField?: ExploreQuickFilterField; + }) => void; + // Entity types selected in the Data Assets filter. Top-level categories that + // cannot contain any of these types are grayed out and non-selectable. + selectedEntityTypes?: string[]; }; export type TreeNodeData = { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx index e5dbb069d0f3..4fc8014c3db2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import ExploreTree from './ExploreTree'; jest.mock('react-router-dom', () => ({ @@ -22,7 +22,7 @@ jest.mock('react-router-dom', () => ({ describe('ExploreTree', () => { it('renders the correct tree nodes', async () => { const { getByText, queryByTestId } = render( - + ); // Wait for loader to disappear @@ -39,4 +39,89 @@ describe('ExploreTree', () => { expect(getByText('label.ml-model-plural')).toBeInTheDocument(); expect(getByText('label.governance')).toBeInTheDocument(); }); + + it('grays out categories that cannot contain the selected asset type', async () => { + const { getByText, queryByTestId } = render( + + ); + + await waitFor(() => { + expect(queryByTestId('loader')).not.toBeInTheDocument(); + }); + + const databaseNode = getByText('label.database-plural').closest( + '.ant-tree-treenode' + ); + const dashboardNode = getByText('label.dashboard-plural').closest( + '.ant-tree-treenode' + ); + + expect(databaseNode).not.toHaveClass('ant-tree-treenode-disabled'); + expect(dashboardNode).toHaveClass('ant-tree-treenode-disabled'); + }); + + it('reports a category-root click as a browse selection, not a quick filter', async () => { + const onTreeSelect = jest.fn(); + const onFieldValueSelect = jest.fn(); + const { getByText, queryByTestId } = render( + + ); + + await waitFor(() => { + expect(queryByTestId('loader')).not.toBeInTheDocument(); + }); + + fireEvent.click(getByText('label.database-plural')); + + expect(onFieldValueSelect).not.toHaveBeenCalled(); + expect(onTreeSelect).toHaveBeenCalledTimes(1); + + const { browseFields, typeField } = onTreeSelect.mock.calls[0][0]; + + expect(typeField).toBeUndefined(); + expect(browseFields).toHaveLength(1); + expect(browseFields[0].key).toBe('entityType'); + expect(browseFields[0].label).toBe('label.database-plural'); + expect(browseFields[0].value.map((v: { key: string }) => v.key)).toContain( + 'table' + ); + }); + + it('keeps static governance leaves on the quick-filter path', async () => { + const onTreeSelect = jest.fn(); + const onFieldValueSelect = jest.fn(); + const { getByText, queryByTestId } = render( + + ); + + await waitFor(() => { + expect(queryByTestId('loader')).not.toBeInTheDocument(); + }); + + const governanceSwitcher = getByText('label.governance') + .closest('.ant-tree-treenode') + ?.querySelector('.ant-tree-switcher'); + fireEvent.click(governanceSwitcher as Element); + + fireEvent.click(getByText('label.glossary-plural')); + + expect(onTreeSelect).not.toHaveBeenCalled(); + expect(onFieldValueSelect).toHaveBeenCalledTimes(1); + + const fields = onFieldValueSelect.mock.calls[0][0]; + + expect(fields).toHaveLength(1); + expect(fields[0].key).toBe('entityType'); + expect(fields[0].value[0].key).toBe('glossaryTerm'); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx index 26bb41f301ed..b33bed8d4db5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx @@ -30,11 +30,15 @@ import { searchQuery } from '../../../rest/searchAPI'; import { getCountBadge } from '../../../utils/EntityDisplayUtils'; import { getPluralizeEntityName } from '../../../utils/EntityNameUtils'; import entityUtilClassBase from '../../../utils/EntityUtilClassBase'; +import { getExploreAssetIcon } from '../../../utils/ExploreIconUtils'; import { + findTreeNodeKeyByBrowsePath, getAggregations, + getDisabledExploreTreeKeys, getQuickFilterObject, getQuickFilterObjectForEntities, getSubLevelHierarchyKey, + parseBrowsePathFields, updateTreeData, updateTreeDataWithCounts, } from '../../../utils/ExplorePureUtils'; @@ -66,7 +70,10 @@ const ExploreTreeTitle = ({ node }: { node: ExploreTreeNode }) => { )} }> -
+
{ ); }; -const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { +const ExploreTree = ({ + onFieldValueSelect, + onTreeSelect, + selectedEntityTypes = [], +}: ExploreTreeProps) => { const hasFetchedRef = useRef(false); // Use a ref to track if we've already fetched, in dev mode as it will fetch twice + const hadBrowsePathRef = useRef(false); const { t } = useTranslation(); const { tab } = useRequiredParams(); const initTreeData = searchClassBase.getExploreTree(); @@ -97,6 +109,8 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { return searchClassBase.getExploreTreeKey(tab as ExplorePageTabs); }, [tab]); + const [expandedKeys, setExpandedKeys] = useState(defaultExpandedKeys); + const [parsedSearch, searchQueryParam, defaultServiceType] = useMemo(() => { const parsedSearch = Qs.parse( location.search.startsWith('?') @@ -116,7 +130,7 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { const onLoadData: TreeProps['loadData'] = useCallback( async (treeNode: Parameters>[0]) => { try { - if (treeNode.children) { + if (treeNode.children || (treeNode as ExploreTreeNode).disabled) { return; } @@ -159,7 +173,7 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { }); const aggregations = getAggregations(res.aggregations); - const buckets = aggregations[bucketToFind].buckets.filter( + const buckets = (aggregations[bucketToFind]?.buckets ?? []).filter( (item) => !searchClassBase .notIncludeAggregationExploreTree() @@ -178,10 +192,11 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { let logo = undefined; if (isEntityType) { const isColumn = bucket.key === EntityType.TABLE_COLUMN; - logo = searchClassBase.getEntityIcon( - bucket.key, - classNames('service-icon w-4 h-4', { 'text-grey-500': isColumn }) - ) ?? <>; + const iconClass = classNames('service-icon w-4 h-4', { + 'text-grey-500': isColumn, + }); + logo = getExploreAssetIcon(bucket.key, iconClass) ?? + searchClassBase.getEntityIcon(bucket.key, iconClass) ?? <>; } else if (isServiceType) { const serviceIcon = serviceUtilClassBase.getServiceLogo(bucket.key); logo = ( @@ -193,15 +208,14 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { ); } else if (bucketToFind === EntityFields.DATABASE_DISPLAY_NAME) { type = 'Database'; - logo = searchClassBase.getEntityIcon( - 'database', - 'service-icon w-4 h-4' - ) ?? <>; + logo = getExploreAssetIcon('database', 'service-icon w-4 h-4') ?? ( + <> + ); } else if ( bucketToFind === EntityFields.DATABASE_SCHEMA_DISPLAY_NAME ) { type = 'Database Schema'; - logo = searchClassBase.getEntityIcon( + logo = getExploreAssetIcon( 'databaseSchema', 'service-icon w-4 h-4' ) ?? <>; @@ -223,7 +237,7 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { <>{bucket.key} ), tooltip: formattedEntityType, - count: isEntityType ? bucket.doc_count : undefined, + count: bucket.doc_count, key: id, type, icon: logo, @@ -267,27 +281,42 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { const node = info.node as ExploreTreeNode; const filterField = node.data?.filterField; if (filterField) { - onFieldValueSelect(filterField); + if (node.isLeaf) { + // Entity-type leaves refine the Type filter; the levels above them + // are the browse location. Both travel together so the page can + // update browsePath and quickFilter in a single navigation. + onTreeSelect({ + browseFields: filterField.slice(0, -1), + typeField: filterField[filterField.length - 1], + }); + } else { + onTreeSelect({ browseFields: filterField }); + } } else if (node.isLeaf) { - const filterField = [ + onFieldValueSelect([ getQuickFilterObject( EntityFields.ENTITY_TYPE, node.data?.entityType ?? '' ), - ]; - onFieldValueSelect(filterField); + ]); } else if (node.data?.childEntities) { - onFieldValueSelect([ - getQuickFilterObjectForEntities( + const categoryField = { + ...getQuickFilterObjectForEntities( EntityFields.ENTITY_TYPE, node.data?.childEntities as EntityType[] ), - ]); + // The chip for a category root reads "In " — keep the + // human title, the key alone only says "entityType". + label: isString(node.title) + ? node.title + : EntityFields.ENTITY_TYPE.toString(), + }; + onTreeSelect({ browseFields: [categoryField] }); } setSelectedKeys([node.key]); }, - [onFieldValueSelect] + [onFieldValueSelect, onTreeSelect] ); const fetchEntityCounts = useCallback(async () => { @@ -327,12 +356,50 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { }, []); useEffect(() => { - // Tree works on the quickFilter, so we need to reset the selectedKeys when the quickFilter is empty - if (isEmpty(parsedSearch.quickFilter)) { + // Hierarchical selections live in browsePath, static leaves in quickFilter + // — only clear the highlight when neither is active. + if (isEmpty(parsedSearch.quickFilter) && isEmpty(parsedSearch.browsePath)) { setSelectedKeys([]); } }, [parsedSearch]); + useEffect(() => { + // Keep the highlight in sync with the browse-path chips: removing the + // Service chip moves the selection back up to the matching ancestor + // (e.g. the category root), and removing the last browse chip clears it. + const browseFields = parseBrowsePathFields(parsedSearch.browsePath); + if (!isEmpty(browseFields)) { + const matchedKey = findTreeNodeKeyByBrowsePath(treeData, browseFields); + setSelectedKeys(matchedKey ? [matchedKey] : []); + hadBrowsePathRef.current = true; + } else if (hadBrowsePathRef.current) { + hadBrowsePathRef.current = false; + setSelectedKeys([]); + } + }, [parsedSearch.browsePath, treeData]); + + // Top-level categories that cannot hold the selected asset type are grayed + // out so the user can't browse into services that won't contain it. + const disabledRootKeys = useMemo( + () => getDisabledExploreTreeKeys(treeData, selectedEntityTypes), + [treeData, selectedEntityTypes] + ); + + const displayTreeData = useMemo( + () => + treeData.map((node) => + disabledRootKeys.has(node.key) ? { ...node, disabled: true } : node + ), + [treeData, disabledRootKeys] + ); + + // Disabled categories also collapse — an expanded Databases subtree makes + // no sense once the selected asset type rules the whole category out. + const visibleExpandedKeys = useMemo( + () => expandedKeys.filter((key) => !disabledRootKeys.has(String(key))), + [expandedKeys, disabledRootKeys] + ); + if (isLoading) { return ; } @@ -378,14 +445,15 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { showIcon className="explore-tree" data-testid="explore-tree" - defaultExpandedKeys={defaultExpandedKeys} + expandedKeys={visibleExpandedKeys} loadData={onLoadData} selectedKeys={selectedKeys} switcherIcon={switcherIcon} titleRender={(node) => ( )} - treeData={treeData as DataNode[]} + treeData={displayTreeData as DataNode[]} + onExpand={(keys) => setExpandedKeys(keys)} onSelect={onNodeSelect} /> ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/explore-tree.less b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/explore-tree.less index a5debafed01a..88a432304ad9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/explore-tree.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/explore-tree.less @@ -27,4 +27,23 @@ height: 16px; } } + + // Deep levels squeeze the label column — ellipsize on one line instead of + // wrapping mid-word, and keep the count pill beside the label. + .ant-tree-title { + min-width: 0; + + [data-testid^='explore-tree-title-'] { + display: block; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .explore-node-count { + flex: none; + } + } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx index bb8c59bbe55f..9b4fef6ebca0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx @@ -148,6 +148,37 @@ describe('ExploreSearchCard - Domain section', () => { }); }); +describe('ExploreSearchCard - Card container', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('always carries the base explore-search-card class', () => { + renderCard({ fullyQualifiedName: 'svc.db.schema.users' }); + + expect( + screen.getByTestId('table-data-card_svc.db.schema.users') + ).toHaveClass('explore-search-card'); + }); + + it('applies the selected highlight-card class passed by SearchedData', () => { + renderWithQueryClient( + + + + ); + + const card = screen.getByTestId('table-data-card_svc.db.schema.users'); + + expect(card).toHaveClass('explore-search-card'); + expect(card).toHaveClass('highlight-card'); + }); +}); + describe('ExploreSearchCard - Highlight functionality', () => { const { highlightEntityNameAndDescription } = jest.requireMock( '../../../utils/EntitySearchUtils' diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index f47430639519..569c29b47e4e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -11,12 +11,13 @@ * limitations under the License. */ import Icon from '@ant-design/icons'; +import { Card } from '@openmetadata/ui-core-components'; import { useQueryClient } from '@tanstack/react-query'; import { Button, Checkbox, Col, Row, Space, Typography } from 'antd'; import classNames from 'classnames'; import { isEmpty, isObject, isString, startCase, uniqueId } from 'lodash'; import { ExtraInfo } from 'Models'; -import { forwardRef, useCallback, useMemo } from 'react'; +import { forwardRef, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as ScoreIcon } from '../../../assets/svg/score.svg'; @@ -29,7 +30,6 @@ import { } from '../../../generated/entity/data/glossaryTerm'; import { Table } from '../../../generated/entity/data/table'; import { EntityReference } from '../../../generated/entity/type'; -import { TagLabel } from '../../../generated/tests/testCase'; import { AssetCertification } from '../../../generated/type/assetCertification'; import { TableColumnSearchSource } from '../../../interface/search.interface'; import { prefetchDashboard } from '../../../rest/queries/dashboardQuery'; @@ -38,6 +38,7 @@ import { prefetchTable } from '../../../rest/queries/tableQuery'; import { prefetchTopic } from '../../../rest/queries/topicQuery'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { highlightEntityNameAndDescription } from '../../../utils/EntitySearchUtils'; +import { getExploreAssetIcon } from '../../../utils/ExploreIconUtils'; import searchClassBase from '../../../utils/SearchClassBase'; import { stringToHTML } from '../../../utils/StringUtils'; import { getUsagePercentile } from '../../../utils/TablePureUtils'; @@ -171,7 +172,7 @@ const ExploreSearchCard: React.FC = forwardRef< ), @@ -183,10 +184,7 @@ const ExploreSearchCard: React.FC = forwardRef< const tierValue = isString(source.tier) ? source.tier : source.tier && ( - + ); const shouldShowDomainField = !searchClassBase @@ -258,11 +256,37 @@ const ExploreSearchCard: React.FC = forwardRef< searchClassBase.getEntityBreadcrumbs( source, source.entityType as EntityType, - true + false ), [source] ); + // Long paths collapse to "first / … / last"; clicking the ellipsis + // reveals the full path. Keeps every card a single breadcrumb line. + const [isBreadcrumbExpanded, setIsBreadcrumbExpanded] = useState(false); + const displayBreadcrumbs = useMemo(() => { + if (isBreadcrumbExpanded || breadcrumbs.length <= 3) { + return breadcrumbs; + } + + return [ + breadcrumbs[0], + { name: '…', url: '' }, + breadcrumbs[breadcrumbs.length - 1], + ]; + }, [breadcrumbs, isBreadcrumbExpanded]); + + const handleBreadcrumbEllipsisClick = useCallback( + (event: React.MouseEvent) => { + if ((event.target as HTMLElement).textContent === '…') { + event.preventDefault(); + event.stopPropagation(); + setIsBreadcrumbExpanded(true); + } + }, + [] + ); + const entityIcon = useMemo(() => { if (showEntityIcon) { if (source.entityType === 'glossaryTerm') { @@ -284,10 +308,11 @@ const ExploreSearchCard: React.FC = forwardRef< return ( - {searchClassBase.getEntityIcon( - source.entityType ?? '', - 'text-link-color' - )} + {getExploreAssetIcon(source.entityType ?? '', 'text-link-color') ?? + searchClassBase.getEntityIcon( + source.entityType ?? '', + 'text-link-color' + )} ); } @@ -328,10 +353,13 @@ const ExploreSearchCard: React.FC = forwardRef<
{breadcrumbs.length > 0 && serviceIcon} -
+
@@ -422,7 +450,7 @@ const ExploreSearchCard: React.FC = forwardRef< ]); return ( -
= forwardRef< {actionPopoverContent && ( {actionPopoverContent} )} -
+ ); } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/explore-search-card.less b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/explore-search-card.less index 941ad23f03f7..c3d0de28d2e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/explore-search-card.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/explore-search-card.less @@ -13,22 +13,63 @@ @import '../../../styles/variables.less'; .explore-search-card { - padding: 20px; + padding: 12px 16px; position: relative; - border-radius: 12px; - border-left: 4px solid transparent; - opacity: 0.95; - background-color: @grey-9 !important; - border: 0.5px solid #eaecf5; - margin: 0 0 16px; + border-radius: 10px; + background-color: @white; + border: 1px solid @border-color; + margin: 0 0 10px; overflow: hidden; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, + background-color 0.2s ease; .no-owner-text { font-size: @size-sm; font-weight: @font-regular; } + + // Density: the design fits ~5 cards per viewport — compact title, + // single-line breadcrumb, tight description and meta rows. + .entity-breadcrumb { + font-size: 12px; + white-space: nowrap; + overflow: hidden; + + .ant-breadcrumb { + font-size: 12px; + } + } + + [data-testid='entity-header-display-name'] { + font-size: 15px; + line-height: 20px; + } + + .p-t-sm { + padding-top: 4px; + } + + .description-text { + margin-bottom: 6px; + font-size: 13px; + + .ant-typography { + margin-bottom: 0; + font-size: 13px; + } + } + + &:hover { + border-color: @primary-1; + box-shadow: 0 1px 3px rgba(10, 13, 18, 0.1); + } + + // Selection highlights the box only — clean blue border on a white card, + // no left accent bar and no tinted fill (matches the redesign). The widths + // are explicit so no legacy left-accent rule can thicken one edge. &.highlight-card { - border-left: 4px solid @primary-color; - background: @primary-50 !important; + border: 1px solid @primary-color; + background-color: @white; } &:last-child { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx index 6408b83bb36a..1ec5a50c5a91 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx @@ -36,6 +36,7 @@ import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAdvanceSearch } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import AppliedFilterText from '../../components/Explore/AppliedFilterText/AppliedFilterText'; +import ExploreQueryFilterChips from '../../components/Explore/ExploreQueryFilterChips/ExploreQueryFilterChips.component'; import ExploreQuickFilters from '../../components/Explore/ExploreQuickFilters'; import SortingDropDown from '../../components/Explore/SortingDropDown'; import { @@ -43,6 +44,7 @@ import { SUPPORTED_EMPTY_FILTER_FIELDS, TAG_FQN_KEY, } from '../../constants/explore.constants'; +import { EntityFields } from '../../enums/AdvancedSearch.enum'; import { SIZE, SORT_ORDER } from '../../enums/common.enum'; import { EntityType } from '../../enums/entity.enum'; import { SearchIndex } from '../../enums/search.enum'; @@ -58,6 +60,7 @@ import { getCombinedQueryFilterObject } from '../../utils/ExplorePage/ExplorePag import { getExploreQueryFilterMust, getSelectedValuesFromQuickFilter, + truncateBrowsePath, } from '../../utils/ExplorePureUtils'; import searchClassBase from '../../utils/SearchClassBase'; import withSuspenseFallback from '../AppRouter/withSuspenseFallback'; @@ -105,6 +108,9 @@ const ExploreV1: React.FC = ({ loading, quickFilters, isElasticSearchIssue, + browseFields = [], + browseQueryFilter, + onTreeSelect = noop, }) => { const tabsInfo = searchClassBase.getTabsInfo(); const { t } = useTranslation(); @@ -194,7 +200,8 @@ const ExploreV1: React.FC = ({ try { const combinedQueryFilter = getCombinedQueryFilterObject( quickFilters, - queryFilter as QueryFilterInterface | undefined + queryFilter as QueryFilterInterface | undefined, + browseQueryFilter ); const allResponse = await searchQuery({ query: searchQueryParam || '*', @@ -222,7 +229,14 @@ const ExploreV1: React.FC = ({ } finally { setIsCountLoading(false); } - }, [searchQueryParam, showDeleted, quickFilters, queryFilter, searchIndex]); + }, [ + searchQueryParam, + showDeleted, + quickFilters, + queryFilter, + browseQueryFilter, + searchIndex, + ]); const handleExportScopeConfirm = useCallback(async () => { if (isAllAssetsLimitExceeded) { @@ -232,7 +246,8 @@ const ExploreV1: React.FC = ({ const isVisibleScope = exportScope === 'visible'; const combinedQueryFilter = getCombinedQueryFilterObject( quickFilters, - queryFilter as QueryFilterInterface | undefined + queryFilter as QueryFilterInterface | undefined, + browseQueryFilter ); let exportSize = allAssetsCount ?? EXPORT_ALL_ASSETS_LIMIT; @@ -307,6 +322,7 @@ const ExploreV1: React.FC = ({ showDeleted, quickFilters, queryFilter, + browseQueryFilter, isAllAssetsLimitExceeded, ]); @@ -384,6 +400,99 @@ const ExploreV1: React.FC = ({ }); }; + const handleRemoveQuickFilterValue = ( + field: ExploreQuickFilterField, + optionKey: string + ) => { + const updatedValue = (field.value ?? []).filter( + (option) => option.key !== optionKey + ); + handleQuickFiltersValueSelect({ ...field, value: updatedValue }); + }; + + // Tree selection: hierarchical levels update the browse location; a leaf + // additionally sets the Type quick filter. The type is upserted into the + // Data Assets slot (entityType.keyword) so existing dropdown filters + // survive, and both URL params change in one navigation upstream. + const handleExploreTreeSelect = useCallback( + (payload: { + browseFields: ExploreQuickFilterField[]; + typeField?: ExploreQuickFilterField; + }) => { + const { browseFields: updatedBrowseFields, typeField } = payload; + if (isUndefined(typeField)) { + onTreeSelect({ browseFields: updatedBrowseFields }); + } else { + // The Data Assets dropdown options come from the entityType.keyword + // aggregation, which returns lowercase values ("tablecolumn"); tree + // leaf buckets are camelCase ("tableColumn"). Store lowercase so the + // dropdown recognizes the selection. + const typeValue = (typeField.value ?? []).map((option) => ({ + ...option, + key: option.key.toLowerCase(), + })); + const hasTypeSlot = selectedQuickFilters.some( + (field) => field.key === EntityFields.ENTITY_TYPE_KEYWORD + ); + const merged = hasTypeSlot + ? selectedQuickFilters.map((field) => + field.key === EntityFields.ENTITY_TYPE_KEYWORD + ? { ...field, value: typeValue } + : field + ) + : [ + ...selectedQuickFilters, + { + key: EntityFields.ENTITY_TYPE_KEYWORD, + label: 'label.data-asset-plural', + value: typeValue, + }, + ]; + + setSelectedQuickFilters(merged); + + const must = getExploreQueryFilterMust(merged); + onTreeSelect({ + browseFields: updatedBrowseFields, + quickFilter: isEmpty(must) + ? undefined + : { query: { bool: { must } } }, + }); + } + }, + [onTreeSelect, selectedQuickFilters] + ); + + const handleRemoveBrowseLevel = useCallback( + (levelKey: string) => { + onTreeSelect({ + browseFields: truncateBrowsePath(browseFields, levelKey), + }); + }, + [onTreeSelect, browseFields] + ); + + const hasQuickFilterValues = useMemo( + () => selectedQuickFilters.some((field) => !isEmpty(field.value)), + [selectedQuickFilters] + ); + + const selectedEntityTypes = useMemo(() => { + const entityTypeField = selectedQuickFilters.find( + (field) => + field.key === EntityFields.ENTITY_TYPE_KEYWORD || + field.key === EntityFields.ENTITY_TYPE + ); + const browseEntityTypeField = browseFields.find( + (field) => field.key === EntityFields.ENTITY_TYPE + ); + + return [ + ...(entityTypeField?.value ?? []), + ...(browseEntityTypeField?.value ?? []), + ].map((option) => option.key); + }, [selectedQuickFilters, browseFields]); + const exploreLeftPanel = useMemo(() => { if (tabItems.length === 0) { return loading ? ( @@ -415,14 +524,22 @@ const ExploreV1: React.FC = ({ ); } - return ; + return ( + + ); }, [ searchQueryParam, tabItems, handleQuickFiltersChange, + handleExploreTreeSelect, activeTabKey, loading, onChangeSearchIndex, + selectedEntityTypes, ]); useEffect(() => { @@ -526,10 +643,15 @@ const ExploreV1: React.FC = ({ + } fields={selectedQuickFilters} fieldsWithNullValues={SUPPORTED_EMPTY_FILTER_FIELDS} + helperText={t('message.pick-values-to-refine')} index={activeTabKey} showDeleted={showDeleted} onAdvanceSearch={() => toggleModal(true)} @@ -566,7 +688,7 @@ const ExploreV1: React.FC = ({ - {(quickFilters || sqlQuery) && ( + {(hasQuickFilterValues || !isEmpty(browseFields) || sqlQuery) && ( = ({ + {(hasQuickFilterValues || + !isEmpty(browseFields) || + !searchQueryParam) && ( + + + + )} {isElasticSearchIssue ? ( @@ -640,7 +780,7 @@ const ExploreV1: React.FC = ({ className: 'content-resizable-panel-container', flex: 0.2, minWidth: 280, - title: t('label.data-asset-plural'), + title: t('label.browse-estate'), children:
{exploreLeftPanel}
, }} secondPanel={{ @@ -652,6 +792,7 @@ const ExploreV1: React.FC = ({ {!loading && !isElasticSearchIssue ? ( { jest.mock('@untitledui/icons', () => ({ ChevronDown: () => ChevronDown, Download01: () => , + FilterFunnel01: () => , + Trash01: () => , + XCircle: () => , + XClose: () => , })); jest.mock('../../rest/searchAPI', () => ({ @@ -445,6 +449,30 @@ describe('ExploreV1', () => { expect(screen.getByText('ExploreTree')).toBeInTheDocument(); }); + it('does not render the toolbar Clear All when no filters are active', () => { + render(, { wrapper: Wrapper }); + + expect(screen.queryByTestId('clear-filters')).not.toBeInTheDocument(); + }); + + it('renders the toolbar Clear All (clear-filters) when a browse filter is active', () => { + render( + , + { wrapper: Wrapper } + ); + + expect(screen.getByTestId('clear-filters')).toBeInTheDocument(); + }); + it('changes sort order when sort button is clicked', () => { render(, { wrapper: Wrapper }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts index fe47c4870c59..b61237207061 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts @@ -34,6 +34,12 @@ export interface SearchDropdownProps { showSelectedCounts?: boolean; // Show counts instead of labels for selected items hideSearchBar?: boolean; // Determines if the search bar should be hidden. Default is false singleSelect?: boolean; // Enable single-select mode with radio buttons instead of checkboxes + // When true, every selection is applied to the query immediately (no Update button). + // The dropdown stays open for multi-select and closes after a single-select pick. + immediateApply?: boolean; + // Helper text shown at the bottom of the dropdown (e.g. "Pick values to refine."). + // Replaces the Update/Close footer when immediateApply is enabled. + helperText?: string; getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx index 9518260196e9..d152eb9ea91c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx @@ -405,6 +405,89 @@ describe('Search DropDown Component', () => { ); }); + describe('Immediate apply mode', () => { + it('does not render the Update/Close footer when immediateApply is set', async () => { + render(); + + const container = await screen.findByTestId('search-dropdown-Owner'); + + await act(async () => { + fireEvent.click(container); + }); + + expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument(); + expect(screen.queryByTestId('update-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('close-btn')).not.toBeInTheDocument(); + }); + + it('applies each selection immediately without clicking Update', async () => { + mockOnChange.mockClear(); + + render(); + + const container = await screen.findByTestId('search-dropdown-Owner'); + + await act(async () => { + fireEvent.click(container); + }); + + const option2 = await screen.findByTestId('User 2'); + + await act(async () => { + fireEvent.click(option2); + }); + + expect(mockOnChange).toHaveBeenCalledWith( + [ + { key: 'User 1', label: 'User 1' }, + { key: 'User 2', label: 'User 2' }, + ], + 'owner.displayName' + ); + }); + + it('keeps a selected option visible even when missing from fetched options', async () => { + // Immediate-apply facets exclude their own field from the aggregation, + // so a selected value may fall outside the fetched top-N options. + render( + + ); + + const container = await screen.findByTestId('search-dropdown-Owner'); + + await act(async () => { + fireEvent.click(container); + }); + + expect(await screen.findByTestId('glossaryterm')).toBeInTheDocument(); + expect(await screen.findByTestId('glossaryterm-checkbox')).toBeChecked(); + }); + + it('renders the helper text in immediateApply mode', async () => { + render( + + ); + + const container = await screen.findByTestId('search-dropdown-Owner'); + + await act(async () => { + fireEvent.click(container); + }); + + expect( + await screen.findByTestId('search-dropdown-helper-text') + ).toHaveTextContent('Pick values to refine.'); + }); + }); + describe('Single Select Mode', () => { it('should allow only one option to be selected at a time', async () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx index 80b5e184e848..dee9fc17c3cf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx @@ -73,6 +73,8 @@ const SearchDropdown: FC = ({ triggerButtonSize = 'small', hideSearchBar = false, singleSelect = false, + immediateApply = false, + helperText, getPopupContainer, }) => { const tabsInfo = searchClassBase.getTabsInfo(); @@ -89,12 +91,17 @@ const SearchDropdown: FC = ({ // derive menu props from options and selected keys const menuOptions: MenuProps['items'] = useMemo(() => { - // Filtering out selected options - const selectedOptionsObj = independent - ? selectedOptions - : options.filter((option) => - selectedOptions.some((selectedOpt) => option.key === selectedOpt.key) - ); + // Filtering out selected options. Immediate-apply facets exclude their + // own field from the aggregation, so a selected value may not be in the + // fetched options — keep it visible (and uncheckable) regardless. + const selectedOptionsObj = + independent || immediateApply + ? selectedOptions + : options.filter((option) => + selectedOptions.some( + (selectedOpt) => option.key === selectedOpt.key + ) + ); if (fixedOrderOptions) { return options.map((item) => ({ @@ -144,6 +151,7 @@ const SearchDropdown: FC = ({ selectedOptions, fixedOrderOptions, independent, + immediateApply, searchText, hideCounts, singleSelect, @@ -151,34 +159,70 @@ const SearchDropdown: FC = ({ highlight, ]); + // Handle dropdown close + const handleDropdownClose = () => { + setIsDropDownOpen(false); + }; + + // Build the onChange payload (prepending the null option when selected) and emit it + const emitChange = ( + updatedOptions: SearchDropdownOption[], + isNullSelected: boolean + ) => { + if (isNullSelected) { + onChange( + [ + { key: NULL_OPTION_KEY, label: nullLabelText }, + ...(singleSelect ? [] : updatedOptions), + ], + searchKey + ); + } else { + onChange(updatedOptions, searchKey); + } + }; + // handle menu item click const handleMenuItemClick: MenuItemProps['onClick'] = (info) => { const currentKey = info.key; const option = options.find((op) => op.key === currentKey); + let updatedValues: SearchDropdownOption[]; + let updatedNullSelected = nullOptionSelected; if (singleSelect) { const isAlreadySelected = selectedOptions.some( (opt) => opt.key === currentKey ); - const updatedValues = !isAlreadySelected && option ? [option] : []; - setSelectedOptions(updatedValues); + updatedValues = !isAlreadySelected && option ? [option] : []; if (!isAlreadySelected && option) { + updatedNullSelected = false; setNullOptionSelected(false); } } else { const isAlreadySelected = selectedOptions.some( - (option) => option.key === currentKey + (opt) => opt.key === currentKey ); - const updatedValues = isAlreadySelected - ? selectedOptions.filter((option) => option.key !== currentKey) + updatedValues = isAlreadySelected + ? selectedOptions.filter((opt) => opt.key !== currentKey) : [...selectedOptions, ...(option ? [option] : [])]; - setSelectedOptions(updatedValues); + } + + setSelectedOptions(updatedValues); + + if (immediateApply) { + emitChange(updatedValues, updatedNullSelected); + if (singleSelect) { + handleDropdownClose(); + } } }; // handle clear all const handleClear = () => { setSelectedOptions([]); + if (immediateApply) { + emitChange([], nullOptionSelected); + } }; // handle search @@ -189,39 +233,29 @@ const SearchDropdown: FC = ({ const debouncedOnSearch = debounce(handleSearch, 500); - // Handle dropdown close - const handleDropdownClose = () => { - setIsDropDownOpen(false); - }; - // Handle null option change const handleNullOptionChange = (checked: boolean) => { setNullOptionSelected(checked); + let updatedValues = selectedOptions; if (singleSelect && checked) { + updatedValues = []; setSelectedOptions([]); } + if (immediateApply) { + emitChange(updatedValues, checked); + } }; // Handle update button click const handleUpdate = () => { - // call on change with updated value - if (nullOptionSelected) { - onChange( - [ - { key: NULL_OPTION_KEY, label: nullLabelText }, - ...(singleSelect ? [] : selectedOptions), - ], - searchKey - ); - } else { - onChange(selectedOptions, searchKey); - } + emitChange(selectedOptions, nullOptionSelected); handleDropdownClose(); }; + // In immediate-apply mode the QUERY bar owns clearing. const showClearAllBtn = useMemo( - () => !singleSelect && selectedOptions.length > 1, - [singleSelect, selectedOptions] + () => !singleSelect && selectedOptions.length > 1 && !immediateApply, + [singleSelect, selectedOptions, immediateApply] ); useEffect(() => { @@ -274,7 +308,9 @@ const SearchDropdown: FC = ({ (menuNode: ReactNode) => ( {!hideSearchBar && ( @@ -339,22 +375,34 @@ const SearchDropdown: FC = ({ )} {getDropdownBody(menuNode)} - - - - + {immediateApply ? ( + helperText ? ( +
+ + {helperText} + +
+ ) : null + ) : ( + + + + + )}
), @@ -373,6 +421,8 @@ const SearchDropdown: FC = ({ handleUpdate, handleDropdownClose, hideSearchBar, + immediateApply, + helperText, ] ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/search-dropdown.less b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/search-dropdown.less index bb1bb5afa3ed..5a01bd7cc6e1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/search-dropdown.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/search-dropdown.less @@ -74,3 +74,53 @@ color: @grey-600; } } + +// Compact menu for the Explore immediate-apply mode: rounded boxes that show +// a "+" when selected, plain right-aligned counts, tighter rows. +.immediate-apply-dropdown { + .ant-dropdown-menu { + box-shadow: none; + padding: 4px; + } + + .ant-dropdown-menu-item { + border-radius: 8px; + padding: 6px 8px; + } + + .ant-checkbox-inner { + width: 18px; + height: 18px; + border-radius: 6px; + } + + .ant-checkbox-checked .ant-checkbox-inner { + background-color: @primary-color; + border-color: @primary-color; + + &::after { + border: none; + width: auto; + height: auto; + top: 50%; + left: 50%; + transform: translate(-50%, -54%); + content: '+'; + color: @white; + font-size: 14px; + font-weight: 600; + line-height: 1; + } + } + + .global-border.rounded-4 { + border: none; + background: transparent; + color: @grey-4; + font-weight: 600; + } + + .dropdown-option-label { + font-size: 13px; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.tsx index 72db87f44380..416554494509 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.tsx @@ -14,7 +14,7 @@ import classNames from 'classnames'; import { isNumber } from 'lodash'; import Qs from 'qs'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { MAX_RESULT_HITS } from '../../constants/explore.constants'; import { ELASTICSEARCH_ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; import { useCurrentUserPreferences } from '../../hooks/currentUserStore/useCurrentUserStore'; @@ -83,17 +83,23 @@ const SearchedData: React.FC = ({ selectedEntityId, ]); - const resultCount = useMemo(() => { - if (isFilterSelected || filter?.quickFilter) { - if (MAX_RESULT_HITS === totalValue) { - return
{`About ${totalValue} results`}
; + const ResultCount = useCallback( + (total: number) => { + if (!showResultCount) { + return null; + } + if (isFilterSelected || filter?.quickFilter) { + if (MAX_RESULT_HITS === total) { + return `~${total} results`; + } else { + return pluralize(total, 'result'); + } } else { - return
{pluralize(totalValue, 'result')}
; + return null; } - } else { - return null; - } - }, [isFilterSelected, filter, totalValue]); + }, + [isFilterSelected, filter, showResultCount] + ); const { page = 1, size = globalPageSize } = useMemo( () => @@ -114,10 +120,10 @@ const SearchedData: React.FC = ({ {totalValue > 0 ? ( <> {children} - {showResultCount ? resultCount : null}
{searchResultCards} = ({ ? Number(size) : globalPageSize } + showTotal={ResultCount} total={totalValue} onChange={onPaginationChange} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/ErrorWithPlaceholder/ErrorPlaceHolderES.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/ErrorWithPlaceholder/ErrorPlaceHolderES.tsx index fb5217fe0063..b5b04a67e1f3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/ErrorWithPlaceholder/ErrorPlaceHolderES.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/ErrorWithPlaceholder/ErrorPlaceHolderES.tsx @@ -72,7 +72,8 @@ const stepsData = [ ]; const ErrorPlaceHolderES = ({ type, errorMessage, query, size }: Props) => { - const { showDeleted, search, queryFilter, quickFilter } = query ?? {}; + const { showDeleted, search, queryFilter, quickFilter, browsePath } = + query ?? {}; const { tab } = useRequiredParams<{ tab: string }>(); const { t } = useTranslation(); const navigate = useNavigate(); @@ -81,8 +82,14 @@ const ErrorPlaceHolderES = ({ type, errorMessage, query, size }: Props) => { const isQuery = useMemo( () => - Boolean(search || queryFilter || quickFilter || showDeleted === 'true'), - [search, queryFilter, quickFilter, showDeleted] + Boolean( + search || + queryFilter || + quickFilter || + browsePath || + showDeleted === 'true' + ), + [search, queryFilter, quickFilter, browsePath, showDeleted] ); const noRecordForES = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 5be8d5b8860a..63d398a0b624 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -259,6 +259,7 @@ "browse": "استعراض", "browse-app-plural": "استعراض التطبيقات", "browse-csv-file": "استعراض ملف CSV", + "browse-estate": "استعراض المنظومة البيانية", "bulk-edit": "تعديل مجمع", "bulk-import-entity": "استيراد {{entity}} بالجملة", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "استيراد ODCS", "import-odcs-contract": "استيراد عقد ODCS", "import-om": "استيراد OM", + "in": "في", "in-last-number-of-days": "في آخر {{numberOfDays}} يوم", "in-lowercase": "في", "in-open-metadata": "في {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "الوقت المتوقع لتوافر البيانات", "available-on-global-view": "متاح في العرض الشامل", "bot-email-confirmation": "{{email}} لروبوت {{botName}}", + "browse-estate-query-placeholder": "استعراض منظومتك البيانية بالكامل — اختر فلترًا من الأعلى أو موقعًا من اليسار وستتراكم هنا.", "bulk-edit-entity-help": "تحرير عدة {{entity}} في وقت واحد باستخدام CSV", "bulk-import-completed": "اكتمل الاستيراد المجمع", "cache-service-not-configured-message": "قم بتكوين خدمة ذاكرة تخزين مؤقت مثل Redis أو ElastiCache وتأكد من إمكانية الوصول إليها لتفعيل تسخين الذاكرة المؤقتة.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "تم تحديث تفضيل الشخصية بنجاح!", "persona-updated-successfully": "تم تحديث الشخصية بنجاح.", "personal-access-token": "رمز الوصول الشخصي المميز", + "pick-values-to-refine": "اختر قيمًا للتنقيح. يبقى موقع الاستعراض في مكانه.", "pii-coverage-widget-description": "تغطية معلومات التعريف الشخصية (PII) لجميع أصول البيانات في الخدمة. <0>تعرف على المزيد.", "pii-distribution-description": "معلومات التعريف الشخصية (Personally Identifiable Information) هي معلومات يمكنها، عند استخدامها بمفردها أو مع بيانات أخرى ذات صلة، تحديد هوية فرد.", "pii-distribution-widget-description": "توزيع بيانات معلومات التعريف الشخصية (PII) لجميع أصول البيانات في الخدمة. <0>تعرف على المزيد.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 40fed6c8e1a1..417d04129522 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -259,6 +259,7 @@ "browse": "Durchsuchen", "browse-app-plural": "Apps durchsuchen", "browse-csv-file": "CSV-Datei durchsuchen", + "browse-estate": "Datenbestand durchsuchen", "bulk-edit": "Massenbearbeitung", "bulk-import-entity": "Massenimport von {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "ODCS importieren", "import-odcs-contract": "ODCS-Vertrag importieren", "import-om": "OM importieren", + "in": "In", "in-last-number-of-days": "In den letzten {{numberOfDays}} Tagen", "in-lowercase": "in", "in-open-metadata": "In {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Tageszeit, zu der die Daten erwartungsgemäß verfügbar sein sollen", "available-on-global-view": "In der globalen Ansicht verfügbar", "bot-email-confirmation": "{{email}} für den {{botName}}-Bot", + "browse-estate-query-placeholder": "Durchsuchen Sie Ihren gesamten Datenbestand — wählen Sie oben einen Filter oder links einen Ort aus und sie werden hier gestapelt.", "bulk-edit-entity-help": "Mehrere {{entity}} gleichzeitig mit CSV bearbeiten", "bulk-import-completed": "Massenimport Abgeschlossen", "cache-service-not-configured-message": "Konfigurieren Sie einen Cache-Dienst wie Redis oder ElastiCache und stellen Sie sicher, dass er erreichbar ist, um das Cache-Warmup zu aktivieren.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Persona-Einstellung erfolgreich aktualisiert!", "persona-updated-successfully": "Persona erfolgreich aktualisiert.", "personal-access-token": "Persönlicher Zugriffstoken", + "pick-values-to-refine": "Wählen Sie Werte zum Verfeinern aus. Ihr Suchort bleibt unverändert.", "pii-coverage-widget-description": "PII-Abdeckung für alle Datenvermögenswerte im Dienst. <0>Mehr erfahren.", "pii-distribution-description": "Persönlich identifizierbare Informationen, die allein oder mit anderen relevanten Daten verwendet werden können, um eine Person zu identifizieren.", "pii-distribution-widget-description": "PII-Datenverteilung für alle Datenvermögenswerte im Dienst. <0>Mehr erfahren.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 7eb64876de9d..1b6d6955d16f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -259,6 +259,7 @@ "browse": "Browse", "browse-app-plural": "Browse Apps", "browse-csv-file": "Browse CSV file", + "browse-estate": "Browse estate", "bulk-edit": "Bulk Edit", "bulk-import-entity": "Bulk Import {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Import ODCS", "import-odcs-contract": "Import ODCS Contract", "import-om": "Import OM", + "in": "In", "in-last-number-of-days": "In last {{numberOfDays}} days", "in-lowercase": "in", "in-open-metadata": "In {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Time of day by which data is expected to be available", "available-on-global-view": "Available on the global view", "bot-email-confirmation": "{{email}} for {{botName}} bot", + "browse-estate-query-placeholder": "Browsing your whole data estate — pick a filter above or a location on the left and they stack here.", "bulk-edit-entity-help": "Edit multiple {{entity}} at once using CSV", "bulk-import-completed": "Bulk Import Completed", "cache-service-not-configured-message": "Configure a Cache Service such as Redis or ElastiCache and make sure it is reachable to enable Cache Warmup.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Persona preference updated successfully!", "persona-updated-successfully": "Persona updated successfully.", "personal-access-token": "Personal Access Token", + "pick-values-to-refine": "Pick values to refine. Your browse location stays put.", "pii-coverage-widget-description": "PII coverage for all data assets in the service. <0>learn more.", "pii-distribution-description": "Personally Identifiable Information information that, when used alone or with other relevant data, can identify an individual.", "pii-distribution-widget-description": "PII data distribution for all data assets in the service. <0>learn more.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index d4fbb125913b..c91b500c433d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -259,6 +259,7 @@ "browse": "Explorar", "browse-app-plural": "Explorar aplicaciones", "browse-csv-file": "Examinar archivo CSV", + "browse-estate": "Explorar el patrimonio", "bulk-edit": "Edición masiva", "bulk-import-entity": "Importación masiva de {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importar desde ODCS", "import-odcs-contract": "Importar contrato ODCS", "import-om": "Importar OM", + "in": "En", "in-last-number-of-days": "En los últimos {{numberOfDays}} días", "in-lowercase": "en", "in-open-metadata": "En {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Momento del día en el que se espera que los datos estén disponibles", "available-on-global-view": "Disponible en la vista global", "bot-email-confirmation": "{{email}} para el bot {{botName}}", + "browse-estate-query-placeholder": "Explorando todo su patrimonio de datos — seleccione un filtro arriba o una ubicación a la izquierda y se acumularán aquí.", "bulk-edit-entity-help": "Editar múltiples {{entity}} a la vez usando CSV", "bulk-import-completed": "Importación Masiva Completada", "cache-service-not-configured-message": "Configura un servicio de caché como Redis o ElastiCache y asegúrate de que sea accesible para habilitar el calentamiento de caché.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Preferencia de persona actualizada exitosamente!", "persona-updated-successfully": "Persona actualizada exitosamente.", "personal-access-token": "Token de Acceso Personal", + "pick-values-to-refine": "Seleccione valores para refinar. Su ubicación de exploración permanece.", "pii-coverage-widget-description": "Cobertura de PII para todos los activos de datos en el servicio. <0>saber más.", "pii-distribution-description": "Información personal identificable que, cuando se utiliza sola o con otros datos relevantes, puede identificar a una persona.", "pii-distribution-widget-description": "Distribución de datos PII para todos los activos de datos en el servicio. <0>saber más.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index c3736bf851e4..f3a00061aff5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -259,6 +259,7 @@ "browse": "Naviguer", "browse-app-plural": "Parcourir les applications", "browse-csv-file": "Naviguer le fichier CSV", + "browse-estate": "Parcourir le patrimoine", "bulk-edit": "Modification en masse", "bulk-import-entity": "Importation en masse de {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importer depuis ODCS", "import-odcs-contract": "Importer un contrat ODCS", "import-om": "Importer OM", + "in": "Dans", "in-last-number-of-days": "Dans les {{numberOfDays}} derniers jours", "in-lowercase": "dans", "in-open-metadata": "Dans {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Moment de la journée où les données sont attendues disponibles", "available-on-global-view": "Disponible dans la vue globale", "bot-email-confirmation": "{{email}} pour {{botName}} Agent Numérique", + "browse-estate-query-placeholder": "Parcourez l'ensemble de votre patrimoine de données — choisissez un filtre ci-dessus ou un emplacement à gauche et ils s'accumuleront ici.", "bulk-edit-entity-help": "Modifier plusieurs {{entity}} à la fois en utilisant CSV", "bulk-import-completed": "Import en Masse Terminé", "cache-service-not-configured-message": "Configurez un service de cache tel que Redis ou ElastiCache et assurez-vous qu'il est accessible pour activer le préchauffage du cache.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Préférence de persona mise à jour avec succès!", "persona-updated-successfully": "Persona mise à jour avec succès.", "personal-access-token": "Jeton d'Accès Personnel (PAT)", + "pick-values-to-refine": "Sélectionnez des valeurs pour affiner. Votre emplacement de navigation reste en place.", "pii-coverage-widget-description": "Couverture des données PII pour tous les actifs de données dans le service. <0>en savoir plus.", "pii-distribution-description": "Information personnelle identifiable qui, lorsqu'elle est utilisée seule ou avec d'autres données pertinentes, peut identifier une personne.", "pii-distribution-widget-description": "Distribution des données PII pour tous les actifs de données dans le service. <0>en savoir plus.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 4a834173ef8e..4ec26db64702 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -259,6 +259,7 @@ "browse": "Explorar", "browse-app-plural": "Explorar aplicacións", "browse-csv-file": "Explorar ficheiro CSV", + "browse-estate": "Explorar o patrimonio", "bulk-edit": "Edición masiva", "bulk-import-entity": "Importación masiva de {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importar desde ODCS", "import-odcs-contract": "Importar contrato ODCS", "import-om": "Importar OM", + "in": "En", "in-last-number-of-days": "Nas últimas {{numberOfDays}} días", "in-lowercase": "en", "in-open-metadata": "En {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Momento do día en que se espera que os datos estean dispoñibles", "available-on-global-view": "Dispoñible na vista global", "bot-email-confirmation": "{{email}} para o bot {{botName}}", + "browse-estate-query-placeholder": "Explorando todo o seu patrimonio de datos — seleccione un filtro arriba ou unha localización á esquerda e acumularanse aquí.", "bulk-edit-entity-help": "Editar múltiples {{entity}} á vez usando CSV", "bulk-import-completed": "Importación Masiva Completada", "cache-service-not-configured-message": "Configura un servizo de caché como Redis ou ElastiCache e asegúrate de que sexa accesible para habilitar o quentamento da caché.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Preferencia de persona actualizada correctamente!", "persona-updated-successfully": "Persona actualizada correctamente.", "personal-access-token": "Token de Acceso Persoal", + "pick-values-to-refine": "Seleccione valores para refinar. A súa localización de exploración mantense.", "pii-coverage-widget-description": "Cobertura PII para todos os activos de datos no servizo. <0>saber máis.", "pii-distribution-description": "Información personal identificable que, cando se usa soa ou con outros datos relevantes, pode identificar a un individuo.", "pii-distribution-widget-description": "Distribución de datos PII para todos os activos de datos no servizo. <0>saber máis.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index fe2aaa4557c2..8dc15864521a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -259,6 +259,7 @@ "browse": "עיון", "browse-app-plural": "עיין באפליקציות", "browse-csv-file": "עיין בקובץ CSV", + "browse-estate": "עיין במאגר הנתונים", "bulk-edit": "עריכה מרובה", "bulk-import-entity": "ייבוא מרוכז של {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "ייבא מ-ODCS", "import-odcs-contract": "ייבא חוזה ODCS", "import-om": "ייבוא OM", + "in": "ב", "in-last-number-of-days": "ב-{{numberOfDays}} הימים האחרונים", "in-lowercase": "ב", "in-open-metadata": "ב-Metadata פתוח", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "שעת היום שבה צפויים הנתונים להיות זמינים", "available-on-global-view": "זמין בתצוגה הגלובלית", "bot-email-confirmation": "{{email}} לבוט {{botName}}", + "browse-estate-query-placeholder": "עיון במלוא מאגר הנתונים שלך — בחר מסנן מלמעלה או מיקום משמאל והם יצטברו כאן.", "bulk-edit-entity-help": "ערוך מספר {{entity}} בבת אחת באמצעות CSV", "bulk-import-completed": "ייבוא בכמות הושלם", "cache-service-not-configured-message": "הגדר שירות מטמון כגון Redis או ElastiCache וודא שהוא נגיש כדי לאפשר חימום מטמון.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "העדפת אישיות עודכנה בהצלחה!", "persona-updated-successfully": "Persona עודכנה בהצלחה.", "personal-access-token": "אסימון גישה אישי", + "pick-values-to-refine": "בחר ערכים לסינון. מיקום הגלישה שלך נשאר במקומו.", "pii-coverage-widget-description": "כיסוי PII עבור כל נכסי הנתונים בשירות. <0>למד עוד.", "pii-distribution-description": "מידע שיכול להזהיר את מי שמשתמש בו או עם נתונים אחרים שיכולים לזהות אדם.", "pii-distribution-widget-description": "התפלגות נתוני PII עבור כל נכסי הנתונים בשירות. <0>למד עוד.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 3a6e059744a3..a3e2e6c6c27a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -259,6 +259,7 @@ "browse": "参照", "browse-app-plural": "アプリを参照", "browse-csv-file": "CSVファイルを参照", + "browse-estate": "データ基盤を参照", "bulk-edit": "一括編集", "bulk-import-entity": "{{entity}}の一括インポート", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "ODCS からインポート", "import-odcs-contract": "ODCS 契約をインポート", "import-om": "OMをインポート", + "in": "内", "in-last-number-of-days": "過去{{numberOfDays}}日間", "in-lowercase": "内", "in-open-metadata": "{{brandName}}内", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "データが利用可能になると予想される時刻帯", "available-on-global-view": "グローバルビューで利用可能", "bot-email-confirmation": "{{botName}} ボットのメールアドレス:{{email}}", + "browse-estate-query-placeholder": "データ基盤全体を参照中 — 上のフィルターまたは左の場所を選択すると、ここに積み重なります。", "bulk-edit-entity-help": "CSV を使用して複数の{{entity}}を一括編集", "bulk-import-completed": "一括インポート完了", "cache-service-not-configured-message": "RedisまたはElastiCacheなどのキャッシュサービスを設定し、到達可能であることを確認して、キャッシュウォームアップを有効にしてください。", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "ペルソナ設定が正常に更新されました!", "persona-updated-successfully": "Persona が正常に更新されました。", "personal-access-token": "パーソナルアクセストークン", + "pick-values-to-refine": "値を選択して絞り込みます。参照場所は変わりません。", "pii-coverage-widget-description": "サービス内のすべてのデータアセットにおける PII カバレッジ。<0>詳細を見る。", "pii-distribution-description": "個人を特定できる情報とは、それ単独または他の関連データと組み合わせることで個人を識別可能にする情報です。", "pii-distribution-widget-description": "サービス内のすべてのデータアセットにおける PII データの分布。<0>詳細を見る。", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index 9bffcd128949..6f996e4c7ebc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -259,6 +259,7 @@ "browse": "탐색", "browse-app-plural": "앱 탐색", "browse-csv-file": "CSV 파일 탐색", + "browse-estate": "에스테이트 탐색", "bulk-edit": "일괄 편집", "bulk-import-entity": "{{entity}} 일괄 가져오기", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "ODCS에서 가져오기", "import-odcs-contract": "ODCS 계약 가져오기", "import-om": "OM 가져오기", + "in": "안에", "in-last-number-of-days": "최근 {{numberOfDays}}일 내", "in-lowercase": "안에", "in-open-metadata": "{{brandName}} 내", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "데이터가 사용 가능할 것으로 예상되는 하루 중 시간", "available-on-global-view": "글로벌 보기에서 사용 가능", "bot-email-confirmation": "{{botName}} 봇의 {{email}}", + "browse-estate-query-placeholder": "전체 데이터 에스테이트 탐색 중 — 위에서 필터를 선택하거나 왼쪽에서 위치를 선택하면 여기에 쌓입니다.", "bulk-edit-entity-help": "CSV를 사용하여 여러 {{entity}}를 한 번에 편집", "bulk-import-completed": "대량 가져오기 완료", "cache-service-not-configured-message": "Redis 또는 ElastiCache와 같은 캐시 서비스를 구성하고 접근 가능한지 확인하여 캐시 워밍업을 활성화하세요.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "페르소나 설정이 성공적으로 업데이트되었습니다!", "persona-updated-successfully": "Persona가 성공적으로 업데이트되었습니다.", "personal-access-token": "개인 접근 토큰", + "pick-values-to-refine": "세부 필터링을 위한 값을 선택하세요. 탐색 위치는 그대로 유지됩니다.", "pii-coverage-widget-description": "서비스 내 모든 데이터 자산의 PII(개인 식별 정보) 커버리지입니다. <0>더 알아보기.", "pii-distribution-description": "다른 관련 데이터와 함께 또는 단독으로 사용될 때 개인을 식별할 수 있는 개인 식별 정보(PII)입니다.", "pii-distribution-widget-description": "서비스 내 모든 데이터 자산의 PII 데이터 분포입니다. <0>더 알아보기.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index f65cf677359f..39533c869e54 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -259,6 +259,7 @@ "browse": "ब्राउझ करा", "browse-app-plural": "ॲप्स ब्राउझ करा", "browse-csv-file": "CSV फाइल ब्राउझ करा", + "browse-estate": "डेटा संपदा ब्राउझ करा", "bulk-edit": "सामूहिक संपादन", "bulk-import-entity": "{{entity}} मोठ्या प्रमाणात आयात", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "ODCS मधून आयात करा", "import-odcs-contract": "ODCS करार आयात करा", "import-om": "OM आयात करा", + "in": "मध्ये", "in-last-number-of-days": "शेवटच्या {{numberOfDays}} दिवसांमध्ये", "in-lowercase": "मध्ये", "in-open-metadata": "{{brandName}} मध्ये", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "दिवस का तोिप वेळ ज्यापर्यंत डेटा उपलब्ध असण्याची अपेक्षा आहे", "available-on-global-view": "जागतिक दृश्यात उपलब्ध", "bot-email-confirmation": "{{botName}} बॉटसाठी {{email}}", + "browse-estate-query-placeholder": "तुमच्या संपूर्ण डेटा संपदेचे ब्राउझिंग — वर एक फिल्टर किंवा डाव्या बाजूला एक स्थान निवडा आणि ते इथे एकत्र होतात.", "bulk-edit-entity-help": "CSV वापरून एकाच वेळी अनेक {{entity}} संपादित करा", "bulk-import-completed": "मोठ्या प्रमाणात आयात पूर्ण झाली", "cache-service-not-configured-message": "Redis किंवा ElastiCache सारखी कॅशे सेवा कॉन्फिगर करा आणि कॅशे वार्मअप सक्षम करण्यासाठी ती पोहोचण्यायोग्य असल्याची खात्री करा.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "व्यक्तिमत्व प्राधान्य यशस्वीरित्या अद्ययावत केले!", "persona-updated-successfully": "Persona यशस्वीरित्या अद्ययावत केले.", "personal-access-token": "वैयक्तिक प्रवेश टोकन", + "pick-values-to-refine": "परिष्करणासाठी मूल्ये निवडा. तुमचे ब्राउझिंग स्थान कायम राहते.", "pii-coverage-widget-description": "सेवेतील सर्व डेटा मालमत्तांसाठी पीआयआय व्याप्ती. <0>अधिक जाणून घ्या.", "pii-distribution-description": "वैयक्तिक पहवत जाणकारी जे एकाच वेळी वापरले जाते किंवा अन्य संबंधित डेटांसह वापरले जाते जे व्यक्तिवाचक असते.", "pii-distribution-widget-description": "सेवेतील सर्व डेटा मालमत्तांसाठी पीआयआय डेटा वितरण. <0>अधिक जाणून घ्या.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 2eb45d1224c1..ca43fbf93500 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -259,6 +259,7 @@ "browse": "Bladeren", "browse-app-plural": "Blader door apps", "browse-csv-file": "Blader door CSV-bestand", + "browse-estate": "Databestand doorzoeken", "bulk-edit": "Bulk bewerken", "bulk-import-entity": "Bulkimport van {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importeren vanuit ODCS", "import-odcs-contract": "ODCS-contract importeren", "import-om": "OM importeren", + "in": "In", "in-last-number-of-days": "In de laatste {{numberOfDays}} dagen", "in-lowercase": "in", "in-open-metadata": "In {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Tijdstip van de dag waarop gegevens beschikbaar worden verwacht", "available-on-global-view": "Beschikbaar in de globale weergave", "bot-email-confirmation": "{{email}} voor {{botName}} bot", + "browse-estate-query-placeholder": "U doorzoekt uw volledige databestand — kies boven een filter of links een locatie en ze worden hier gestapeld.", "bulk-edit-entity-help": "Bewerk meerdere {{entity}} tegelijk met CSV", "bulk-import-completed": "Bulkimport Voltooid", "cache-service-not-configured-message": "Configureer een cache-service zoals Redis of ElastiCache en zorg dat deze bereikbaar is om Cache Warmup in te schakelen.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Persoonlijkheidsvoorkeur succesvol bijgewerkt!", "persona-updated-successfully": "Persona succesvol bijgewerkt.", "personal-access-token": "Persoonlijke toegangstoken", + "pick-values-to-refine": "Selecteer waarden om te verfijnen. Uw bladerpositie blijft ongewijzigd.", "pii-coverage-widget-description": "PII-dekking voor alle data-assets in de service. <0>meer informatie.", "pii-distribution-description": "Persoonlijk Identificeerbare Informatie die, wanneer alleen of met andere relevante gegevens wordt gebruikt, een persoon kan identificeren.", "pii-distribution-widget-description": "PII-datadistributie voor alle data-assets in de service. <0>meer informatie.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 149b70aaa991..639e376e1667 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -259,6 +259,7 @@ "browse": "مرور", "browse-app-plural": "مرور برنامه‌ها", "browse-csv-file": "مرور فایل CSV", + "browse-estate": "مرور مجموعه داده", "bulk-edit": "ویرایش دسته‌ای", "bulk-import-entity": "درون‌ریزی انبوه {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "وارد کردن از ODCS", "import-odcs-contract": "وارد کردن قرارداد ODCS", "import-om": "Importar OM", + "in": "در", "in-last-number-of-days": "در {{numberOfDays}} روز گذشته", "in-lowercase": "در", "in-open-metadata": "در {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "زمان روزی که انتظار می‌رود داده‌ها در دسترس باشند", "available-on-global-view": "در نمای کلی موجود است", "bot-email-confirmation": "{{email}} برای ربات {{botName}}", + "browse-estate-query-placeholder": "مرور کل مجموعه داده شما — یک فیلتر از بالا یا یک مکان از چپ انتخاب کنید و اینجا روی هم انباشته می‌شوند.", "bulk-edit-entity-help": "چندین {{entity}} را به‌طور هم‌زمان با استفاده از CSV ویرایش کنید", "bulk-import-completed": "واردات دسته‌ای تکمیل شد", "cache-service-not-configured-message": "یک سرویس کش مانند Redis یا ElastiCache را پیکربندی کنید و مطمئن شوید قابل دسترس است تا گرم‌سازی کش فعال شود.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "ترجیح شخصیت با موفقیت به‌روزرسانی شد!", "persona-updated-successfully": "Persona با موفقیت به‌روزرسانی شد.", "personal-access-token": "توکن دسترسی شخصی.", + "pick-values-to-refine": "مقادیر را برای پالایش انتخاب کنید. موقعیت مرور شما ثابت می‌ماند.", "pii-coverage-widget-description": "پوشش PII برای تمام دارایی‌های داده در سرویس. <0>بیشتر بدانید.", "pii-distribution-description": "اطلاعات شخصی قابل شناسایی که وقتی به تنهایی یا همراه با سایر داده های مرتبط استفاده می شود، می تواند یک فرد را شناسایی کند.", "pii-distribution-widget-description": "توزیع داده‌های PII برای تمام دارایی‌های داده در سرویس. <0>بیشتر بدانید.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index fa70ee659a8b..60eed8d4d523 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -259,6 +259,7 @@ "browse": "Navegar", "browse-app-plural": "Navegar pelos aplicativos", "browse-csv-file": "Navegar no arquivo CSV", + "browse-estate": "Navegar pelo acervo", "bulk-edit": "Edição em massa", "bulk-import-entity": "Importação em massa de {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importar do ODCS", "import-odcs-contract": "Importar Contrato ODCS", "import-om": "Importar OM", + "in": "Em", "in-last-number-of-days": "Nas últimas {{numberOfDays}} dias", "in-lowercase": "em", "in-open-metadata": "Em {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Horário do dia em que os dados devem estar disponíveis", "available-on-global-view": "Disponível na visão global", "bot-email-confirmation": "{{email}} para o bot {{botName}}", + "browse-estate-query-placeholder": "Navegando por todo o seu acervo de dados — escolha um filtro acima ou uma localização à esquerda e eles se acumularão aqui.", "bulk-edit-entity-help": "Editar vários {{entity}} de uma vez usando CSV", "bulk-import-completed": "Importação em Massa Concluída", "cache-service-not-configured-message": "Configure um serviço de cache como Redis ou ElastiCache e certifique-se de que esteja acessível para habilitar o aquecimento de cache.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Preferência de persona atualizada com sucesso!", "persona-updated-successfully": "Persona atualizada com sucesso.", "personal-access-token": "Token de Acesso Pessoal", + "pick-values-to-refine": "Selecione valores para refinar. Sua localização de navegação permanece.", "pii-coverage-widget-description": "Cobertura de PII para todos os ativos de dados no serviço. <0>saiba mais.", "pii-distribution-description": "Informações Pessoais Identificáveis que, quando usadas sozinhas ou com outros dados relevantes, podem identificar uma pessoa.", "pii-distribution-widget-description": "Distribuição de dados PII para todos os ativos de dados no serviço. <0>saiba mais.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index ad4406e100b2..b2866e3adee8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -259,6 +259,7 @@ "browse": "Navegar", "browse-app-plural": "Navegar em Apps", "browse-csv-file": "Navegar no arquivo CSV", + "browse-estate": "Navegar pelo acervo", "bulk-edit": "Edição em massa", "bulk-import-entity": "Importação em massa de {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importar do ODCS", "import-odcs-contract": "Importar Contrato ODCS", "import-om": "Importar OM", + "in": "Em", "in-last-number-of-days": "Nas últimas {{numberOfDays}} dias", "in-lowercase": "em", "in-open-metadata": "Em {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Hora do dia em que se espera que os dados estejam disponíveis", "available-on-global-view": "Disponível na vista global", "bot-email-confirmation": "{{email}} para o bot {{botName}}", + "browse-estate-query-placeholder": "A navegar por todo o seu acervo de dados — selecione um filtro acima ou uma localização à esquerda e estes acumular-se-ão aqui.", "bulk-edit-entity-help": "Edite várias {{entity}} de uma só vez usando CSV", "bulk-import-completed": "Importação em Massa Concluída", "cache-service-not-configured-message": "Configure um serviço de cache como o Redis ou o ElastiCache e certifique-se de que está acessível para ativar o aquecimento da cache.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Preferência de persona atualizada com sucesso!", "persona-updated-successfully": "Persona atualizada com sucesso.", "personal-access-token": "Token de Acesso Pessoal", + "pick-values-to-refine": "Selecione valores para refinar. A sua localização de navegação mantém-se.", "pii-coverage-widget-description": "Cobertura de PII para todos os ativos de dados no serviço. <0>saber mais.", "pii-distribution-description": "Informações Pessoais Identificáveis que, quando usadas sozinhas ou com outros dados relevantes, podem identificar uma pessoa.", "pii-distribution-widget-description": "Distribuição de dados PII para todos os ativos de dados no serviço. <0>saber mais.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 3fd0db1e283d..8b1c551a10ec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -259,6 +259,7 @@ "browse": "Просмотр", "browse-app-plural": "Просмотр приложений", "browse-csv-file": "Просмотр CSV-файла", + "browse-estate": "Просмотр активов", "bulk-edit": "Массовое редактирование", "bulk-import-entity": "Массовый импорт {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Импорт из ODCS", "import-odcs-contract": "Импортировать контракт ODCS", "import-om": "Импортировать OM", + "in": "В", "in-last-number-of-days": "За последние {{numberOfDays}} дней", "in-lowercase": "в", "in-open-metadata": "В {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Время суток, к которому ожидается доступность данных", "available-on-global-view": "Доступно в глобальном представлении", "bot-email-confirmation": "{{email}} для бота {{botName}}", + "browse-estate-query-placeholder": "Просмотр всего массива данных — выберите фильтр сверху или местоположение слева, и они накапливаются здесь.", "bulk-edit-entity-help": "Редактировать несколько {{entity}} одновременно с помощью CSV", "bulk-import-completed": "Массовый импорт завершён", "cache-service-not-configured-message": "Настройте сервис кэша, такой как Redis или ElastiCache, и убедитесь, что он доступен, чтобы включить прогрев кэша.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Предпочтения персонажа успешно обновлены!", "persona-updated-successfully": "Persona успешно обновлена.", "personal-access-token": "Персональный токен доступа", + "pick-values-to-refine": "Выберите значения для уточнения. Ваше местоположение поиска остается на месте.", "pii-coverage-widget-description": "Покрытие PII для всех объектов данных в сервисе. <0>Узнать больше.", "pii-distribution-description": "Любая информация, позволяющая идентифицировать человека как отдельно, так и в сочетании с другими данными.", "pii-distribution-widget-description": "Распределение данных PII для всех объектов данных в сервисе. <0>узнать больше.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json index 2b5635dfb521..ae37adb88c8c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json @@ -259,6 +259,7 @@ "browse": "Bläddra", "browse-app-plural": "Bläddra bland appar", "browse-csv-file": "Bläddra efter CSV-fil", + "browse-estate": "Browse estate", "bulk-edit": "Massredigera", "bulk-import-entity": "Massimportera {{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "Importera ODCS", "import-odcs-contract": "Importera ODCS-kontrakt", "import-om": "Importera OM", + "in": "In", "in-last-number-of-days": "De senaste {{numberOfDays}} dagarna", "in-lowercase": "i", "in-open-metadata": "I {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Tidpunkt på dygnet då data förväntas vara tillgängliga", "available-on-global-view": "Tillgänglig i den globala vyn", "bot-email-confirmation": "{{email}} för boten {{botName}}", + "browse-estate-query-placeholder": "Browsing your whole data estate — pick a filter above or a location on the left and they stack here.", "bulk-edit-entity-help": "Redigera flera {{entity}} samtidigt med CSV", "bulk-import-completed": "Massimport slutförd", "cache-service-not-configured-message": "Konfigurera en cachetjänst som Redis eller ElastiCache och se till att den är nåbar för att aktivera cacheuppvärmning.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Personainställningen har uppdaterats!", "persona-updated-successfully": "Personan har uppdaterats.", "personal-access-token": "Personal Access Token", + "pick-values-to-refine": "Pick values to refine. Your browse location stays put.", "pii-coverage-widget-description": "PII-täckning för alla datatillgångar i tjänsten. <0>läs mer.", "pii-distribution-description": "Personligt identifierbar information som, när den används ensam eller med andra relevanta data, kan identifiera en individ.", "pii-distribution-widget-description": "PII-datafördelning för alla datatillgångar i tjänsten. <0>läs mer.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 6c9eccd6bdad..4855bd7875b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -259,6 +259,7 @@ "browse": "เรียกดู", "browse-app-plural": "เรียกดูแอปพลิเคชัน", "browse-csv-file": "เรียกดูไฟล์ CSV", + "browse-estate": "เรียกดูคลังข้อมูล", "bulk-edit": "แก้ไขเป็นกลุ่ม", "bulk-import-entity": "นำเข้า {{entity}} จำนวนมาก", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "นำเข้าจาก ODCS", "import-odcs-contract": "นำเข้าสัญญา ODCS", "import-om": "นำเข้า OM", + "in": "ใน", "in-last-number-of-days": "ในช่วง {{numberOfDays}} วันที่ผ่านมา", "in-lowercase": "ใน", "in-open-metadata": "ใน {{brandName}}", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "เวลาของวันที่คาดว่าข้อมูลจะใช้งานได้", "available-on-global-view": "ใช้ได้ในมุมมองรวม", "bot-email-confirmation": "{{email}} สำหรับบอท {{botName}}", + "browse-estate-query-placeholder": "กำลังเรียกดูคลังข้อมูลทั้งหมดของคุณ — เลือกตัวกรองด้านบนหรือตำแหน่งด้านซ้าย และจะแสดงที่นี่", "bulk-edit-entity-help": "แก้ไขหลาย {{entity}} พร้อมกันโดยใช้ CSV", "bulk-import-completed": "นำเข้าจำนวนมากเสร็จสมบูรณ์", "cache-service-not-configured-message": "กำหนดค่าบริการแคช เช่น Redis หรือ ElastiCache และตรวจสอบให้แน่ใจว่าเข้าถึงได้เพื่อเปิดใช้ Cache Warmup", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "อัปเดตการตั้งค่าบุคลิกภาพสำเร็จแล้ว!", "persona-updated-successfully": "Persona อัปเดตสำเร็จแล้ว!", "personal-access-token": "โทเค็นการเข้าถึงส่วนบุคคล", + "pick-values-to-refine": "เลือกค่าเพื่อกรอง ตำแหน่งการเรียกดูของคุณยังคงอยู่", "pii-coverage-widget-description": "ความครอบคลุมของ PII สำหรับสินทรัพย์ข้อมูลทั้งหมดในบริการ <0>เรียนรู้เพิ่มเติม", "pii-distribution-description": "ข้อมูลที่สามารถใช้เพื่อระบุบุคคลเฉพาะเมื่อใช้งานคนเดียวหรือกับข้อมูลที่เกี่ยวข้องกัน", "pii-distribution-widget-description": "การกระจายข้อมูล PII สำหรับสินทรัพย์ข้อมูลทั้งหมดในบริการ <0>เรียนรู้เพิ่มเติม", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index a3554640b7de..e5a3d16cf014 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -259,6 +259,7 @@ "browse": "Gözat", "browse-app-plural": "Uygulamalara Gözat", "browse-csv-file": "CSV dosyasına göz at", + "browse-estate": "Veri Mirasına Gözat", "bulk-edit": "Toplu Düzenleme", "bulk-import-entity": "{{entity}} toplu içe aktarma", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "ODCS'den İçe Aktar", "import-odcs-contract": "ODCS Sözleşmesi İçe Aktar", "import-om": "OM içe aktar", + "in": "İçinde", "in-last-number-of-days": "Son {{numberOfDays}} günde", "in-lowercase": "içinde", "in-open-metadata": "{{brandName}}'da", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "Günün o saati itibarıyla verilerin kullanılabilir olması beklenir", "available-on-global-view": "Genel görünümde kullanılabilir", "bot-email-confirmation": "{{botName}} botu için {{email}}", + "browse-estate-query-placeholder": "Tüm veri mirasınıza göz atıyorsunuz — yukarıdan bir filtre veya soldan bir konum seçin ve bunlar burada birikir.", "bulk-edit-entity-help": "CSV kullanarak birden fazla {{entity}} düzenleyin", "bulk-import-completed": "Toplu İçe Aktarma Tamamlandı", "cache-service-not-configured-message": "Redis veya ElastiCache gibi bir Önbellek Hizmeti yapılandırın ve önbellek ısınmasını etkinleştirmek için erişilebilir olduğundan emin olun.", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "Kişilik tercihi başarıyla güncellendi!", "persona-updated-successfully": "Persona başarıyla güncellendi.", "personal-access-token": "Kişisel Erişim Anahtarı", + "pick-values-to-refine": "Filtrelemek için değerleri seçin. Göz atma konumunuz sabit kalır.", "pii-coverage-widget-description": "Servisteki tüm veri varlıkları için KVT kapsamı. <0>daha fazla bilgi edinin.", "pii-distribution-description": "Kişisel Tanımlanabilir Bilgi, tek başına veya diğer ilgili verilerle kullanıldığında bir bireyi tanımlayabilen bilgilerdir.", "pii-distribution-widget-description": "Servisteki tüm veri varlıkları için KVT veri dağılımına genel bakış. <0>daha fazla bilgi edinin.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 20158ad2934e..fb3871ee4540 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -259,6 +259,7 @@ "browse": "浏览", "browse-app-plural": "浏览应用", "browse-csv-file": "浏览CSV文件", + "browse-estate": "浏览数据资产", "bulk-edit": "批量编辑", "bulk-import-entity": "批量导入{{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "从 ODCS 导入", "import-odcs-contract": "导入 ODCS 合同", "import-om": "导入 OM", + "in": "在", "in-last-number-of-days": "最近 {{numberOfDays}} 天", "in-lowercase": "in", "in-open-metadata": "在 {{brandName}} 平台", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "数据预期可用的一天中的时间", "available-on-global-view": "在全局视图中可用", "bot-email-confirmation": "{{botName}}机器人的邮箱{{email}}", + "browse-estate-query-placeholder": "正在浏览整个数据资产 — 在上方选择筛选条件或在左侧选择位置,它们将在此处叠加。", "bulk-edit-entity-help": "使用 CSV 一次编辑多个{{entity}}", "bulk-import-completed": "批量导入完成", "cache-service-not-configured-message": "请配置 Redis 或 ElastiCache 等缓存服务,并确保其可访问,以启用缓存预热。", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "角色偏好设置已成功更新!", "persona-updated-successfully": "Persona 已成功更新。", "personal-access-token": "个人访问令牌", + "pick-values-to-refine": "选择值以细化。您的浏览位置保持不变。", "pii-coverage-widget-description": "服务中所有数据资产的 PII 覆盖率。<0>了解更多。", "pii-distribution-description": "个人可识别信息, 当单独使用或与其他相关数据一起使用时, 可以识别个人。", "pii-distribution-widget-description": "服务中所有数据资产的 PII 数据分布。<0>了解更多。", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index fc4a7e10f476..434dc54acde4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -259,6 +259,7 @@ "browse": "瀏覽", "browse-app-plural": "瀏覽應用程式", "browse-csv-file": "瀏覽 CSV 檔案", + "browse-estate": "瀏覽資料資產", "bulk-edit": "批次編輯", "bulk-import-entity": "大量匯入{{entity}}", "bullet-point": "• {{text}}", @@ -1118,6 +1119,7 @@ "import-odcs": "從 ODCS 匯入", "import-odcs-contract": "匯入 ODCS 合約", "import-om": "匯入 OM", + "in": "在", "in-last-number-of-days": "過去 {{numberOfDays}} 天內", "in-lowercase": "在", "in-open-metadata": "在 {{brandName}} 中", @@ -2652,6 +2654,7 @@ "availability-time-contract-description": "預期資料可用的時間段", "available-on-global-view": "在全域檢視中可用", "bot-email-confirmation": "{{botName}} 機器人的 {{email}}", + "browse-estate-query-placeholder": "正在瀏覽整個資料資產 — 在上方選擇篩選條件或在左側選擇位置,它們將在此處堆疊。", "bulk-edit-entity-help": "使用 CSV 一次編輯多個{{entity}}", "bulk-import-completed": "批次匯入完成", "cache-service-not-configured-message": "請設定 Redis 或 ElastiCache 等快取服務並確保其可連線,以啟用快取預熱。", @@ -3330,6 +3333,7 @@ "persona-preference-updated": "角色偏好設定已成功更新!", "persona-updated-successfully": "Persona 已成功更新。", "personal-access-token": "個人存取權杖", + "pick-values-to-refine": "選擇值以細化。您的瀏覽位置保持不變。", "pii-coverage-widget-description": "服務中所有資料資產的 PII 涵蓋範圍。<0>了解更多。", "pii-distribution-description": "個人可識別資訊,單獨使用或與其他相關資料結合使用時,可以識別個人。", "pii-distribution-widget-description": "服務中所有資料資產的 PII 資料分佈。<0>了解更多。", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx index 3c4ebd573d6b..c3c181a8aeb3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx @@ -19,6 +19,7 @@ import { withAdvanceSearch } from '../../components/AppRouter/withAdvanceSearch' import { useAdvanceSearch } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import { ExploreProps, + ExploreQuickFilterField, ExploreSearchIndex, SearchHitCounts, UrlParams, @@ -40,6 +41,7 @@ import { getCombinedQueryFilterObject } from '../../utils/ExplorePage/ExplorePag import { extractTermKeys, findActiveSearchIndex, + getBrowsePathQueryFilter, parseSearchParams, } from '../../utils/ExplorePureUtils'; import { fetchEntityData, generateTabItems } from '../../utils/ExploreUtils'; @@ -98,6 +100,7 @@ const ExplorePageV1: FC = () => { const { parsedSearch, searchQueryParam, + browseFields, sortValue, sortOrder, page, @@ -107,6 +110,13 @@ const ExplorePageV1: FC = () => { return parseSearchParams(location.search, globalPageSize, queryFilter); }, [location.search, queryFilter]); + // ES filter contributed by the browse-tree location. It ANDs with the + // dropdown quickFilter so browsing never clears filters and vice versa. + const browseQueryFilter = useMemo( + () => getBrowsePathQueryFilter(browseFields), + [browseFields] + ); + const handlePageChange: ExploreProps['onChangePage'] = (page, size) => { setPreference({ globalPageSize: size ?? globalPageSize }); navigate({ @@ -214,6 +224,32 @@ const ExplorePageV1: FC = () => { [history, parsedSearch] ); + // A tree click may update the browse location AND the Type quick filter + // (leaf rows). Both params must land in one navigate — two sequential + // navigates against the same memoized parsedSearch clobber each other. + const handleTreeSelect = useCallback( + (payload: { + browseFields: ExploreQuickFilterField[]; + quickFilter?: QueryFilterInterface; + }) => { + const { browseFields: updatedBrowseFields, quickFilter } = payload; + if (quickFilter) { + setAdvancedSearchQuickFilters(quickFilter); + } + navigate({ + search: Qs.stringify({ + ...parsedSearch, + browsePath: isEmpty(updatedBrowseFields) + ? undefined + : JSON.stringify(updatedBrowseFields), + ...(quickFilter ? { quickFilter: JSON.stringify(quickFilter) } : {}), + page: 1, + }), + }); + }, + [parsedSearch] + ); + const handleShowDeletedChange: ExploreProps['onChangeShowDeleted'] = ( showDeleted ) => { @@ -316,6 +352,7 @@ const ExplorePageV1: FC = () => { const fetchDependencies = useMemo(() => { return JSON.stringify({ quickFilter: parsedSearch.quickFilter, + browsePath: parsedSearch.browsePath, queryFilter, searchQueryParam, sortValue, @@ -327,6 +364,7 @@ const ExplorePageV1: FC = () => { }); }, [ parsedSearch.quickFilter, + parsedSearch.browsePath, queryFilter, searchQueryParam, sortValue, @@ -361,7 +399,13 @@ const ExplorePageV1: FC = () => { const cacheKey = fetchDependencies; const cached = getCached(cacheKey); - const updatedQuickFilters = getAdvancedSearchQuickFilters(); + // Single injection point for the browse-tree location: pre-combining here + // scopes the tab counts, search and NLQ queries inside fetchEntityData + // without leaking browse terms into the dropdown chip state. + const updatedQuickFilters = getCombinedQueryFilterObject( + getAdvancedSearchQuickFilters(), + browseQueryFilter + ); // Setters wrapped to (a) capture the resolved values for the eventual cache write and // (b) drop the update entirely if the user has navigated to a different search since the @@ -533,6 +577,8 @@ const ExplorePageV1: FC = () => { = () => { onChangeSortValue={(sortVal) => { handleSortValueChange(1, sortVal); }} + onTreeSelect={handleTreeSelect} /> ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/pagination.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/pagination.less index 8d1652be4ec2..725eb7b8378d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/pagination.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/pagination.less @@ -19,6 +19,7 @@ padding: 4px; border: none; border-radius: 8px; + margin-right: 4px; } .ant-pagination-item-active { background-color: @primary-button-background; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts new file mode 100644 index 000000000000..5f5a9f257d30 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Collate. + * 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 { Bucket } from 'Models'; +import { EntityType } from '../enums/entity.enum'; +import { getOptionsFromAggregationBucket } from './AdvancedSearchPureUtils'; + +const buckets = [ + { key: 'table', doc_count: 1734 }, + { key: 'tableColumn', doc_count: 21500 }, + { key: EntityType.INGESTION_PIPELINE, doc_count: 5 }, +] as Bucket[]; + +describe('getOptionsFromAggregationBucket', () => { + it('returns an empty array when buckets is falsy', () => { + expect( + getOptionsFromAggregationBucket(undefined as unknown as Bucket[]) + ).toEqual([]); + }); + + it('uses the raw bucket key as label when no formatter is provided', () => { + const result = getOptionsFromAggregationBucket([ + { key: 'tableColumn', doc_count: 21500 }, + ] as Bucket[]); + + expect(result).toEqual([ + { key: 'tableColumn', label: 'tableColumn', count: 21500 }, + ]); + }); + + it('applies the label formatter to produce human-readable labels', () => { + const formatter = (key: string) => + key === 'tableColumn' ? 'Column' : 'Table'; + + const result = getOptionsFromAggregationBucket( + [ + { key: 'table', doc_count: 1734 }, + { key: 'tableColumn', doc_count: 21500 }, + ] as Bucket[], + formatter + ); + + expect(result).toEqual([ + { key: 'table', label: 'Table', count: 1734 }, + { key: 'tableColumn', label: 'Column', count: 21500 }, + ]); + }); + + it('keeps the original key while formatting only the label', () => { + const [option] = getOptionsFromAggregationBucket( + [{ key: 'tableColumn', doc_count: 1 }] as Bucket[], + () => 'Column' + ); + + expect(option.key).toBe('tableColumn'); + expect(option.label).toBe('Column'); + }); + + it('excludes aggregation keys that should not appear as quick filters', () => { + const result = getOptionsFromAggregationBucket(buckets); + + expect( + result.some((option) => option.key === EntityType.INGESTION_PIPELINE) + ).toBe(false); + }); + + it('defaults count to 0 when doc_count is missing', () => { + const [option] = getOptionsFromAggregationBucket([ + { key: 'table' }, + ] as Bucket[]); + + expect(option.count).toBe(0); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts index f91c26ac8add..64619f7de741 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts @@ -207,7 +207,10 @@ export const getServiceOptions = ( : option.text; }; -export const getOptionsFromAggregationBucket = (buckets: Bucket[]) => { +export const getOptionsFromAggregationBucket = ( + buckets: Bucket[], + labelFormatter?: (key: string) => string +) => { if (!buckets) { return []; } @@ -219,7 +222,7 @@ export const getOptionsFromAggregationBucket = (buckets: Bucket[]) => { ) .map((option) => ({ key: option.key, - label: option.key, + label: labelFormatter ? labelFormatter(option.key) : option.key, count: option.doc_count ?? 0, })); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ExploreIconUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreIconUtils.tsx new file mode 100644 index 000000000000..50ac2389becc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreIconUtils.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Collate. + * 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 { + Atom01, + BarChart01, + BookOpen01, + CodeBrowser, + CodeSnippet01, + Compass03, + Cube01, + Database01, + File05, + Folder, + GitBranch01, + LayoutAlt01, + Rows01, + SearchMd, + Shield01, + Table, +} from '@untitledui/icons'; +import { CSSProperties } from 'react'; +import { EntityType } from '../enums/entity.enum'; + +type StrokeIcon = typeof Database01; + +/** + * Explore-only stroke icon set matching the redesign (Untitled-UI style). + * Scoped on purpose: the global getEntityIcon serves 30+ surfaces with the + * legacy colored glyphs and is not changed here. + */ +const EXPLORE_ASSET_ICONS: Record = { + [EntityType.DATABASE]: Database01, + [EntityType.DATABASE_SCHEMA]: Database01, + [EntityType.TABLE]: Table, + [EntityType.TABLE_COLUMN]: Table, + [EntityType.STORED_PROCEDURE]: CodeSnippet01, + [EntityType.DASHBOARD]: LayoutAlt01, + [EntityType.DASHBOARD_DATA_MODEL]: LayoutAlt01, + [EntityType.CHART]: BarChart01, + [EntityType.PIPELINE]: GitBranch01, + [EntityType.TOPIC]: Rows01, + [EntityType.MLMODEL]: Atom01, + [EntityType.CONTAINER]: Cube01, + [EntityType.SEARCH_INDEX]: SearchMd, + [EntityType.API_COLLECTION]: CodeBrowser, + [EntityType.API_ENDPOINT]: CodeBrowser, + [EntityType.GLOSSARY_TERM]: BookOpen01, + [EntityType.TAG]: Shield01, + [EntityType.METRIC]: BarChart01, + [EntityType.DOMAIN]: Compass03, + [EntityType.DATA_PRODUCT]: Cube01, + [EntityType.DIRECTORY]: Folder, + [EntityType.FILE]: File05, + [EntityType.SPREADSHEET]: Table, + [EntityType.WORKSHEET]: Table, + [EntityType.KNOWLEDGE_PAGE]: BookOpen01, +}; + +const EXPLORE_ICON_LOOKUP = new Map( + Object.entries(EXPLORE_ASSET_ICONS).map(([key, icon]) => [ + key.toLowerCase(), + icon, + ]) +); + +export const getExploreAssetIcon = ( + assetType: string, + className = '', + style: CSSProperties = {} +): JSX.Element | null => { + const IconComponent = EXPLORE_ICON_LOOKUP.get(assetType.toLowerCase()); + + return IconComponent ? ( + + ) : null; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.test.ts new file mode 100644 index 000000000000..7fa126548b4f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.test.ts @@ -0,0 +1,388 @@ +/* + * Copyright 2025 Collate. + * 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 { ExploreQuickFilterField } from '../components/Explore/ExplorePage.interface'; +import { ExploreTreeNode } from '../components/Explore/ExploreTree/ExploreTree.interface'; +import { EntityType } from '../enums/entity.enum'; +import { + findTreeNodeKeyByBrowsePath, + getBrowsePathQueryFilter, + getCanonicalEntityType, + getDisabledExploreTreeKeys, + parseBrowsePathFields, + truncateBrowsePath, +} from './ExplorePureUtils'; + +const DATABASE_KEY = 'database_root'; +const DASHBOARD_KEY = 'dashboard_root'; +const PIPELINE_KEY = 'pipeline_root'; +const GOVERNANCE_KEY = 'governance_root'; + +const treeNodes = [ + { + key: DATABASE_KEY, + title: 'Databases', + data: { + isRoot: true, + childEntities: [ + EntityType.DATABASE, + EntityType.DATABASE_SCHEMA, + EntityType.STORED_PROCEDURE, + EntityType.TABLE, + EntityType.TABLE_COLUMN, + ], + }, + }, + { + key: DASHBOARD_KEY, + title: 'Dashboards', + data: { + isRoot: true, + childEntities: [ + EntityType.DASHBOARD_DATA_MODEL, + EntityType.DASHBOARD, + EntityType.CHART, + ], + }, + }, + { + key: PIPELINE_KEY, + title: 'Pipelines', + data: { isRoot: true, childEntities: [EntityType.PIPELINE] }, + }, + { + key: GOVERNANCE_KEY, + title: 'Governance', + data: { + isRoot: true, + childEntities: [EntityType.TAG, EntityType.GLOSSARY_TERM], + }, + }, +] as ExploreTreeNode[]; + +describe('getDisabledExploreTreeKeys', () => { + it('disables nothing when no entity type is selected', () => { + expect(getDisabledExploreTreeKeys(treeNodes, []).size).toBe(0); + }); + + it('disables every category except Database when Table is selected', () => { + const disabled = getDisabledExploreTreeKeys(treeNodes, [EntityType.TABLE]); + + expect(disabled.has(DATABASE_KEY)).toBe(false); + expect(disabled.has(DASHBOARD_KEY)).toBe(true); + expect(disabled.has(PIPELINE_KEY)).toBe(true); + expect(disabled.has(GOVERNANCE_KEY)).toBe(true); + }); + + it('keeps the Database category enabled for a nested asset type like Column', () => { + const disabled = getDisabledExploreTreeKeys(treeNodes, [ + EntityType.TABLE_COLUMN, + ]); + + expect(disabled.has(DATABASE_KEY)).toBe(false); + expect(disabled.has(DASHBOARD_KEY)).toBe(true); + }); + + it('enables every category that holds any selected type (multi-select)', () => { + const disabled = getDisabledExploreTreeKeys(treeNodes, [ + EntityType.TABLE, + EntityType.DASHBOARD, + ]); + + expect(disabled.has(DATABASE_KEY)).toBe(false); + expect(disabled.has(DASHBOARD_KEY)).toBe(false); + expect(disabled.has(PIPELINE_KEY)).toBe(true); + expect(disabled.has(GOVERNANCE_KEY)).toBe(true); + }); + + it('disables all categories when the selected type belongs to none of them', () => { + const disabled = getDisabledExploreTreeKeys(treeNodes, ['nonExistentType']); + + expect(disabled.size).toBe(treeNodes.length); + }); + + it('matches entity types case-insensitively (e.g. aggregated tablecolumn vs enum tableColumn)', () => { + const disabled = getDisabledExploreTreeKeys(treeNodes, ['tablecolumn']); + + expect(disabled.has(DATABASE_KEY)).toBe(false); + expect(disabled.has(DASHBOARD_KEY)).toBe(true); + }); + + it('treats a category with no childEntities as disabled under any selection', () => { + const nodes = [ + { key: 'empty_root', title: 'Empty', data: { isRoot: true } }, + ] as ExploreTreeNode[]; + + expect( + getDisabledExploreTreeKeys(nodes, [EntityType.TABLE]).has('empty_root') + ).toBe(true); + }); +}); + +const browseFields: ExploreQuickFilterField[] = [ + { + key: 'entityType', + label: 'Databases', + value: [ + { key: 'table', label: 'table' }, + { key: 'tableColumn', label: 'tableColumn' }, + ], + }, + { + key: 'serviceType', + label: 'serviceType', + value: [{ key: 'Redshift', label: 'Redshift' }], + }, + { + key: 'service.displayName.keyword', + label: 'service.displayName.keyword', + value: [{ key: 'redshift prod', label: 'redshift prod' }], + }, + { + key: 'database.displayName', + label: 'database.displayName', + value: [{ key: 'dev', label: 'dev' }], + }, +]; + +describe('parseBrowsePathFields', () => { + it('returns an empty array for undefined, empty, or invalid JSON', () => { + expect(parseBrowsePathFields(undefined)).toEqual([]); + expect(parseBrowsePathFields('')).toEqual([]); + expect(parseBrowsePathFields('not-json')).toEqual([]); + expect(parseBrowsePathFields('{"a":1}')).toEqual([]); + }); + + it('round-trips a serialized browse path', () => { + expect(parseBrowsePathFields(JSON.stringify(browseFields))).toEqual( + browseFields + ); + }); + + it('drops malformed elements from a crafted browsePath param', () => { + expect(parseBrowsePathFields('[1,2,3]')).toEqual([]); + expect(parseBrowsePathFields('[{}]')).toEqual([]); + expect(parseBrowsePathFields('[null]')).toEqual([]); + expect( + parseBrowsePathFields('[{"key":"serviceType","value":"not-an-array"}]') + ).toEqual([]); + }); + + it('keeps well-formed fields while dropping garbage siblings', () => { + const valid = { + key: 'serviceType', + label: 'serviceType', + value: [{ key: 'Mysql', label: 'Mysql' }], + }; + const noValue = { key: 'entityType' }; + + expect( + parseBrowsePathFields(JSON.stringify([valid, 42, {}, noValue])) + ).toEqual([valid, noValue]); + }); +}); + +describe('getBrowsePathQueryFilter', () => { + it('returns undefined for an empty path', () => { + expect(getBrowsePathQueryFilter([])).toBeUndefined(); + }); + + it('builds an AND of per-level should terms', () => { + const filter = getBrowsePathQueryFilter(browseFields); + const must = filter?.query?.bool?.must as Array<{ + bool: { should: Array<{ term: Record }> }; + }>; + + expect(must).toHaveLength(4); + expect(must[1].bool.should).toEqual([ + { term: { serviceType: 'Redshift' } }, + ]); + expect(must[2].bool.should).toEqual([ + { term: { 'service.displayName.keyword': 'redshift prod' } }, + ]); + }); + + it('keeps existing lowercase semantics for the category entityType level', () => { + const filter = getBrowsePathQueryFilter([browseFields[0]]); + const must = filter?.query?.bool?.must as Array<{ + bool: { should: Array<{ term: Record }> }; + }>; + + expect(must[0].bool.should).toEqual([ + { term: { 'entityType.keyword': 'table' } }, + { term: { 'entityType.keyword': 'tablecolumn' } }, + ]); + }); +}); + +describe('truncateBrowsePath', () => { + it('removes the given level and everything after it', () => { + const result = truncateBrowsePath( + browseFields, + 'service.displayName.keyword' + ); + + expect(result.map((field) => field.key)).toEqual([ + 'entityType', + 'serviceType', + ]); + }); + + it('removing the first level clears the whole path', () => { + expect(truncateBrowsePath(browseFields, 'entityType')).toEqual([]); + }); + + it('returns the path unchanged when the level is not present', () => { + expect(truncateBrowsePath(browseFields, 'unknown.key')).toEqual( + browseFields + ); + }); +}); + +describe('getBrowsePathQueryFilter — OR within a field, AND across fields', () => { + it('two tiers in one field become one must clause with two should terms (Tier1 OR Tier2)', () => { + const filter = getBrowsePathQueryFilter([ + { + key: 'tier.tagFQN', + label: 'tier.tagFQN', + value: [ + { key: 'Tier.Tier1', label: 'Tier.Tier1' }, + { key: 'Tier.Tier2', label: 'Tier.Tier2' }, + ], + }, + ]); + const must = filter?.query?.bool?.must as Array<{ + bool: { should: Array<{ term: Record }> }; + }>; + + expect(must).toHaveLength(1); + expect(must[0].bool.should).toEqual([ + { term: { 'tier.tagFQN': 'Tier.Tier1' } }, + { term: { 'tier.tagFQN': 'Tier.Tier2' } }, + ]); + }); + + it('values across two fields become two must clauses (tier AND tag)', () => { + const filter = getBrowsePathQueryFilter([ + { + key: 'tier.tagFQN', + label: 'tier.tagFQN', + value: [ + { key: 'Tier.Tier1', label: 'Tier.Tier1' }, + { key: 'Tier.Tier2', label: 'Tier.Tier2' }, + ], + }, + { + key: 'tags.tagFQN', + label: 'tags.tagFQN', + value: [{ key: 'PII.Sensitive', label: 'PII.Sensitive' }], + }, + ]); + const must = filter?.query?.bool?.must as Array<{ + bool: { should: Array<{ term: Record }> }; + }>; + + expect(must).toHaveLength(2); + expect(must[0].bool.should).toHaveLength(2); + expect(must[1].bool.should).toEqual([ + { term: { 'tags.tagFQN': 'PII.Sensitive' } }, + ]); + }); + + it('fields without values contribute no must clause', () => { + const filter = getBrowsePathQueryFilter([ + { key: 'tier.tagFQN', label: 'tier.tagFQN', value: [] }, + { + key: 'tags.tagFQN', + label: 'tags.tagFQN', + value: [{ key: 'PII.Sensitive', label: 'PII.Sensitive' }], + }, + ]); + const must = filter?.query?.bool?.must as unknown[]; + + expect(must).toHaveLength(1); + }); +}); + +describe('getCanonicalEntityType', () => { + it('resolves lowercase aggregation keys to the EntityType enum casing', () => { + expect(getCanonicalEntityType('tablecolumn')).toBe('tableColumn'); + expect(getCanonicalEntityType('storedprocedure')).toBe('storedProcedure'); + expect(getCanonicalEntityType('table')).toBe('table'); + }); + + it('passes through unknown values unchanged', () => { + expect(getCanonicalEntityType('somethingElse')).toBe('somethingElse'); + }); +}); + +describe('findTreeNodeKeyByBrowsePath', () => { + const serviceField: ExploreQuickFilterField = { + key: 'serviceType', + label: 'serviceType', + value: [{ key: 'Mysql', label: 'Mysql' }], + }; + const nodes = [ + { + key: 'db_root', + title: 'Databases', + data: { + isRoot: true, + childEntities: [EntityType.TABLE, EntityType.TABLE_COLUMN], + }, + children: [ + { + key: 'svc_mysql', + title: 'mysql', + data: { filterField: [serviceField] }, + }, + ], + }, + ] as unknown as ExploreTreeNode[]; + + it('matches a category root by its childEntities set', () => { + const key = findTreeNodeKeyByBrowsePath(nodes, [ + { + key: 'entityType', + label: 'Databases', + value: [ + { key: 'table', label: 'table' }, + { key: 'tableColumn', label: 'tableColumn' }, + ], + }, + ]); + + expect(key).toBe('db_root'); + }); + + it('matches a nested node by its filter-field signature', () => { + expect(findTreeNodeKeyByBrowsePath(nodes, [serviceField])).toBe( + 'svc_mysql' + ); + }); + + it('returns null when no loaded node corresponds to the path', () => { + expect( + findTreeNodeKeyByBrowsePath(nodes, [ + { + key: 'serviceType', + label: 'serviceType', + value: [{ key: 'Redshift', label: 'Redshift' }], + }, + ]) + ).toBeNull(); + }); + + it('returns null for an empty path', () => { + expect(findTreeNodeKeyByBrowsePath(nodes, [])).toBeNull(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.ts index ec63f376cbe0..e4dabf99fd26 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExplorePureUtils.ts @@ -355,6 +355,40 @@ export const updateTreeDataWithCounts = ( }); }; +/** + * Given the explore tree root nodes and the entity types selected in the Data + * Assets filter, return the set of root keys whose service category contains + * none of the selected entity types. Those roots are grayed out so the user + * cannot browse into services that can't hold the selected asset type — e.g. + * selecting "Table" disables every non-Database service, since a table only + * belongs to a Database Service. An empty selection disables nothing. + */ +export const getDisabledExploreTreeKeys = ( + treeNodes: ExploreTreeNode[], + selectedEntityTypes: string[] +): Set => { + const disabledKeys = new Set(); + if (!isEmpty(selectedEntityTypes)) { + // Compare case-insensitively: a few entity types are aggregated in a + // different case than their EntityType enum value (e.g. tableColumn), and + // no two entity types differ only by case, so this is safe. + const selected = new Set( + selectedEntityTypes.map((entityType) => entityType.toLowerCase()) + ); + treeNodes.forEach((node) => { + const childEntities = node.data?.childEntities ?? []; + const containsSelectedType = childEntities.some((entityType) => + selected.has(entityType.toLowerCase()) + ); + if (!containsSelectedType) { + disabledKeys.add(node.key); + } + }); + } + + return disabledKeys; +}; + export const isElasticsearchError = (error: unknown): boolean => { if (!error) { return false; @@ -375,6 +409,158 @@ export const isElasticsearchError = (error: unknown): boolean => { ); }; +const isBrowsePathField = ( + field: unknown +): field is ExploreQuickFilterField => { + const record = + field && typeof field === 'object' + ? (field as Record) + : undefined; + + return ( + typeof record?.key === 'string' && + (record.value === undefined || Array.isArray(record.value)) + ); +}; + +/** + * The browse location selected in the explore tree, kept in its own + * `browsePath` URL param (ordered ExploreQuickFilterField[] — category, + * serviceType, service, database, schema). It ANDs with the dropdown + * `quickFilter`, so browsing never clears filters and vice versa. + * The param is untrusted URL input — malformed elements are dropped so + * crafted/legacy deep links degrade to an empty browse path. + */ +export const parseBrowsePathFields = ( + browsePath?: unknown +): ExploreQuickFilterField[] => { + let result: ExploreQuickFilterField[] = []; + if (isString(browsePath) && !isEmpty(browsePath)) { + try { + const parsed: unknown = JSON.parse(browsePath); + if (Array.isArray(parsed)) { + result = parsed.filter(isBrowsePathField); + } + } catch { + result = []; + } + } + + return result; +}; + +export const getBrowsePathQueryFilter = ( + browseFields: ExploreQuickFilterField[] +): QueryFilterInterface | undefined => { + const must = getExploreQueryFilterMust(browseFields); + + return isEmpty(must) + ? undefined + : ({ query: { bool: { must } } } as QueryFilterInterface); +}; + +/** + * Removing a browse chip truncates the path from that level down — removing + * "Service" also drops the database and schema picked beneath it. + */ +export const truncateBrowsePath = ( + browseFields: ExploreQuickFilterField[], + levelKey: string +): ExploreQuickFilterField[] => { + const levelIndex = browseFields.findIndex((field) => field.key === levelKey); + + return levelIndex < 0 ? browseFields : browseFields.slice(0, levelIndex); +}; + +const CANONICAL_ENTITY_TYPES = new Map( + Object.values(EntityType).map((value) => [value.toLowerCase(), value]) +); + +/** + * Search aggregations return entityType values in lowercase ("tablecolumn") + * while the EntityType enum and display-label maps use camelCase + * ("tableColumn"). Resolve any casing to the canonical enum value so labels + * and icons keep working regardless of which layer produced the key. + */ +export const getCanonicalEntityType = (entityTypeKey: string): string => + CANONICAL_ENTITY_TYPES.get(entityTypeKey.toLowerCase()) ?? entityTypeKey; + +const getBrowsePathSignature = (fields: ExploreQuickFilterField[]): string => + fields + .map( + (field) => + `${field.key}=${(field.value ?? []) + .map((option) => option.key.toLowerCase()) + .sort() + .join(',')}` + ) + .join('|'); + +/** + * Find the loaded tree node that corresponds to a browse path, so the tree + * highlight can follow chip removals — dropping the Service chip moves the + * selection back up to the category root. + */ +export const findTreeNodeKeyByBrowsePath = ( + treeNodes: ExploreTreeNode[], + browseFields: ExploreQuickFilterField[] +): string | null => { + let result: string | null = null; + if (!isEmpty(browseFields)) { + const targetSignature = getBrowsePathSignature(browseFields); + const isCategoryPath = + browseFields.length === 1 && + browseFields[0].key === EntityFields.ENTITY_TYPE; + + const visit = (nodes: ExploreTreeNode[]) => { + nodes.forEach((node) => { + if (result) { + return; + } + if (isCategoryPath && node.data?.isRoot) { + const childEntities = (node.data?.childEntities ?? []) + .map((entityType) => entityType.toLowerCase()) + .sort() + .join(','); + const targetEntities = (browseFields[0].value ?? []) + .map((option) => option.key.toLowerCase()) + .sort() + .join(','); + if (childEntities === targetEntities) { + result = node.key; + + return; + } + } + if ( + node.data?.filterField && + getBrowsePathSignature(node.data.filterField) === targetSignature + ) { + result = node.key; + + return; + } + if (node.children) { + visit(node.children); + } + }); + }; + visit(treeNodes); + + // Deep links and reloads start with only the shallow levels loaded — + // fall back to the deepest loaded ancestor of the path so the tree still + // shows where the user is browsing. + if (!result && browseFields.length > 1) { + result = findTreeNodeKeyByBrowsePath( + treeNodes, + browseFields.slice(0, -1) + ); + } + } + + return result; +}; + export const parseSearchParams = ( search: string, globalPageSize: number, @@ -388,6 +574,8 @@ export const parseSearchParams = ( ? parsedSearch.search : ''; + const browseFields = parseBrowsePathFields(parsedSearch.browsePath); + const sortValue = isString(parsedSearch.sort) ? parsedSearch.sort : INITIAL_SORT_FIELD; @@ -419,6 +607,7 @@ export const parseSearchParams = ( return { parsedSearch, searchQueryParam, + browseFields, sortValue, sortOrder, page, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx index 062837e95d51..004f278c2d2d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx @@ -41,6 +41,30 @@ import { import { escapeESReservedCharacters } from './StringUtils'; import { showErrorToast } from './ToastUtils'; +export { + extractTermKeys, + findActiveSearchIndex, + findTreeNodeKeyByBrowsePath, + getAggregations, + getBrowsePathQueryFilter, + getCanonicalEntityType, + getDisabledExploreTreeKeys, + getExploreQueryFilterMust, + getParseValueFromLocation, + getQuickFilterObject, + getQuickFilterObjectForEntities, + getQuickFilterQuery, + getSelectedValuesFromQuickFilter, + getSubLevelHierarchyKey, + isElasticsearchError, + parseBrowsePathFields, + parseSearchParams, + truncateBrowsePath, + updateCountsInTreeData, + updateTreeData, + updateTreeDataWithCounts, +} from './ExplorePureUtils'; + export const getAggregationOptions = async ( index: SearchIndex | SearchIndex[], key: string, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts index a8e76c9578cb..a93cdae19d1e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts @@ -11,37 +11,28 @@ * limitations under the License. */ import { SearchOutlined } from '@ant-design/icons'; -import { ReactComponent as GovernIcon } from '../assets/svg/bank.svg'; import { ReactComponent as ChartIcon } from '../assets/svg/chart.svg'; import { ReactComponent as ClassificationIcon } from '../assets/svg/classification.svg'; import { ReactComponent as IconDataModel } from '../assets/svg/data-model.svg'; import { ReactComponent as GlossaryIcon } from '../assets/svg/glossary.svg'; import { ReactComponent as IconAPICollection } from '../assets/svg/ic-api-collection-default.svg'; import { ReactComponent as IconAPIEndpoint } from '../assets/svg/ic-api-endpoint-default.svg'; -import { ReactComponent as IconAPIService } from '../assets/svg/ic-api-service-default.svg'; import { ReactComponent as ColumnIcon } from '../assets/svg/ic-column.svg'; import { ReactComponent as DashboardIcon } from '../assets/svg/ic-dashboard.svg'; import { ReactComponent as DataProductIcon } from '../assets/svg/ic-data-product.svg'; import { ReactComponent as DatabaseIcon } from '../assets/svg/ic-database.svg'; import { ReactComponent as DirectoryIcon } from '../assets/svg/ic-directory.svg'; -import { ReactComponent as DomainIcon } from '../assets/svg/ic-domain.svg'; -import { ReactComponent as DriveIcon } from '../assets/svg/ic-drive-service.svg'; import { ReactComponent as FileIcon } from '../assets/svg/ic-file.svg'; -import { ReactComponent as KnowledgeCenterIcon } from '../assets/svg/ic-knowledge-page.svg'; import { ReactComponent as MlModelIcon } from '../assets/svg/ic-ml-model.svg'; import { ReactComponent as PipelineIcon } from '../assets/svg/ic-pipeline.svg'; import { ReactComponent as SchemaIcon } from '../assets/svg/ic-schema.svg'; -import { ReactComponent as SearchIcon } from '../assets/svg/ic-search.svg'; import { ReactComponent as SpreadsheetIcon } from '../assets/svg/ic-spreadsheet.svg'; import { ReactComponent as ContainerIcon } from '../assets/svg/ic-storage.svg'; import { ReactComponent as IconStoredProcedure } from '../assets/svg/ic-stored-procedure.svg'; import { ReactComponent as TableIcon } from '../assets/svg/ic-table.svg'; import { ReactComponent as TopicIcon } from '../assets/svg/ic-topic.svg'; import { ReactComponent as WorksheetIcon } from '../assets/svg/ic-worksheet.svg'; -import { - ReactComponent as KnowledgeCenterIconComponent, - ReactComponent as KnowledgePageIcon, -} from '../assets/svg/knowledge-center.svg'; +import { ReactComponent as KnowledgeCenterIconComponent } from '../assets/svg/knowledge-center.svg'; import { ReactComponent as MetricIcon } from '../assets/svg/metric.svg'; import { ReactComponent as IconTable } from '../assets/svg/table-grey.svg'; import { ExploreSearchIndex } from '../components/Explore/ExplorePage.interface'; @@ -92,6 +83,7 @@ import { TabsInfoData } from '../pages/ExplorePage/ExplorePage.interface'; import { getEntityBreadcrumbs } from './EntityBreadcrumbPureUtils'; import { getEntityLinkFromType } from './EntityLinkUtils'; import { getEntityName } from './EntityNameUtils'; +import { getExploreAssetIcon } from './ExploreIconUtils'; import { t } from './i18next/LocalUtil'; import { getPageSummaryComponent } from './KnowledgeComponentUtils'; import { getKnowledgePagePath } from './KnowledgePagePureUtils'; @@ -269,7 +261,7 @@ class SearchClassBase { EntityType.TABLE_COLUMN, ], }, - icon: DatabaseIcon, + icon: getExploreAssetIcon(EntityType.DATABASE, 'service-icon w-4 h-4'), }, { title: t('label.dashboard-plural'), @@ -282,37 +274,40 @@ class SearchClassBase { EntityType.CHART, ], }, - icon: DashboardIcon, + icon: getExploreAssetIcon(EntityType.DASHBOARD, 'service-icon w-4 h-4'), }, { title: t('label.pipeline-plural'), key: SearchIndex.PIPELINE, data: { isRoot: true, childEntities: [EntityType.PIPELINE] }, - icon: PipelineIcon, + icon: getExploreAssetIcon(EntityType.PIPELINE, 'service-icon w-4 h-4'), }, { title: t('label.topic-plural'), key: SearchIndex.TOPIC, data: { isRoot: true, childEntities: [EntityType.TOPIC] }, - icon: TopicIcon, + icon: getExploreAssetIcon(EntityType.TOPIC, 'service-icon w-4 h-4'), }, { title: t('label.ml-model-plural'), key: SearchIndex.MLMODEL, data: { isRoot: true, childEntities: [EntityType.MLMODEL] }, - icon: MlModelIcon, + icon: getExploreAssetIcon(EntityType.MLMODEL, 'service-icon w-4 h-4'), }, { title: t('label.container-plural'), key: SearchIndex.CONTAINER, data: { isRoot: true, childEntities: [EntityType.CONTAINER] }, - icon: ContainerIcon, + icon: getExploreAssetIcon(EntityType.CONTAINER, 'service-icon w-4 h-4'), }, { title: t('label.search-index-plural'), key: SearchIndex.SEARCH_INDEX, data: { isRoot: true, childEntities: [EntityType.SEARCH_INDEX] }, - icon: SearchIcon, + icon: getExploreAssetIcon( + EntityType.SEARCH_INDEX, + 'service-icon w-4 h-4' + ), }, { title: t('label.api-uppercase-plural'), @@ -321,7 +316,10 @@ class SearchClassBase { isRoot: true, childEntities: [EntityType.API_ENDPOINT, EntityType.API_COLLECTION], }, - icon: IconAPIService, + icon: getExploreAssetIcon( + EntityType.API_COLLECTION, + 'service-icon w-4 h-4' + ), }, { title: t('label.drive-plural'), @@ -335,7 +333,7 @@ class SearchClassBase { EntityType.WORKSHEET, ], }, - icon: DriveIcon, + icon: getExploreAssetIcon(EntityType.DIRECTORY, 'service-icon w-4 h-4'), }, { title: t('label.governance'), @@ -348,13 +346,16 @@ class SearchClassBase { EntityType.METRIC, ], }, - icon: GovernIcon, + icon: getExploreAssetIcon(EntityType.TAG, 'service-icon w-4 h-4'), children: [ { title: t('label.glossary-plural'), key: EntityType.GLOSSARY_TERM, isLeaf: true, - icon: GlossaryIcon, + icon: getExploreAssetIcon( + EntityType.GLOSSARY_TERM, + 'service-icon w-4 h-4' + ), data: { entityType: EntityType.GLOSSARY_TERM, isStatic: true, @@ -365,7 +366,7 @@ class SearchClassBase { title: t('label.tag-plural'), key: EntityType.TAG, isLeaf: true, - icon: ClassificationIcon, + icon: getExploreAssetIcon(EntityType.TAG, 'service-icon w-4 h-4'), data: { entityType: EntityType.TAG, isStatic: true, @@ -376,7 +377,10 @@ class SearchClassBase { title: t('label.metric-plural'), key: EntityType.METRIC, isLeaf: true, - icon: MetricIcon, + icon: getExploreAssetIcon( + EntityType.METRIC, + 'service-icon w-4 h-4' + ), data: { entityType: EntityType.METRIC, isStatic: true, @@ -389,13 +393,16 @@ class SearchClassBase { title: t('label.domain-plural'), key: 'Domain', data: { isRoot: true, childEntities: [EntityType.DATA_PRODUCT] }, - icon: DomainIcon, + icon: getExploreAssetIcon(EntityType.DOMAIN, 'service-icon w-4 h-4'), children: [ { title: t('label.data-product-plural'), key: EntityType.DATA_PRODUCT, isLeaf: true, - icon: DataProductIcon, + icon: getExploreAssetIcon( + EntityType.DATA_PRODUCT, + 'service-icon w-4 h-4' + ), data: { entityType: EntityType.DATA_PRODUCT, isStatic: true, @@ -410,13 +417,19 @@ class SearchClassBase { isRoot: true, childEntities: [EntityType.KNOWLEDGE_PAGE], }, - icon: KnowledgePageIcon, + icon: getExploreAssetIcon( + EntityType.KNOWLEDGE_PAGE, + 'service-icon w-4 h-4' + ), children: [ { title: t('label.knowledge-page'), key: EntityType.KNOWLEDGE_PAGE, isLeaf: true, - icon: KnowledgeCenterIcon, + icon: getExploreAssetIcon( + EntityType.KNOWLEDGE_PAGE, + 'service-icon w-4 h-4' + ), data: { entityType: EntityType.KNOWLEDGE_PAGE, isStatic: true,