diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 715cc87fff1a..8d71a84a6053 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -7979,6 +7979,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx" ], "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/SearchSettings.spec.ts", "playwright/e2e/Search/SearchRelevance.spec.ts" ] @@ -7988,6 +7989,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx" ], "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/CustomProperties.spec.ts", "playwright/e2e/Pages/SearchSettings.spec.ts" ] @@ -8027,6 +8029,7 @@ "playwright/e2e/Features/MetricAssets.spec.ts", "playwright/e2e/Features/OnlineUsers.spec.ts", "playwright/e2e/Features/Pagination.spec.ts", + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Features/SchemaSearch.spec.ts", "playwright/e2e/Features/Table.spec.ts", "playwright/e2e/Flow/PersonaFlow.spec.ts", @@ -10532,6 +10535,70 @@ "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Pages/CustomProperties.spec.ts", + "playwright/e2e/Pages/SearchSettings.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostTable.tsx" + ], + "specs": [ + "playwright/e2e/Pages/SearchSettings.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/GlobalSearchSettings.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Pages/SearchSettings.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/MatchingFieldCard.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Pages/CustomProperties.spec.ts", + "playwright/e2e/Pages/SearchSettings.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/RankingSection.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Search/SearchRelevance.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Search/SearchRelevance.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Pages/SearchSettings.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionServiceDetailsPage/ConnectionServiceDetailsPage.tsx" @@ -12448,6 +12515,7 @@ "openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx" ], "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/SearchSettings.spec.ts" ] }, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ProfilePlatformSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ProfilePlatformSettings.spec.ts index 93c1fb66c634..4e97b2892ac9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ProfilePlatformSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ProfilePlatformSettings.spec.ts @@ -144,6 +144,7 @@ test.describe( ['lineage', 'lineage-settings'], ['brand-url', 'brand-url-settings'], ['learning-resources', 'learning-resources-settings'], + ['search', 'search-settings'], ['app-mode', 'default-app-mode-page'], ]; @@ -710,5 +711,160 @@ test.describe( await afterAction(); } }); + + test('search: global settings save the full config on each change', async ({ + page, + }) => { + const settings = await stubSettingRoundTrip(page, 'searchSettings'); + await openPlatformSettings(page); + await openCard(page, 'search'); + await expect(page.getByTestId('search-settings')).toBeVisible({ + timeout: 30_000, + }); + + const accessControl = page + .getByTestId('enable-roles-polices-in-search-switch') + .locator('input'); + const wasEnabled = await accessControl.isChecked(); + await clickAndWaitFor( + page, + page.getByTestId('enable-roles-polices-in-search-switch'), + SETTINGS_PUT + ); + + const saved = settings.puts[0].config_value as { + globalSettings: { enableAccessControl: boolean }; + assetTypeConfigurations: unknown[]; + }; + + expect(saved.globalSettings.enableAccessControl).toBe(!wasEnabled); + // The whole document is saved, not just the changed flag. + expect(saved.assetTypeConfigurations.length).toBeGreaterThan(0); + + await page.getByTestId('global-setting-edit-maxResultHits').click(); + const input = page.getByTestId('global-setting-input-maxResultHits'); + await input.fill('50'); + await expect( + page.getByTestId('global-setting-save-maxResultHits') + ).toBeDisabled(); + await input.fill('500'); + await clickAndWaitFor( + page, + page.getByTestId('global-setting-save-maxResultHits'), + SETTINGS_PUT + ); + + expect( + (settings.puts[1].config_value as { globalSettings: object }) + .globalSettings + ).toEqual(expect.objectContaining({ maxResultHits: 500 })); + await expect( + page.getByTestId('global-setting-value-maxResultHits') + ).toHaveText('500'); + }); + + test('search: reset restores the defaults only after confirmation', async ({ + page, + }) => { + // Never reset the shared tenant's settings. + let resets = 0; + await page.route( + '**/api/v1/system/settings/reset/searchSettings', + (route) => { + resets += 1; + + return route.fulfill({ json: {} }); + } + ); + await openPlatformSettings(page); + await openCard(page, 'search'); + await expect(page.getByTestId('search-settings')).toBeVisible({ + timeout: 30_000, + }); + + await header(page).getByTestId('reset-search-settings-btn').click(); + await page.getByTestId('reset-search-settings-dialog-cancel').click(); + expect(resets).toBe(0); + + await header(page).getByTestId('reset-search-settings-btn').click(); + await clickAndWaitFor( + page, + page.getByTestId('reset-search-settings-dialog-confirm'), + '**/api/v1/system/settings/reset/searchSettings' + ); + expect(resets).toBe(1); + await toastNotification(page, /updated successfully/); + }); + + test('search: an entity page previews the draft and saves it', async ({ + page, + }) => { + const settings = await stubSettingRoundTrip(page, 'searchSettings'); + await openPlatformSettings(page); + await openCard(page, 'search'); + await page.getByTestId('search-entity-card-tables').click(); + + await expect(page.getByTestId('entity-search-settings')).toBeVisible({ + timeout: 30_000, + }); + await expect(header(page)).toContainText('Table'); + await expect(page.getByTestId('ranking-settings')).toBeVisible(); + await expect(page.getByTestId('search-preview')).toBeVisible(); + await expect(header(page).getByTestId('save-btn')).toBeDisabled(); + + // A field the default table configuration always matches on. + const fieldName = 'displayName.keyword'; + const field = page.getByTestId(`field-configuration-panel-${fieldName}`); + + // Removing a field re-runs the preview with the unsaved draft. + const previewWithoutField = page.waitForRequest((request) => { + if (!request.url().includes('/api/v1/search/preview')) { + return false; + } + const table = ( + request.postDataJSON() as { + searchSettings: { + assetTypeConfigurations: { + assetType: string; + searchFields?: { field: string }[]; + }[]; + }; + } + ).searchSettings.assetTypeConfigurations.find( + (config) => config.assetType === 'table' + ); + + return !table?.searchFields?.some((field) => field.field === fieldName); + }); + await field.getByTestId('delete-search-field').click(); + await previewWithoutField; + await expect(field).toHaveCount(0); + + await clickAndWaitFor( + page, + header(page).getByTestId('save-btn'), + SETTINGS_PUT + ); + + const table = ( + settings.puts[0].config_value as { + assetTypeConfigurations: { + assetType: string; + searchFields: { field: string }[]; + }[]; + } + ).assetTypeConfigurations.find((config) => config.assetType === 'table'); + + expect(table?.searchFields.map((field) => field.field)).not.toContain( + fieldName + ); + await expect(header(page).getByTestId('save-btn')).toBeDisabled(); + + await header(page) + .getByLabel('Breadcrumb') + .getByText('Search', { exact: true }) + .click(); + await expect(page.getByTestId('search-settings')).toBeVisible(); + }); } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ConfirmDialog.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ConfirmDialog.tsx new file mode 100644 index 000000000000..11cf96b86e1f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ConfirmDialog.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2026 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, + Dialog, + Modal, + ModalOverlay, + Typography, +} from '@openmetadata/ui-core-components'; +import { useTranslation } from 'react-i18next'; + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmLabel: string; + isLoading?: boolean; + isDestructive?: boolean; + testId: string; + onConfirm: () => void; + onCancel: () => void; +} + +const ConfirmDialog = ({ + isOpen, + title, + message, + confirmLabel, + isLoading, + isDestructive, + testId, + onConfirm, + onCancel, +}: ConfirmDialogProps) => { + const { t } = useTranslation(); + + return ( + !open && onCancel()}> + + + + + {message} + + + +
+ + +
+
+
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.constants.ts index 663c90211831..3cf8d4f2e58a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.constants.ts @@ -22,8 +22,11 @@ import { Lock01, Mail01, RunProfiler, + Search, SwitchHorizontal01, } from '@openmetadata/ui-core-components/icons'; +import { startCase } from 'lodash'; +import { ENTITY_PATH } from '../../../../../../constants/constants'; import { isLoginConfigurationApplicable } from '../../../../../../utils/AuthProvider.util'; import type { PlatformSettingsPage } from './PlatformSettings.types'; @@ -84,6 +87,15 @@ export const PLATFORM_SETTINGS_PAGES: PlatformSettingsPage[] = [ descriptionKey: 'message.page-sub-header-for-lineage-config-setting', hasEditView: true, }, + { + id: 'search', + icon: Search, + titleKey: 'label.search', + descriptionKey: 'message.page-sub-header-for-search-setting', + hasItemViews: true, + getItemTitle: (_t, itemId) => + startCase(ENTITY_PATH[itemId as keyof typeof ENTITY_PATH] ?? itemId), + }, { id: 'brand-url', icon: Link01, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.types.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.types.ts index 53720c4ff14b..ef64ca87bd4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.types.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.types.ts @@ -26,6 +26,7 @@ export type PlatformSettingsPageId = | 'brand-url' | 'data-asset-rules' | 'learning-resources' + | 'search' | 'app-mode'; export type PlatformSettingsView = @@ -50,12 +51,17 @@ export interface PlatformSettingsPage { /** False when the edit form has no per-field docs to offer as hints. */ hasFieldHints?: boolean; isVisible?: (authProvider?: AuthProvider) => boolean; + /** Read-only sub-pages at `/`, e.g. one entity's search settings. */ + hasItemViews?: boolean; + /** Header title for a read-only sub-page. */ + getItemTitle?: (t: TFunction, itemId: string) => string; /** Overrides the default "Edit {title}" header, e.g. "Add Dimension" on a list page. */ getEditTitle?: (t: TFunction, itemId?: string) => string; } /** Shared by every platform-settings page: report back to the panel. */ export interface PlatformSettingsPageProps { + itemId?: string; onNavigate: (view: PlatformSettingsView) => void; onSetHeaderActions: (actions: React.ReactNode) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.test.ts index 06d702db5f69..89f6aad943e9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.test.ts @@ -12,7 +12,10 @@ */ import { AuthProvider } from '../../../../../../generated/settings/settings'; +import { PLATFORM_SETTINGS_PAGES } from './PlatformSettings.constants'; +import type { PlatformSettingsPage } from './PlatformSettings.types'; import { + getPageHeader, getVisiblePlatformSettingsPages, hashSubPathToView, nonNegativeNumberRules, @@ -57,6 +60,14 @@ describe('PlatformSettings.utils', () => { itemId: 'accuracy', }, ], + ['search', { type: 'page', page: 'search', isEditing: false }], + // Pages with item views read the rest of the path as the item. + [ + 'search/tables', + { type: 'page', page: 'search', isEditing: false, itemId: 'tables' }, + ], + // Without item views, an unknown segment still opens the page itself. + ['email/tables', { type: 'page', page: 'email', isEditing: false }], ])('maps sub-path "%s" to its view', (subPath, expected) => { expect(hashSubPathToView(subPath, allPages)).toEqual(expected); }); @@ -105,4 +116,72 @@ describe('PlatformSettings.utils', () => { expect(optional.validate('3')).toBe(true); expect(optional.validate('-1')).toBe('label.greater-than-or-equal-to 0'); }); + + it('round-trips an item view through its sub-path', () => { + const view = { + type: 'page', + page: 'search', + isEditing: false, + itemId: 'tables', + } as const; + + expect(viewToSubPath(view)).toBe('search/tables'); + expect(hashSubPathToView('search/tables', allPages)).toEqual(view); + }); + + describe('getPageHeader', () => { + const page = (id: string) => + PLATFORM_SETTINGS_PAGES.find( + (item) => item.id === id + ) as PlatformSettingsPage; + + it('titles a page view after the page', () => { + expect( + getPageHeader( + page('email'), + { type: 'page', page: 'email', isEditing: false }, + t + ) + ).toEqual({ + title: 'label.email', + breadcrumbs: [{ id: 'email', label: 'label.email' }], + }); + }); + + it('adds an edit crumb, using the page override when it has one', () => { + expect( + getPageHeader( + page('email'), + { type: 'page', page: 'email', isEditing: true }, + t + ).title + ).toBe('label.edit-entity'); + expect( + getPageHeader( + page('learning-resources'), + { type: 'page', page: 'learning-resources', isEditing: true }, + t + ).breadcrumbs + ).toEqual([ + { id: 'learning-resources', label: 'label.learning-resources' }, + { id: 'edit', label: 'label.add-resource' }, + ]); + }); + + it('titles an item view after the item', () => { + expect( + getPageHeader( + page('search'), + { type: 'page', page: 'search', isEditing: false, itemId: 'tables' }, + t + ) + ).toEqual({ + title: 'Table', + breadcrumbs: [ + { id: 'search', label: 'label.search' }, + { id: 'item', label: 'Table' }, + ], + }); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.ts index e8470dec3be8..471767f567a1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettings.utils.ts @@ -45,7 +45,12 @@ export const hashSubPathToView = ( } const isEditing = Boolean(page.hasEditView) && mode === EDIT_SEGMENT; - const itemId = isEditing && rest.length ? rest.join('/') : undefined; + let itemId: string | undefined; + if (isEditing) { + itemId = rest.length ? rest.join('/') : undefined; + } else if (page.hasItemViews && mode && mode !== EDIT_SEGMENT) { + itemId = [mode, ...rest].join('/'); + } return { type: 'page', @@ -63,7 +68,7 @@ export const viewToSubPath = ( } if (!view.isEditing) { - return view.page; + return view.itemId ? `${view.page}/${view.itemId}` : view.page; } return view.itemId @@ -88,3 +93,38 @@ export const nonNegativeNumberRules = ( Number(value) >= 0 || `${t('label.greater-than-or-equal-to')} 0`, }); + +/** The page's header title and the breadcrumbs below the Platform Settings root. */ +export const getPageHeader = ( + page: PlatformSettingsPage, + view: PlatformSettingsView, + t: TFunction +): { title: string; breadcrumbs: { id: string; label: string }[] } => { + const pageTitle = t(page.titleKey); + const breadcrumbs = [{ id: page.id, label: pageTitle }]; + if (view.type !== 'page') { + return { title: pageTitle, breadcrumbs }; + } + + if (view.isEditing) { + const title = + page.getEditTitle?.(t, view.itemId) ?? + String(t('label.edit-entity', { entity: pageTitle })); + + return { + title, + breadcrumbs: [...breadcrumbs, { id: 'edit', label: title }], + }; + } + + if (view.itemId) { + const title = page.getItemTitle?.(t, view.itemId) ?? view.itemId; + + return { + title, + breadcrumbs: [...breadcrumbs, { id: 'item', label: title }], + }; + } + + return { title: pageTitle, breadcrumbs }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.test.tsx index 1d991d1acb68..3beb5471052a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.test.tsx @@ -92,6 +92,15 @@ jest.mock('./DimensionSettingsForm', () =>
)) ); +jest.mock('./search/SearchSettingsView', () => () => ( +
+)); +jest.mock( + './search/EntitySearchSettings', + () => + ({ itemId }: { itemId: string }) => +
{itemId}
+); jest.mock('./AppModeSettingsForm', () => () => (
)); @@ -129,6 +138,7 @@ describe('PlatformSettingsPanel', () => { 'brand-url', 'data-asset-rules', 'learning-resources', + 'search', 'app-mode', ].forEach((id) => expect( @@ -193,6 +203,32 @@ describe('PlatformSettingsPanel', () => { ]); }); + it("renders the search settings, and one entity's settings as a sub-page", () => { + mockSubPath = 'search'; + const { unmount } = renderPanel(); + + expect(screen.getByTestId('search-view')).toBeInTheDocument(); + + unmount(); + mockSubPath = 'search/tables'; + renderPanel(); + + expect(screen.getByTestId('entity-search-view')).toHaveTextContent( + 'tables' + ); + expect(lastHeader().title).toBe('Table'); + expect(lastHeader().breadcrumbs.map((b: { id: string }) => b.id)).toEqual([ + 'settings', + 'platform-settings', + 'search', + 'item', + ]); + + lastHeader().onBreadcrumbAction('search'); + + expect(mockSetHash).toHaveBeenCalledWith('platform-settings', 'search'); + }); + it('renders the edit form with the show-hint toggle in the header', () => { mockSubPath = 'email/edit'; renderPanel(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.tsx index 75d712942125..8dc66431fdeb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.tsx @@ -43,6 +43,7 @@ import type { PlatformSettingsView, } from './PlatformSettings.types'; import { + getPageHeader, getVisiblePlatformSettingsPages, hashSubPathToView, viewToSubPath, @@ -50,6 +51,8 @@ import { import PlatformSettingsLanding from './PlatformSettingsLanding'; import ProfilerSettings from './ProfilerSettings'; import ProfilerSettingsForm from './ProfilerSettingsForm'; +import EntitySearchSettings from './search/EntitySearchSettings'; +import SearchSettingsView from './search/SearchSettingsView'; import ThemeSettings from './ThemeSettings'; import ThemeSettingsForm from './ThemeSettingsForm'; @@ -74,6 +77,12 @@ const VIEW_PAGES: Partial< 'data-asset-rules': () => , 'learning-resources': (props) => , 'app-mode': (props) => , + search: (props) => + props.itemId ? ( + + ) : ( + + ), }; /** The `/edit` view of each page that has one. */ @@ -145,16 +154,9 @@ const PlatformSettingsPanel = ({ let title = rootLabel; if (activePage) { - const pageTitle = t(activePage.titleKey); - breadcrumbs.push({ id: activePage.id, label: pageTitle }); - title = pageTitle; - - if (view.type === 'page' && view.isEditing) { - title = - activePage.getEditTitle?.(t, view.itemId) ?? - t('label.edit-entity', { entity: pageTitle }); - breadcrumbs.push({ id: 'edit', label: title }); - } + const header = getPageHeader(activePage, view, t); + breadcrumbs.push(...header.breadcrumbs); + title = header.title; } const hintToggle = ( @@ -216,6 +218,7 @@ const PlatformSettingsPanel = ({ } return VIEW_PAGES[view.page]?.({ + itemId: view.itemId, onNavigate, onSetHeaderActions: setPageActions, }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.test.tsx new file mode 100644 index 000000000000..f13af26f93cb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.test.tsx @@ -0,0 +1,474 @@ +/* + * Copyright 2026 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 { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { SearchIndex } from '../../../../../../../enums/search.enum'; +import { getCustomPropertiesByEntityType } from '../../../../../../../rest/metadataTypeAPI'; +import { searchPreview } from '../../../../../../../rest/searchAPI'; +import { + getSettingsByType, + restoreSettingsConfig, + updateSettingsConfig, +} from '../../../../../../../rest/settingConfigAPI'; +import tagClassBase from '../../../../../../../utils/TagClassBase'; +import { showSuccessToast } from '../../../../../../../utils/ToastUtils'; +import EntitySearchSettings from './EntitySearchSettings'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../../rest/settingConfigAPI', () => ({ + getSettingsByType: jest.fn(), + restoreSettingsConfig: jest.fn(), + updateSettingsConfig: jest.fn(), +})); + +jest.mock('../../../../../../../rest/metadataTypeAPI', () => ({ + getCustomPropertiesByEntityType: jest.fn(), +})); + +jest.mock('../../../../../../../rest/searchAPI', () => ({ + searchPreview: jest.fn(), +})); + +jest.mock('../../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +jest.mock('../../../../../../../utils/TagClassBase', () => ({ + __esModule: true, + default: { getTags: jest.fn().mockResolvedValue({ data: [] }) }, +})); + +const mockSetAppPreferences = jest.fn(); +jest.mock('../../../../../../../hooks/useApplicationStore', () => ({ + useApplicationStore: ( + selector: (state: { setAppPreferences: jest.Mock }) => unknown + ) => selector({ setAppPreferences: mockSetAppPreferences }), +})); + +jest.mock( + '../../../../../../ExploreV1/ExploreSearchCard/ExploreSearchCard', + () => + ({ source }: { source: { fullyQualifiedName: string } }) => +
{source.fullyQualifiedName}
+); + +const TABLE_CONFIG = { + assetType: 'table', + searchFields: [ + { field: 'name', boost: 10, matchType: 'exact' }, + { field: 'description', boost: 2 }, + ], + highlightFields: ['name'], + termBoosts: [{ field: 'tier.tagFQN', value: 'Tier.Tier1', boost: 5 }], + fieldValueBoosts: [{ field: 'totalVotes', factor: 2 }], + scoreMode: 'sum', + boostMode: 'multiply', + ranking: { + algorithm: 'nameFirst', + enabled: true, + stages: [ + { + name: 'exactName', + weight: 100, + matchType: 'exact', + fields: ['name.keyword'], + }, + ], + signals: { boostMode: 'sum', scoreMode: 'sum', maxBoost: 2 }, + }, +}; +const TOPIC_CONFIG = { assetType: 'topic', searchFields: [] }; +const CONFIG = { + allowedFields: [ + { + entityType: 'table', + fields: [ + { name: 'name', description: 'Table name', highlight: true }, + { name: 'description', description: 'Table description' }, + { name: 'columns.name', description: 'Column names' }, + ], + }, + ], + allowedFieldValueBoosts: [{ fields: [{ name: 'totalVotes' }] }], + assetTypeConfigurations: [TABLE_CONFIG, TOPIC_CONFIG], +}; + +const onNavigate = jest.fn(); +let headerActions: ReactNode; +const onSetHeaderActions = (actions: ReactNode) => { + headerActions = actions; +}; + +const renderPage = async (itemId = 'tables') => { + render( + + ); + await screen.findByTestId('entity-search-settings'); +}; + +const renderHeader = () => render(<>{headerActions}); + +const savedTableConfig = () => + ( + updateSettingsConfig as jest.Mock + ).mock.calls[0][0].config_value.assetTypeConfigurations.find( + (config: { assetType: string }) => config.assetType === 'table' + ); + +const lastPreviewTableConfig = () => { + const calls = (searchPreview as jest.Mock).mock.calls; + + return calls[calls.length - 1][0].searchSettings.assetTypeConfigurations[0]; +}; + +describe('EntitySearchSettings', () => { + beforeEach(() => { + jest.clearAllMocks(); + headerActions = undefined; + (getSettingsByType as jest.Mock).mockResolvedValue(CONFIG); + (getCustomPropertiesByEntityType as jest.Mock).mockResolvedValue([ + { name: 'owner_team', description: 'Owning team' }, + ]); + (searchPreview as jest.Mock).mockResolvedValue({ + hits: { + hits: [ + { _id: '1', _source: { fullyQualifiedName: 'db.schema.orders' } }, + ], + total: { value: 1 }, + }, + }); + (updateSettingsConfig as jest.Mock).mockImplementation(async (body) => ({ + data: body, + })); + (restoreSettingsConfig as jest.Mock).mockResolvedValue({ data: {} }); + }); + + it('shows the entity ranking, matching fields and a preview, with Save disabled', async () => { + await renderPage(); + + expect(screen.getByTestId('ranking-stage-exactName')).toBeInTheDocument(); + expect(screen.getByTestId('ranking-stage-weight-0')).toHaveValue(100); + expect(screen.getByTestId('field-configurations')).toHaveTextContent('2'); + expect( + screen.getByTestId('field-configuration-panel-name') + ).toHaveTextContent('Table name'); + expect(await screen.findByTestId('preview-result')).toHaveTextContent( + 'db.schema.orders' + ); + expect(searchPreview).toHaveBeenCalledWith( + expect.objectContaining({ index: SearchIndex.TABLE, query: '' }) + ); + + renderHeader(); + + expect(screen.getByTestId('save-btn')).toBeDisabled(); + }); + + it('previews and saves the draft for this entity only', async () => { + await renderPage(); + fireEvent.change(screen.getByTestId('ranking-stage-weight-0'), { + target: { value: '80' }, + }); + fireEvent.click( + within( + screen.getByTestId('field-configuration-panel-description') + ).getByTestId('delete-search-field') + ); + + await waitFor(() => + expect(lastPreviewTableConfig().searchFields).toEqual([ + TABLE_CONFIG.searchFields[0], + ]) + ); + + renderHeader(); + fireEvent.click(screen.getByTestId('save-btn')); + + await waitFor(() => expect(updateSettingsConfig).toHaveBeenCalled()); + + expect(savedTableConfig().ranking.stages[0].weight).toBe(80); + expect(savedTableConfig().searchFields).toEqual([ + TABLE_CONFIG.searchFields[0], + ]); + expect( + (updateSettingsConfig as jest.Mock).mock.calls[0][0].config_value + .assetTypeConfigurations[1] + ).toEqual(TOPIC_CONFIG); + expect(mockSetAppPreferences).toHaveBeenLastCalledWith({ + searchConfig: expect.objectContaining({ + allowedFields: CONFIG.allowedFields, + }), + }); + }); + + it('adds unselected and custom-property fields from the Add menu', async () => { + await renderPage(); + fireEvent.click(screen.getByTestId('add-field-btn')); + + expect( + await screen.findByTestId('add-field-columns.name') + ).toBeInTheDocument(); + expect(screen.queryByTestId('add-field-name')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('add-field-extension.owner_team')); + + const panel = await screen.findByTestId( + 'field-configuration-panel-extension.owner_team' + ); + + expect( + within(panel).getByTestId('custom-property-badge') + ).toBeInTheDocument(); + // A newly added field opens so its weight can be set straight away. + expect(within(panel).getByTestId('field-weight-value')).toHaveTextContent( + '0' + ); + }); + + it('only lets the server-highlightable fields be highlighted', async () => { + await renderPage(); + const toggleOf = (field: string) => { + const panel = screen.getByTestId(`field-configuration-panel-${field}`); + fireEvent.click(within(panel).getByTestId('field-configuration-toggle')); + + return within(panel) + .getByTestId('highlight-field-switch') + .querySelector('input') as HTMLInputElement; + }; + + expect(toggleOf('name')).toBeChecked(); + expect(toggleOf('description')).toBeDisabled(); + }); + + it('edits a field value boost into the draft without saving', async () => { + await renderPage(); + fireEvent.click( + within(screen.getByTestId('field-value-boost-totalVotes')).getByTestId( + 'edit-field-value-boost-btn' + ) + ); + const dialog = await screen.findByTestId('field-value-boost-dialog'); + fireEvent.change( + within(dialog).getByTestId('gte-input').querySelector('input') as Element, + { target: { value: '5' } } + ); + await act(async () => { + fireEvent.click(within(dialog).getByTestId('save-field-value-boost')); + }); + + await waitFor(() => + expect(lastPreviewTableConfig().fieldValueBoosts).toEqual([ + expect.objectContaining({ + field: 'totalVotes', + factor: 2, + condition: { range: { gte: 5 } }, + }), + ]) + ); + + expect(updateSettingsConfig).not.toHaveBeenCalled(); + }); + + const choose = async (selectTestId: string, option: string) => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await act(async () => { + await user.click( + within(screen.getByTestId(selectTestId)).getByRole('button') + ); + }); + await act(async () => { + await user.click(await screen.findByRole('option', { name: option })); + }); + }; + + it('edits the ranking switch, signals and modes into the draft', async () => { + await renderPage(); + fireEvent.click( + screen + .getByTestId('ranking-enabled-switch') + .querySelector('input') as HTMLInputElement + ); + fireEvent.change(screen.getByTestId('ranking-max-boost'), { + target: { value: '' }, + }); + await choose('ranking-boost-mode-select', 'Multiply'); + await choose('ranking-score-mode-select', 'Max'); + await choose('score-mode-select', 'First'); + await choose('boost-mode-select', 'Replace'); + + await waitFor(() => + expect(lastPreviewTableConfig()).toEqual( + expect.objectContaining({ + scoreMode: 'first', + boostMode: 'replace', + ranking: expect.objectContaining({ + enabled: false, + signals: { boostMode: 'multiply', scoreMode: 'max' }, + }), + }) + ) + ); + }); + + it('edits a matching field weight and match type into the draft', async () => { + await renderPage(); + const panel = screen.getByTestId('field-configuration-panel-description'); + fireEvent.click(within(panel).getByTestId('field-configuration-toggle')); + act(() => { + fireEvent.keyDown(within(panel).getByRole('slider'), { + key: 'ArrowRight', + }); + }); + await choose('match-type-select', 'label.phrase-match'); + + await waitFor(() => + expect(lastPreviewTableConfig().searchFields[1]).toEqual({ + field: 'description', + boost: 2.1, + matchType: 'phrase', + }) + ); + }); + + it('adds and removes term boosts in the draft only', async () => { + await renderPage(); + fireEvent.click(screen.getByTestId('add-term-boost-btn')); + + expect(await screen.findByTestId('term-boost-new')).toBeInTheDocument(); + expect(screen.getByTestId('add-term-boost-btn')).toBeDisabled(); + + fireEvent.click( + within(screen.getByTestId('term-boost-new')).getByTestId( + 'delete-term-boost' + ) + ); + + expect(screen.queryByTestId('term-boost-new')).not.toBeInTheDocument(); + + fireEvent.click( + within(screen.getByTestId('term-boost-Tier.Tier1')).getByTestId( + 'delete-term-boost' + ) + ); + + await waitFor(() => + expect(lastPreviewTableConfig().termBoosts).toEqual([]) + ); + + expect(updateSettingsConfig).not.toHaveBeenCalled(); + }); + + it('closes a new term boost card whose tag was picked but not boosted', async () => { + (tagClassBase.getTags as jest.Mock).mockResolvedValue({ + data: [{ data: { fullyQualifiedName: 'PII.Sensitive' } }], + }); + await renderPage(); + fireEvent.click(screen.getByTestId('add-term-boost-btn')); + const card = await screen.findByTestId('term-boost-new'); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await act(async () => { + await user.click(within(card).getByRole('combobox')); + }); + await act(async () => { + await user.keyboard('{ArrowDown}{Enter}'); + }); + const picked = await screen.findByTestId('term-boost-PII.Sensitive'); + fireEvent.click(within(picked).getByTestId('delete-term-boost')); + + await waitFor(() => + expect( + screen.queryByTestId('term-boost-PII.Sensitive') + ).not.toBeInTheDocument() + ); + + expect(screen.getByTestId('add-term-boost-btn')).toBeEnabled(); + }); + + it('removes a field value boost and opens an empty dialog to add one', async () => { + await renderPage(); + fireEvent.click( + within(screen.getByTestId('field-value-boost-totalVotes')).getByTestId( + 'delete-field-value-boost-btn' + ) + ); + + await waitFor(() => + expect(lastPreviewTableConfig().fieldValueBoosts).toEqual([]) + ); + + fireEvent.click(screen.getByTestId('add-field-value-boost-btn')); + const dialog = await screen.findByTestId('field-value-boost-dialog'); + await act(async () => { + fireEvent.click(within(dialog).getByTestId('save-field-value-boost')); + }); + + expect( + await within(dialog).findByText('message.field-required') + ).toBeInTheDocument(); + }); + + it('restores the defaults after confirmation and reloads them', async () => { + await renderPage(); + renderHeader(); + fireEvent.click(screen.getByTestId('restore-defaults-btn')); + fireEvent.click( + await screen.findByTestId('restore-defaults-dialog-confirm') + ); + + await waitFor(() => + expect(showSuccessToast).toHaveBeenCalledWith( + 'server.restore-entity-success' + ) + ); + + expect(restoreSettingsConfig).toHaveBeenCalled(); + expect(getSettingsByType).toHaveBeenCalledTimes(2); + }); + + it('returns to the entity list for an unknown entity', async () => { + render( + + ); + + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'search', + isEditing: false, + }) + ); + + expect(getSettingsByType).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.tsx new file mode 100644 index 000000000000..45dd916119a0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/EntitySearchSettings.tsx @@ -0,0 +1,500 @@ +/* + * Copyright 2026 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 { + Box, + Button, + Dropdown, + Select, +} from '@openmetadata/ui-core-components'; +import { Plus, RefreshCcw01 } from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ENTITY_PATH } from '../../../../../../../constants/constants'; +import { + AllowedFieldField, + BoostMode, + FieldValueBoost, + ScoreMode, + SearchSettings, + TermBoost, +} from '../../../../../../../generated/configuration/searchSettings'; +import { + Settings, + SettingType, +} from '../../../../../../../generated/settings/settings'; +import { useApplicationStore } from '../../../../../../../hooks/useApplicationStore'; +import { getCustomPropertiesByEntityType } from '../../../../../../../rest/metadataTypeAPI'; +import { + getSettingsByType, + restoreSettingsConfig, + updateSettingsConfig, +} from '../../../../../../../rest/settingConfigAPI'; +import { + getEffectiveRankingConfiguration, + getEntitySearchConfig, +} from '../../../../../../../utils/SearchSettingsUtils'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../../utils/ToastUtils'; +import ConfirmDialog from '../ConfirmDialog'; +import type { PlatformSettingsPageProps } from '../PlatformSettings.types'; +import { SettingsSkeleton } from '../SettingsFormLayout'; +import FieldValueBoostDialog from './FieldValueBoostDialog'; +import FieldValueBoostTable from './FieldValueBoostTable'; +import MatchingFieldCard from './MatchingFieldCard'; +import RankingSection, { + BOOST_MODE_ITEMS, + SCORE_MODE_ITEMS, +} from './RankingSection'; +import SearchPreviewPanel from './SearchPreviewPanel'; +import SearchSection, { SearchSectionTitle } from './SearchSection'; +import { + EntitySearchDraft, + toEntityDraft, + toggleHighlightField, + toggleSearchField, + updateSearchField, + upsertFieldValueBoost, + upsertTermBoost, + withEntityDraft, +} from './SearchSettings.utils'; +import TermBoostCard from './TermBoostCard'; + +/** + * One entity's search settings, edited as a draft with a live preview and + * saved together, as on the classic page. + */ +const EntitySearchSettings = ({ + itemId = '', + onNavigate, + onSetHeaderActions, +}: PlatformSettingsPageProps) => { + const { t } = useTranslation(); + const entityType: string | undefined = + ENTITY_PATH[itemId as keyof typeof ENTITY_PATH]; + const setAppPreferences = useApplicationStore( + (state) => state.setAppPreferences + ); + const [config, setConfig] = useState(); + const [draft, setDraft] = useState(toEntityDraft()); + const [isChanged, setIsChanged] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [customProperties, setCustomProperties] = useState( + [] + ); + const [lastAddedField, setLastAddedField] = useState(); + const [showNewTermBoost, setShowNewTermBoost] = useState(false); + const [editingBoost, setEditingBoost] = useState<{ + boost?: FieldValueBoost; + } | null>(null); + const [isRestoreOpen, setIsRestoreOpen] = useState(false); + + const applyConfig = useCallback( + (next: SearchSettings) => { + setConfig(next); + setAppPreferences({ searchConfig: next }); + if (entityType) { + const entityConfig = getEntitySearchConfig(next, entityType); + setDraft( + toEntityDraft( + entityConfig ?? undefined, + getEffectiveRankingConfiguration(next, entityConfig ?? null) + ) + ); + } + setIsChanged(false); + }, + [entityType, setAppPreferences] + ); + + const fetchConfig = useCallback(async () => { + try { + applyConfig( + (await getSettingsByType(SettingType.SearchSettings)) as SearchSettings + ); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, [applyConfig]); + + // Once per mount (the page remounts per entity): under StrictMode a second + // response would reset a draft the user has already started editing. + const hasLoadedRef = useRef(false); + useEffect(() => { + if (!entityType) { + // A stale or mistyped link: back to the entity list. + onNavigate({ type: 'page', page: 'search', isEditing: false }); + + return; + } + if (hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + void fetchConfig(); + getCustomPropertiesByEntityType(entityType) + .then((properties) => + setCustomProperties( + properties.map((property) => ({ + name: `extension.${property.name}`, + description: `${property.description ?? property.displayName} (${t( + 'label.custom-property' + )})`, + })) + ) + ) + .catch(() => setCustomProperties([])); + // Load once per entity; `t` is not a reason to refetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entityType]); + + const updateDraft = (update: Partial) => { + setDraft((prev) => ({ ...prev, ...update })); + setIsChanged(true); + }; + + const handleSave = useCallback(async () => { + if (!config || !entityType) { + return; + } + setIsSaving(true); + try { + const { data } = await updateSettingsConfig({ + config_type: SettingType.SearchSettings, + config_value: withEntityDraft(config, entityType, draft), + } as Settings); + applyConfig(data.config_value as SearchSettings); + showSuccessToast( + t('server.update-entity-success', { + entity: t('label.search-setting-plural'), + }) + ); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsSaving(false); + } + }, [applyConfig, config, draft, entityType, t]); + + const handleRestore = async () => { + setIsSaving(true); + try { + await restoreSettingsConfig(SettingType.SearchSettings); + await fetchConfig(); + showSuccessToast( + t('server.restore-entity-success', { + entity: t('label.search-setting-plural'), + }) + ); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsSaving(false); + setIsRestoreOpen(false); + } + }; + + useEffect(() => { + onSetHeaderActions( + + + + + ); + + return () => onSetHeaderActions(undefined); + }, [config, handleSave, isChanged, isSaving, onSetHeaderActions, t]); + + const entityFields = useMemo(() => { + const allowed = + config?.allowedFields?.find((item) => item.entityType === entityType) + ?.fields ?? []; + + return [...allowed, ...customProperties]; + }, [config, customProperties, entityType]); + + const unselectedFields = entityFields.filter( + (field) => !draft.searchFields.some((item) => item.field === field.name) + ); + + const previewConfig = useMemo( + () => + config && entityType + ? withEntityDraft(config, entityType, draft) + : undefined, + [config, draft, entityType] + ); + + if (!config || !entityType || !previewConfig) { + return ; + } + + const handleTermBoostChange = (boost: TermBoost) => { + if (!boost.value || !boost.boost) { + return; + } + updateDraft({ termBoosts: upsertTermBoost(draft.termBoosts, boost) }); + setShowNewTermBoost(false); + }; + + const handleTermBoostDelete = (value: string) => { + // A new card that never got a boost was never added to the draft. + if (!draft.termBoosts.some((boost) => boost.value === value)) { + setShowNewTermBoost(false); + + return; + } + updateDraft({ + termBoosts: draft.termBoosts.filter((boost) => boost.value !== value), + }); + }; + + return ( +
+ + + + {t('label.ranking-detail-plural')} + + updateDraft({ ranking })} + /> + + + + + + { + setLastAddedField(String(key)); + updateDraft({ + searchFields: toggleSearchField( + draft.searchFields, + String(key) + ), + }); + }}> + {unselectedFields.map((field) => ( + + {field.name} + + ))} + + + + } + count={draft.searchFields.length} + testId="field-configurations" + title={t('label.matching-fields')}> + {draft.searchFields.map((field) => { + const entityField = entityFields.find( + (item) => item.name === field.field + ); + + return ( + + updateDraft({ + searchFields: updateSearchField( + draft.searchFields, + field.field, + update + ), + }) + } + onDelete={() => + updateDraft({ + searchFields: toggleSearchField( + draft.searchFields, + field.field + ), + }) + } + onToggleHighlight={() => + updateDraft({ + highlightFields: toggleHighlightField( + draft.highlightFields, + field.field + ), + }) + } + /> + ); + })} +
+ + +
+
+ + setShowNewTermBoost(true)}> + {t('label.add')} + + } + count={draft.termBoosts.length} + testId="term-boosts" + title={t('label.term-boost')}> + + {draft.termBoosts.map((boost) => ( + + ))} + {showNewTermBoost && ( + + )} + + + + setEditingBoost({})}> + {t('label.add')} + + } + count={draft.fieldValueBoosts.length} + testId="field-value-boosts" + title={t('label.field-value-boost')}> + + updateDraft({ + fieldValueBoosts: draft.fieldValueBoosts.filter( + (boost) => boost.field !== field + ), + }) + } + onEdit={(boost) => setEditingBoost({ boost })} + /> + +
+ + + + {editingBoost && ( + field.name + ) ?? [] + } + onClose={() => setEditingBoost(null)} + onSave={(boost) => { + updateDraft({ + fieldValueBoosts: upsertFieldValueBoost( + draft.fieldValueBoosts, + boost + ), + }); + setEditingBoost(null); + }} + /> + )} + + setIsRestoreOpen(false)} + onConfirm={handleRestore} + /> +
+ ); +}; + +export default EntitySearchSettings; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostDialog.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostDialog.tsx new file mode 100644 index 000000000000..e19996ab5160 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostDialog.tsx @@ -0,0 +1,252 @@ +/* + * Copyright 2026 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 type { FieldProp } from '@openmetadata/ui-core-components'; +import { + Box, + Button, + Dialog, + FieldTypes, + FormField, + getField, + HookForm, + Modal, + ModalOverlay, + Slider, + Typography, +} from '@openmetadata/ui-core-components'; +import { useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { + FieldValueBoost, + Modifier, +} from '../../../../../../../generated/configuration/searchSettings'; +import { BOOST_STEP, MAX_BOOST } from './TermBoostCard'; + +interface SelectItem { + id: string; + label: string; +} + +interface FieldValueBoostFormValues { + field: SelectItem | null; + factor: number; + modifier: SelectItem | null; + missing: string; + gte: string; + lte: string; + gt: string; + lt: string; +} + +const RANGE_KEYS = ['gte', 'lte', 'gt', 'lt'] as const; + +const MODIFIER_ITEMS: SelectItem[] = Object.values(Modifier).map((value) => ({ + id: value, + label: value, +})); + +const toItem = (value?: string) => (value ? { id: value, label: value } : null); + +export const toFieldValueBoostFormValues = ( + boost?: FieldValueBoost +): FieldValueBoostFormValues => ({ + field: toItem(boost?.field), + factor: boost?.factor ?? 0, + modifier: toItem(boost?.modifier ?? Modifier.None), + missing: boost?.missing?.toString() ?? '0', + gte: boost?.condition?.range?.gte?.toString() ?? '', + lte: boost?.condition?.range?.lte?.toString() ?? '', + gt: boost?.condition?.range?.gt?.toString() ?? '', + lt: boost?.condition?.range?.lt?.toString() ?? '', +}); + +/** Empty range bounds are left out rather than sent as 0, as the classic modal did. */ +export const toFieldValueBoost = ( + values: FieldValueBoostFormValues +): FieldValueBoost => ({ + field: values.field?.id ?? '', + factor: values.factor, + modifier: (values.modifier?.id as Modifier) ?? Modifier.None, + missing: values.missing ? Number(values.missing) : 0, + condition: { + range: Object.fromEntries( + RANGE_KEYS.filter((key) => values[key] !== '').map((key) => [ + key, + Number(values[key]), + ]) + ), + }, +}); + +interface FieldValueBoostDialogProps { + /** Present when editing; its field cannot change. */ + boost?: FieldValueBoost; + fieldOptions: string[]; + onSave: (boost: FieldValueBoost) => void | Promise; + onClose: () => void; +} + +const FieldValueBoostDialog = ({ + boost, + fieldOptions, + onSave, + onClose, +}: FieldValueBoostDialogProps) => { + const { t } = useTranslation(); + const form = useForm({ + defaultValues: toFieldValueBoostFormValues(boost), + }); + const title = t(boost ? 'label.edit-entity' : 'label.add-entity', { + entity: t('label.field-value-boost'), + }); + + const fields: FieldProp[] = useMemo( + () => [ + { + name: 'field', + label: t('label.field'), + type: FieldTypes.SELECT, + required: true, + placeholder: t('label.select-field', { field: t('label.field') }), + props: { + 'data-testid': 'field-select', + isDisabled: Boolean(boost), + items: fieldOptions.map((option) => ({ id: option, label: option })), + }, + rules: { required: t('message.field-required') }, + }, + { + name: 'modifier', + label: t('label.modifier'), + type: FieldTypes.SELECT, + props: { 'data-testid': 'modifier-select', items: MODIFIER_ITEMS }, + }, + { + name: 'missing', + label: t('label.missing-value'), + type: FieldTypes.NUMBER, + props: { 'data-testid': 'missing-value-input' }, + }, + ], + [boost, fieldOptions, t] + ); + + const rangeFields: FieldProp[] = RANGE_KEYS.map((key) => { + const labelKey = { + gte: 'label.greater-than-or-equal-to', + lte: 'label.less-than-or-equal-to', + gt: 'label.greater-than', + lt: 'label.less-than', + }[key]; + + return { + name: key, + label: t(labelKey), + type: FieldTypes.NUMBER, + props: { 'data-testid': `${key}-input` }, + }; + }); + + const [fieldField, modifierField, missingField] = fields; + + return ( + !open && onClose()}> + + + + onSave(toFieldValueBoost(values)) + )}> + + + {getField(fieldField)} + + {({ field }) => ( + + + + {t('label.impact')} + + + {field.value} + + + + field.onChange( + Array.isArray(value) ? value[0] : value + ) + } + /> + + )} + +
+ {getField(modifierField)} + {getField(missingField)} +
+ + + {t('label.range-condition')} + +
+ {rangeFields.map((rangeField) => ( +
{getField(rangeField)}
+ ))} +
+
+
+
+ +
+ + +
+
+
+
+
+
+ ); +}; + +export default FieldValueBoostDialog; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostTable.tsx new file mode 100644 index 000000000000..cd45de53c948 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostTable.tsx @@ -0,0 +1,156 @@ +/* + * Copyright 2026 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 { + Box, + ButtonUtility, + Table, + TableCard, + Typography, +} from '@openmetadata/ui-core-components'; +import { Edit01, Trash01 } from '@openmetadata/ui-core-components/icons'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FieldValueBoost } from '../../../../../../../generated/configuration/searchSettings'; + +interface FieldValueBoostTableProps { + boosts: FieldValueBoost[]; + /** The entity page shows field and factor only, as the classic page does. */ + isCompact?: boolean; + testId: string; + onEdit: (boost: FieldValueBoost) => void; + onDelete: (field: string) => void; +} + +type RangeKey = 'gt' | 'gte' | 'lt' | 'lte'; + +const RANGE_COLUMNS: { id: RangeKey; labelKey: string }[] = [ + { id: 'gt', labelKey: 'label.greater-than' }, + { id: 'gte', labelKey: 'label.greater-than-or-equal-to' }, + { id: 'lt', labelKey: 'label.less-than' }, + { id: 'lte', labelKey: 'label.less-than-or-equal-to' }, +]; + +const FieldValueBoostTable = ({ + boosts, + isCompact = false, + testId, + onEdit, + onDelete, +}: FieldValueBoostTableProps) => { + const { t } = useTranslation(); + + const columns = useMemo( + () => [ + { id: 'field', label: t('label.field') }, + { id: 'factor', label: t('label.factor') }, + ...(isCompact + ? [] + : [ + { id: 'modifier', label: t('label.modifier') }, + { id: 'missing', label: t('label.missing-value') }, + ...RANGE_COLUMNS.map(({ id, labelKey }) => ({ + id, + label: t(labelKey), + })), + ]), + { id: 'actions', label: t('label.action-plural') }, + ], + [isCompact, t] + ); + + const renderCell = (boost: FieldValueBoost, columnId: string) => { + switch (columnId) { + case 'field': + return ( + + {boost.field} + + ); + case 'factor': + return boost.factor; + case 'modifier': + return boost.modifier ?? '-'; + case 'missing': + return boost.missing ?? '-'; + case 'actions': + return ( + + onEdit(boost)} + /> + onDelete(boost.field)} + /> + + ); + default: + return boost.condition?.range?.[columnId as RangeKey] ?? '-'; + } + }; + + return ( + + + + {(column) => ( + + )} + + ({ ...boost, id: boost.field }))} + renderEmptyState={() => ( + + {t('label.no-entity', { entity: t('label.field-value-boost') })} + + )}> + {(boost) => ( + + {(column) => ( + + {renderCell(boost, column.id)} + + )} + + )} + +
+
+ ); +}; + +export default FieldValueBoostTable; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/GlobalSearchSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/GlobalSearchSettings.tsx new file mode 100644 index 000000000000..cba2567dd060 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/GlobalSearchSettings.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2026 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 { Toggle } from '@openmetadata/ui-core-components'; +import { useTranslation } from 'react-i18next'; +import { globalSettings as NUMBER_SETTINGS } from '../../../../../../../constants/SearchSettings.constant'; +import { GlobalSettings } from '../../../../../../../generated/configuration/searchSettings'; +import SettingsSection, { + ReadOnlyRow, +} from '../../../components/SettingsSection'; +import NumberSettingRow from './NumberSettingRow'; + +interface GlobalSearchSettingsProps { + globalSettings: GlobalSettings; + isDisabled: boolean; + onUpdate: ( + update: Partial, + successMessage?: string + ) => Promise; + /** Turning column indexing off is confirmed first: it drops column search. */ + onDisableColumnIndexing: () => void; +} + +/** Toggles and limits that apply to every index, each saved on change. */ +const GlobalSearchSettings = ({ + globalSettings, + isDisabled, + onUpdate, + onDisableColumnIndexing, +}: GlobalSearchSettingsProps) => { + const { t } = useTranslation(); + // Installs that predate the setting have no stored value, and they index columns. + const columnIndexing = globalSettings.enableColumnIndexing ?? true; + + return ( + + + onUpdate({ enableAccessControl: enabled })} + /> + + + + columnIndexing + ? onDisableColumnIndexing() + : onUpdate( + { enableColumnIndexing: true }, + t('message.column-indexing-enabled-reindex') + ) + } + /> + + {NUMBER_SETTINGS.map(({ key, label, min, max }) => ( + onUpdate({ [key]: value })} + /> + ))} + + ); +}; + +export default GlobalSearchSettings; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/HybridWeightsSection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/HybridWeightsSection.tsx new file mode 100644 index 000000000000..bb5872a1c3d2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/HybridWeightsSection.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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 { + Box, + Button, + Slider, + Typography, +} from '@openmetadata/ui-core-components'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SearchSectionTitle } from './SearchSection'; +import { + complementWeight, + DEFAULT_SEMANTIC_WEIGHT, +} from './SearchSettings.utils'; + +interface HybridWeightsSectionProps { + savedWeight?: number; + isDisabled: boolean; + onSave: (semanticWeight: number) => void; +} + +/** One slider splits the ranking between semantic and keyword matching. */ +const HybridWeightsSection = ({ + savedWeight = DEFAULT_SEMANTIC_WEIGHT, + isDisabled, + onSave, +}: HybridWeightsSectionProps) => { + const { t } = useTranslation(); + const [weight, setWeight] = useState(savedWeight); + + return ( + + + + {t('label.hybrid-search-weight-plural')} + + + + + + {`${t('label.keyword')}: ${complementWeight(weight).toFixed(1)}`} + + + setWeight(Array.isArray(value) ? value[0] : value) + } + /> + + {`${t('label.semantic')}: ${weight.toFixed(1)}`} + + + + ); +}; + +export default HybridWeightsSection; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/MatchingFieldCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/MatchingFieldCard.tsx new file mode 100644 index 000000000000..6165d3cb2e1b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/MatchingFieldCard.tsx @@ -0,0 +1,195 @@ +/* + * Copyright 2026 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 { + Badge, + Box, + ButtonUtility, + Card, + Select, + Slider, + Toggle, + Tooltip, + Typography, +} from '@openmetadata/ui-core-components'; +import { + ChevronDown, + ChevronUp, + Trash01, +} from '@openmetadata/ui-core-components/icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FieldBoost, + SearchFieldMatchType as MatchType, +} from '../../../../../../../generated/configuration/searchSettings'; +import { BOOST_STEP, MAX_BOOST } from './TermBoostCard'; + +interface MatchingFieldCardProps { + field: FieldBoost; + description?: string; + /** The server only allows highlighting analyzed text fields. */ + isHighlightAllowed: boolean; + isHighlighted: boolean; + initialOpen?: boolean; + onChange: (update: Partial) => void; + onToggleHighlight: () => void; + onDelete: () => void; +} + +/** One matching field: weight, match type and highlighting, collapsed to a summary. */ +const MatchingFieldCard = ({ + field, + description, + isHighlightAllowed, + isHighlighted, + initialOpen = false, + onChange, + onToggleHighlight, + onDelete, +}: MatchingFieldCardProps) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(initialOpen); + const weight = field.boost ?? 0; + const matchTypeItems = [ + { id: MatchType.Exact, label: t('label.exact-match') }, + { id: MatchType.Phrase, label: t('label.phrase-match') }, + { id: MatchType.Fuzzy, label: t('label.fuzzy-match') }, + { id: MatchType.Standard, label: t('label.standard-match') }, + ]; + const highlightToggle = ( + + ); + + return ( + + + + + + + {field.field} + + {field.field.startsWith('extension.') && ( + + {t('label.custom')} + + )} + + + + {weight} + + setIsOpen((open) => !open)} + /> + + + + + {description ?? t('label.no-description')} + + + {isOpen && ( + + + + {t('label.highlight-field-plural')} + + {isHighlightAllowed ? ( + highlightToggle + ) : ( + + {highlightToggle} + + )} + + + + {t('label.weight')} + + {weight} + + + + onChange({ boost: Array.isArray(value) ? value[0] : value }) + } + /> + + + {t('label.match-type')} + + + + )} + + + + ); +}; + +export default MatchingFieldCard; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/NumberSettingRow.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/NumberSettingRow.tsx new file mode 100644 index 000000000000..28389ec46cb8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/NumberSettingRow.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2026 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 { + Box, + ButtonUtility, + Input, + Typography, +} from '@openmetadata/ui-core-components'; +import { Check, Edit01, XClose } from '@openmetadata/ui-core-components/icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReadOnlyRow } from '../../../components/SettingsSection'; + +interface NumberSettingRowProps { + name: string; + label: string; + value: number; + min: number; + max: number; + isDisabled?: boolean; + /** Resolves to whether the value was saved; the editor stays open if not. */ + onSave: (value: number) => Promise; +} + +/** A numeric global setting, edited in place and saved on its own. */ +const NumberSettingRow = ({ + name, + label, + value, + min, + max, + isDisabled, + onSave, +}: NumberSettingRowProps) => { + const { t } = useTranslation(); + const [draft, setDraft] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const draftNumber = draft?.trim() ? Number(draft) : Number.NaN; + const isValid = + Number.isInteger(draftNumber) && draftNumber >= min && draftNumber <= max; + + const save = async () => { + setIsSaving(true); + try { + if (await onSave(draftNumber)) { + setDraft(null); + } + } finally { + setIsSaving(false); + } + }; + + return ( + + {draft === null ? ( + + + {value} + + setDraft(String(value))} + /> + + ) : ( + + + + setDraft(null)} + /> + + )} + + ); +}; + +export default NumberSettingRow; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/RankingSection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/RankingSection.tsx new file mode 100644 index 000000000000..25d7984194fe --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/RankingSection.tsx @@ -0,0 +1,240 @@ +/* + * Copyright 2026 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 { + Badge, + Box, + Card, + Input, + Select, + Toggle, + Typography, +} from '@openmetadata/ui-core-components'; +import { startCase } from 'lodash'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + BoostMode, + RankingConfiguration, + RankingStage, + ScoreMode, +} from '../../../../../../../generated/configuration/searchSettings'; +import { + parseOptionalNumber, + setSignalMaxBoost, + setStageWeight, +} from './SearchSettings.utils'; + +export const BOOST_MODE_ITEMS = Object.values(BoostMode).map((value) => ({ + id: value, + label: startCase(value), +})); + +export const SCORE_MODE_ITEMS = Object.values(ScoreMode).map((value) => ({ + id: value, + label: startCase(value), +})); + +interface LabelledProps { + label: string; + children: ReactNode; +} + +const Labelled = ({ label, children }: LabelledProps) => ( + + + {label} + + {children} + +); + +const FieldBadges = ({ fields = [] }: { fields?: string[] }) => ( + + {fields.map((field) => ( + + {field} + + ))} + +); + +interface RankingSectionProps { + ranking?: RankingConfiguration; + onChange: (ranking: RankingConfiguration) => void; +} + +/** The entity's ranking stages and signals; only weights, modes and the switch are editable. */ +const RankingSection = ({ ranking, onChange }: RankingSectionProps) => { + const { t } = useTranslation(); + + if (!ranking) { + return ( + + {t('message.no-data-available')} + + ); + } + + const { signals } = ranking; + + const renderStage = (stage: RankingStage, index: number) => { + const name = stage.name ?? ''; + const minimumShouldMatch = stage.minimumShouldMatch + ? ` (${stage.minimumShouldMatch})` + : ''; + + return ( + + + + + + {name ? startCase(name) : t('label.no-data')} + + + onChange( + setStageWeight(ranking, index, parseOptionalNumber(value)) + ) + } + /> + + {stage.purpose && ( + + {stage.purpose} + + )} +
+ + + {stage.matchType + ? `${startCase(stage.matchType)}${minimumShouldMatch}` + : t('label.no-data')} + + + + + +
+
+
+
+ ); + }; + + return ( + + + + + + + {ranking.algorithm + ? startCase(ranking.algorithm) + : t('label.no-data')} + + + onChange({ ...ranking, enabled })} + /> + + + + + {(ranking.stages ?? []).map((stage, index) => renderStage(stage, index))} + + {signals && ( + + + + {signals.purpose && ( + + {signals.purpose} + + )} + + {t('message.search-ranking-signals-explanation')} + +
+ + + + onChange( + setSignalMaxBoost(ranking, parseOptionalNumber(value)) + ) + } + /> + + + +
+
+
+
+ )} +
+ ); +}; + +export default RankingSection; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchEntityCards.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchEntityCards.tsx new file mode 100644 index 000000000000..8705b8a0fbb5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchEntityCards.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2026 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 { Box, Card, Typography } from '@openmetadata/ui-core-components'; +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { usePermissionProvider } from '../../../../../../../context/PermissionProvider/PermissionProvider'; +import { useAuth } from '../../../../../../../hooks/authHooks'; +import { getSearchSettingCategories } from '../../../../../../../utils/SearchSettingsUtils'; +import type { PlatformSettingsPageProps } from '../PlatformSettings.types'; +import { SearchSectionTitle } from './SearchSection'; + +/** One card per entity type, opening that entity's search settings. */ +const SearchEntityCards = ({ + onNavigate, +}: Pick) => { + const { t } = useTranslation(); + const { permissions } = usePermissionProvider(); + const { isAdminUser } = useAuth(); + const entityCategories = useMemo( + () => getSearchSettingCategories(permissions, Boolean(isAdminUser)) ?? [], + [permissions, isAdminUser] + ); + + return ( + + {t('label.entity-plural')} +
+ {entityCategories.map((category) => { + const itemId = category.key.split('.')[2]; + const Icon = category.icon as FC<{ className?: string }>; + const open = () => + onNavigate({ + type: 'page', + page: 'search', + isEditing: false, + itemId, + }); + + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }}> + + + {Icon && ( + + )} + + + {category.label} + + + {category.description} + + + + + + ); + })} +
+
+ ); +}; + +export default SearchEntityCards; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.test.tsx new file mode 100644 index 000000000000..daba9cece632 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.test.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 2026 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 { act, fireEvent, render, screen } from '@testing-library/react'; +import { SearchIndex } from '../../../../../../../enums/search.enum'; +import { searchPreview } from '../../../../../../../rest/searchAPI'; +import { showErrorToast } from '../../../../../../../utils/ToastUtils'; +import SearchPreviewPanel from './SearchPreviewPanel'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../../rest/searchAPI', () => ({ + searchPreview: jest.fn(), +})); + +jest.mock('../../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock( + '../../../../../../ExploreV1/ExploreSearchCard/ExploreSearchCard', + () => + ({ + source, + score, + }: { + source: { fullyQualifiedName: string }; + score?: number; + }) => + ( +
+ {source.fullyQualifiedName} + {score !== undefined && ` score:${score}`} +
+ ) +); + +const CONFIG = { assetTypeConfigurations: [{ assetType: 'table' }] }; + +const hits = (count: number, total = count) => ({ + hits: { + hits: Array.from({ length: count }, (_, index) => ({ + _id: String(index), + _score: 1.5, + _source: { fullyQualifiedName: `db.schema.t${index}` }, + })), + total: { value: total }, + }, +}); + +const lastRequest = () => { + const calls = (searchPreview as jest.Mock).mock.calls; + + return calls[calls.length - 1][0]; +}; + +const renderPanel = async () => { + render(); + await act(async () => { + await Promise.resolve(); + }); +}; + +describe('SearchPreviewPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + (searchPreview as jest.Mock).mockResolvedValue(hits(2)); + }); + + it('previews the entity index with the given settings', async () => { + await renderPanel(); + + expect(searchPreview).toHaveBeenCalledWith({ + from: 0, + size: 15, + index: SearchIndex.TABLE, + query: '', + queryFilter: '', + explain: false, + searchSettings: CONFIG, + }); + expect(screen.getAllByTestId('preview-result')).toHaveLength(2); + expect( + screen.queryByTestId('search-preview-empty') + ).not.toBeInTheDocument(); + }); + + it('searches once typing settles', async () => { + await renderPanel(); + fireEvent.change(screen.getByTestId('search-preview-input'), { + target: { value: 'orders' }, + }); + + expect(searchPreview).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(lastRequest().query).toBe('orders'); + }); + + it('previews a changed draft once it stops changing', async () => { + const { rerender } = render( + + ); + await act(async () => { + await Promise.resolve(); + }); + const drafts = [1, 2, 3].map((weight) => ({ + ...CONFIG, + globalSettings: { semanticWeight: weight / 10 }, + })); + for (const draft of drafts) { + rerender(); + await act(async () => { + jest.advanceTimersByTime(100); + }); + } + + expect(searchPreview).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(400); + }); + + expect(searchPreview).toHaveBeenCalledTimes(2); + expect(lastRequest().searchSettings).toBe(drafts[2]); + }); + + it('asks for and shows ranking details when switched on', async () => { + await renderPanel(); + await act(async () => { + fireEvent.click( + screen + .getByTestId('ranking-details-switch') + .querySelector('input') as HTMLInputElement + ); + }); + + expect(lastRequest().explain).toBe(true); + expect(screen.getAllByTestId('preview-result')[0]).toHaveTextContent( + 'score:1.5' + ); + }); + + it('pages through results beyond one page', async () => { + (searchPreview as jest.Mock).mockResolvedValue(hits(15, 40)); + await renderPanel(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /next/i })); + }); + + expect(lastRequest().from).toBe(15); + }); + + it('shows an empty state with no results and a toast on failure', async () => { + (searchPreview as jest.Mock).mockResolvedValue(hits(0)); + await renderPanel(); + + expect(screen.getByTestId('search-preview-empty')).toBeInTheDocument(); + + (searchPreview as jest.Mock).mockRejectedValue(new Error('boom')); + fireEvent.change(screen.getByTestId('search-preview-input'), { + target: { value: 'x' }, + }); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(showErrorToast).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx new file mode 100644 index 000000000000..664ef7ab4612 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx @@ -0,0 +1,208 @@ +/* + * Copyright 2026 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 { + Box, + EmptyPlaceholder, + Input, + PaginationCardWithControls, + Toggle, +} from '@openmetadata/ui-core-components'; +import { Search } from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import { debounce } from 'lodash'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PAGE_SIZE_BASE } from '../../../../../../../constants/constants'; +import { ENTITY_PATH_TO_SEARCH_INDEX } from '../../../../../../../constants/SearchSettings.constant'; +import { SearchSettings } from '../../../../../../../generated/configuration/searchSettings'; +import { searchPreview } from '../../../../../../../rest/searchAPI'; +import { showErrorToast } from '../../../../../../../utils/ToastUtils'; +import ExploreSearchCard from '../../../../../../ExploreV1/ExploreSearchCard/ExploreSearchCard'; +import { SearchedDataProps } from '../../../../../../SearchedData/SearchedData.interface'; +import { SettingsSkeleton } from '../SettingsFormLayout'; +import { SearchSectionTitle } from './SearchSection'; + +const SEARCH_DEBOUNCE_MS = 1000; +const CONFIG_DEBOUNCE_MS = 400; + +interface SearchPreviewPanelProps { + entityType: string; + /** The full settings with the unsaved draft applied. */ + searchConfig: SearchSettings; +} + +/** Live results for the entity's index, ranked by the unsaved settings. */ +const SearchPreviewPanel = ({ + entityType, + searchConfig, +}: SearchPreviewPanelProps) => { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + const [showRankingDetails, setShowRankingDetails] = useState(false); + const [hits, setHits] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const latestRequestId = useRef(0); + // Slider drags change the draft many times a second; preview once it settles. + const [previewConfig, setPreviewConfig] = useState(searchConfig); + + useEffect(() => { + const handle = setTimeout( + () => setPreviewConfig(searchConfig), + CONFIG_DEBOUNCE_MS + ); + + return () => clearTimeout(handle); + }, [searchConfig]); + + const debouncedSetSearchTerm = useMemo( + () => + debounce((value: string) => { + setSearchTerm(value); + setPage(1); + }, SEARCH_DEBOUNCE_MS), + [] + ); + + useEffect( + () => () => debouncedSetSearchTerm.cancel(), + [debouncedSetSearchTerm] + ); + + useEffect(() => { + // A slow, superseded response must never overwrite the latest preview. + const requestId = ++latestRequestId.current; + const fetchPreview = async () => { + setIsLoading(true); + try { + const response = await searchPreview({ + from: (page - 1) * pageSize, + size: pageSize, + index: ENTITY_PATH_TO_SEARCH_INDEX[entityType], + query: searchTerm, + queryFilter: '', + explain: showRankingDetails, + searchSettings: previewConfig, + }); + if (requestId === latestRequestId.current) { + setHits(response.hits.hits as unknown as SearchedDataProps['data']); + setTotal(response.hits.total.value ?? 0); + } + } catch (error) { + if (requestId === latestRequestId.current) { + showErrorToast(error as AxiosError); + } + } finally { + if (requestId === latestRequestId.current) { + setIsLoading(false); + } + } + }; + void fetchPreview(); + }, [ + entityType, + page, + pageSize, + previewConfig, + searchTerm, + showRankingDetails, + ]); + + const renderResults = () => { + if (isLoading) { + return ; + } + + if (hits.length === 0) { + return ( +
+ } + title={t('message.no-data-available')} + /> +
+ ); + } + + return hits.map( + ({ + _score, + _source, + _id = '', + highlight, + _explanation, + matched_queries, + }) => ( + + ) + ); + }; + + return ( + + + {t('label.preview')} + + + { + setQuery(value); + debouncedSetSearchTerm(value); + }} + /> + + {renderResults()} + + {total > pageSize && ( + { + setPageSize(next); + setPage(1); + }} + /> + )} + + ); +}; + +export default SearchPreviewPanel; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSection.tsx new file mode 100644 index 000000000000..c28aa27ef283 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSection.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2026 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 { Badge, Box, Typography } from '@openmetadata/ui-core-components'; +import type { ReactNode } from 'react'; + +export const SearchSectionTitle = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +interface SearchSectionProps { + title: string; + count: number; + testId: string; + actions: ReactNode; + children: ReactNode; +} + +/** Caption with a count and actions, above the boost content. */ +const SearchSection = ({ + title, + count, + testId, + actions, + children, +}: SearchSectionProps) => ( + + + + {title} + + {count} + + + + {actions} + + + {children} + +); + +export default SearchSection; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettings.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettings.utils.test.ts new file mode 100644 index 000000000000..06fe88dffafe --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettings.utils.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2026 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 { + complementWeight, + parseOptionalNumber, + setSignalMaxBoost, + setStageWeight, + toEntityDraft, + toggleHighlightField, + toggleSearchField, + updateSearchField, + upsertFieldValueBoost, + upsertTermBoost, + withEntityDraft, +} from './SearchSettings.utils'; + +describe('SearchSettings.utils', () => { + it('keeps keyword and semantic weights summing to 1', () => { + expect(complementWeight(0.4)).toBe(0.6); + expect(complementWeight(0.7)).toBe(0.3); + expect(complementWeight(1)).toBe(0); + }); + + it('adds a term boost, or replaces the one for the same tag', () => { + const tier1 = { field: 'tier.tagFQN', value: 'Tier.Tier1', boost: 5 }; + const pii = { field: 'tags.tagFQN', value: 'PII.Sensitive', boost: 2 }; + + expect(upsertTermBoost([tier1], pii)).toEqual([tier1, pii]); + expect(upsertTermBoost([tier1, pii], { ...tier1, boost: 9 })).toEqual([ + { ...tier1, boost: 9 }, + pii, + ]); + expect(upsertTermBoost(undefined, pii)).toEqual([pii]); + }); + + it('adds a field value boost, or replaces the one for the same field', () => { + const votes = { field: 'totalVotes', factor: 1 }; + + expect( + upsertFieldValueBoost([votes], { field: 'usage', factor: 2 }) + ).toEqual([votes, { field: 'usage', factor: 2 }]); + expect(upsertFieldValueBoost([votes], { ...votes, factor: 3 })).toEqual([ + { ...votes, factor: 3 }, + ]); + }); + + it('puts a new matching field first with no boost, and removes it when toggled again', () => { + const fields = [{ field: 'name', boost: 10 }]; + const added = toggleSearchField(fields, 'description'); + + expect(added).toEqual([{ field: 'description', boost: 0 }, ...fields]); + expect(toggleSearchField(added, 'description')).toEqual(fields); + }); + + it('updates one matching field and toggles highlights', () => { + expect( + updateSearchField( + [ + { field: 'name', boost: 10 }, + { field: 'description', boost: 1 }, + ], + 'description', + { boost: 4 } + ) + ).toEqual([ + { field: 'name', boost: 10 }, + { field: 'description', boost: 4 }, + ]); + expect(toggleHighlightField(['name'], 'description')).toEqual([ + 'name', + 'description', + ]); + expect(toggleHighlightField(['name'], 'name')).toEqual([]); + }); + + it('clears a stage weight or max boost instead of storing 0 when the input is emptied', () => { + const ranking = { + stages: [ + { name: 'exact', fields: ['name'], weight: 100 }, + { name: 'phrase', fields: ['name'], weight: 70 }, + ], + signals: { maxBoost: 2 }, + }; + + expect(setStageWeight(ranking, 1, 50).stages).toEqual([ + { name: 'exact', fields: ['name'], weight: 100 }, + { name: 'phrase', fields: ['name'], weight: 50 }, + ]); + expect(setStageWeight(ranking, 0, null).stages?.[0]).toEqual({ + name: 'exact', + fields: ['name'], + }); + expect(setSignalMaxBoost(ranking, 3).signals).toEqual({ maxBoost: 3 }); + expect(setSignalMaxBoost(ranking, null).signals).toEqual({}); + }); + + it('parses optional numeric inputs', () => { + expect(parseOptionalNumber('')).toBeNull(); + expect(parseOptionalNumber(' ')).toBeNull(); + expect(parseOptionalNumber('abc')).toBeNull(); + expect(parseOptionalNumber('1.5')).toBe(1.5); + }); + + it('builds a draft from an entity configuration and writes it back to that entity only', () => { + const table = { assetType: 'table', searchFields: [{ field: 'name' }] }; + const topic = { assetType: 'topic', searchFields: [{ field: 'name' }] }; + + expect(toEntityDraft()).toEqual({ + searchFields: [], + highlightFields: [], + termBoosts: [], + fieldValueBoosts: [], + scoreMode: undefined, + boostMode: undefined, + ranking: undefined, + }); + + const draft = { + ...toEntityDraft(table), + searchFields: [{ field: 'description', boost: 3 }], + }; + const result = withEntityDraft( + { assetTypeConfigurations: [table, topic] }, + 'table', + draft + ); + + expect(result.assetTypeConfigurations?.[0]).toEqual({ + ...table, + ...draft, + }); + expect(result.assetTypeConfigurations?.[1]).toBe(topic); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettings.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettings.utils.ts new file mode 100644 index 000000000000..fd875657106f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettings.utils.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2026 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 { omit } from 'lodash'; +import { + AssetTypeConfiguration, + BoostMode, + FieldBoost, + FieldValueBoost, + RankingConfiguration, + ScoreMode, + SearchSettings, + TermBoost, +} from '../../../../../../../generated/configuration/searchSettings'; + +export const DEFAULT_SEMANTIC_WEIGHT = 0.4; + +/** Keyword and semantic weights always sum to 1, rounded to one decimal. */ +export const complementWeight = (weight: number) => + Math.round((1 - weight) * 10) / 10; + +/** Adds the boost, or replaces the one for the same tag. */ +export const upsertTermBoost = ( + boosts: TermBoost[] | undefined, + boost: TermBoost +): TermBoost[] => { + const list = boosts ?? []; + const index = list.findIndex((item) => item.value === boost.value); + + return index >= 0 + ? list.map((item, i) => (i === index ? boost : item)) + : [...list, boost]; +}; + +/** Adds the boost, or replaces the one for the same field. */ +export const upsertFieldValueBoost = ( + boosts: FieldValueBoost[] | undefined, + boost: FieldValueBoost +): FieldValueBoost[] => { + const list = boosts ?? []; + const index = list.findIndex((item) => item.field === boost.field); + + return index >= 0 + ? list.map((item, i) => (i === index ? boost : item)) + : [...list, boost]; +}; + +/** The per-entity settings the entity page edits as one draft. */ +export interface EntitySearchDraft { + searchFields: FieldBoost[]; + highlightFields: string[]; + termBoosts: TermBoost[]; + fieldValueBoosts: FieldValueBoost[]; + scoreMode?: ScoreMode; + boostMode?: BoostMode; + ranking?: RankingConfiguration; +} + +export const toEntityDraft = ( + config?: AssetTypeConfiguration, + ranking?: RankingConfiguration +): EntitySearchDraft => ({ + searchFields: config?.searchFields ?? [], + highlightFields: config?.highlightFields ?? [], + termBoosts: config?.termBoosts ?? [], + fieldValueBoosts: config?.fieldValueBoosts ?? [], + scoreMode: config?.scoreMode, + boostMode: config?.boostMode, + ranking, +}); + +/** The full settings with one entity's configuration replaced by the draft. */ +export const withEntityDraft = ( + config: SearchSettings, + assetType: string, + draft: EntitySearchDraft +): SearchSettings => ({ + ...config, + assetTypeConfigurations: config.assetTypeConfigurations?.map((item) => + item.assetType === assetType ? { ...item, ...draft } : item + ), +}); + +/** Adding a matching field puts it first with no boost; adding it again removes it. */ +export const toggleSearchField = ( + fields: FieldBoost[], + fieldName: string +): FieldBoost[] => + fields.some((field) => field.field === fieldName) + ? fields.filter((field) => field.field !== fieldName) + : [{ field: fieldName, boost: 0 }, ...fields]; + +export const updateSearchField = ( + fields: FieldBoost[], + fieldName: string, + update: Partial +): FieldBoost[] => + fields.map((field) => + field.field === fieldName ? { ...field, ...update } : field + ); + +export const toggleHighlightField = (fields: string[], fieldName: string) => + fields.includes(fieldName) + ? fields.filter((field) => field !== fieldName) + : [...fields, fieldName]; + +/** An emptied numeric input clears the value instead of storing 0. */ +export const setStageWeight = ( + ranking: RankingConfiguration, + stageIndex: number, + weight: number | null +): RankingConfiguration => ({ + ...ranking, + stages: (ranking.stages ?? []).map((stage, index) => { + if (index !== stageIndex) { + return stage; + } + + return weight === null ? omit(stage, 'weight') : { ...stage, weight }; + }), +}); + +export const setSignalMaxBoost = ( + ranking: RankingConfiguration, + maxBoost: number | null +): RankingConfiguration => ({ + ...ranking, + signals: + maxBoost === null + ? omit(ranking.signals ?? {}, 'maxBoost') + : { ...ranking.signals, maxBoost }, +}); + +/** Parses a numeric input: '' (cleared) becomes null. */ +export const parseOptionalNumber = (value: string): number | null => + value.trim() === '' || Number.isNaN(Number(value)) ? null : Number(value); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.test.tsx new file mode 100644 index 000000000000..0c1448465ce9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.test.tsx @@ -0,0 +1,474 @@ +/* + * Copyright 2026 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 { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { ReactNode } from 'react'; +import { + getSettingsByType, + restoreSettingsConfig, + updateSettingsConfig, +} from '../../../../../../../rest/settingConfigAPI'; +import searchSettingsClassBase from '../../../../../../../utils/SearchSettingsClassBase'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../../utils/ToastUtils'; +import SearchSettingsView from './SearchSettingsView'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../../rest/settingConfigAPI', () => ({ + getSettingsByType: jest.fn(), + restoreSettingsConfig: jest.fn(), + updateSettingsConfig: jest.fn(), +})); + +jest.mock('../../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +const mockSetAppPreferences = jest.fn(); +jest.mock('../../../../../../../hooks/useApplicationStore', () => ({ + useApplicationStore: ( + selector: (state: { setAppPreferences: jest.Mock }) => unknown + ) => selector({ setAppPreferences: mockSetAppPreferences }), +})); + +let mockIsAdmin = true; +jest.mock('../../../../../../../hooks/authHooks', () => ({ + useAuth: () => ({ isAdminUser: mockIsAdmin }), +})); + +jest.mock( + '../../../../../../../context/PermissionProvider/PermissionProvider', + () => ({ usePermissionProvider: () => ({ permissions: {} }) }) +); + +jest.mock('../../../../../../../utils/SearchSettingsUtils', () => ({ + getSearchSettingCategories: () => [ + { + key: 'preferences.search-settings.tables', + label: 'Tables', + description: 'Table search', + isProtected: true, + }, + ], +})); + +jest.mock('../../../../../../../utils/TagClassBase', () => ({ + __esModule: true, + default: { getTags: jest.fn().mockResolvedValue({ data: [] }) }, +})); + +const CONFIG = { + globalSettings: { + enableAccessControl: false, + enableColumnIndexing: true, + maxAggregateSize: 10000, + maxResultHits: 10000, + maxAnalyzedOffset: 1000, + termBoosts: [ + { field: 'tier.tagFQN', value: 'Tier.Tier1', boost: 5 }, + { field: 'tags.tagFQN', value: 'PII.Sensitive', boost: 2 }, + ], + fieldValueBoosts: [ + { field: 'totalVotes', factor: 0.0015 }, + { field: 'usageSummary.weeklyStats.count', factor: 0.00005 }, + ], + }, + allowedFieldValueBoosts: [{ fields: [{ name: 'totalVotes' }] }], + assetTypeConfigurations: [{ assetType: 'table' }], +}; + +const onNavigate = jest.fn(); +let headerActions: ReactNode; +const onSetHeaderActions = (actions: ReactNode) => { + headerActions = actions; +}; + +const renderView = async () => { + render( + + ); + await screen.findByTestId('search-settings'); +}; + +const savedGlobalSettings = (call = 0) => + (updateSettingsConfig as jest.Mock).mock.calls[call][0].config_value + .globalSettings; + +const clickToggle = (testId: string) => + fireEvent.click( + screen.getByTestId(testId).querySelector('input') as HTMLInputElement + ); + +describe('SearchSettingsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsAdmin = true; + headerActions = undefined; + (getSettingsByType as jest.Mock).mockResolvedValue(CONFIG); + (updateSettingsConfig as jest.Mock).mockImplementation(async (body) => ({ + data: body, + })); + (restoreSettingsConfig as jest.Mock).mockResolvedValue({ data: {} }); + }); + + it('shows the global settings, boosts and one card per entity', async () => { + await renderView(); + + expect( + screen.getByTestId('global-setting-value-maxAggregateSize') + ).toHaveTextContent('10000'); + expect(screen.getByTestId('term-boosts')).toHaveTextContent('2'); + expect(screen.getByTestId('term-boost-Tier.Tier1')).toBeInTheDocument(); + expect( + within(screen.getByTestId('field-value-boost-table')).getByText( + 'totalVotes' + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('search-entity-card-tables')).toHaveTextContent( + 'Tables' + ); + expect(mockSetAppPreferences).toHaveBeenCalledWith({ + searchConfig: CONFIG, + }); + }); + + it('saves the full config when access control is toggled', async () => { + await renderView(); + clickToggle('enable-roles-polices-in-search-switch'); + + await waitFor(() => expect(updateSettingsConfig).toHaveBeenCalled()); + + const body = (updateSettingsConfig as jest.Mock).mock.calls[0][0]; + + expect(body.config_value.assetTypeConfigurations).toEqual( + CONFIG.assetTypeConfigurations + ); + expect(savedGlobalSettings()).toEqual({ + ...CONFIG.globalSettings, + enableAccessControl: true, + }); + }); + + it('asks before turning column indexing off', async () => { + await renderView(); + clickToggle('enable-column-indexing-switch'); + + expect(updateSettingsConfig).not.toHaveBeenCalled(); + + fireEvent.click( + await screen.findByTestId('disable-column-indexing-dialog-confirm') + ); + + await waitFor(() => + expect(savedGlobalSettings().enableColumnIndexing).toBe(false) + ); + }); + + it('tells the admin to reindex when column indexing is turned on', async () => { + (getSettingsByType as jest.Mock).mockResolvedValue({ + ...CONFIG, + globalSettings: { ...CONFIG.globalSettings, enableColumnIndexing: false }, + }); + await renderView(); + clickToggle('enable-column-indexing-switch'); + + await waitFor(() => + expect(showSuccessToast).toHaveBeenCalledWith( + 'message.column-indexing-enabled-reindex' + ) + ); + + expect(savedGlobalSettings().enableColumnIndexing).toBe(true); + }); + + it('saves a number setting only when it is within range', async () => { + await renderView(); + fireEvent.click(screen.getByTestId('global-setting-edit-maxAggregateSize')); + const input = screen.getByTestId('global-setting-input-maxAggregateSize'); + + fireEvent.change(input, { target: { value: '50' } }); + + expect( + screen.getByTestId('global-setting-save-maxAggregateSize') + ).toBeDisabled(); + + fireEvent.change(input, { target: { value: '500' } }); + fireEvent.click(screen.getByTestId('global-setting-save-maxAggregateSize')); + + await waitFor(() => + expect(savedGlobalSettings().maxAggregateSize).toBe(500) + ); + }); + + it('saves straight away when an existing term boost is deleted', async () => { + await renderView(); + fireEvent.click( + within(screen.getByTestId('term-boost-Tier.Tier1')).getByTestId( + 'delete-term-boost' + ) + ); + + await waitFor(() => + expect(savedGlobalSettings().termBoosts).toEqual([ + CONFIG.globalSettings.termBoosts[1], + ]) + ); + }); + + it('drops an unsaved new term boost without saving', async () => { + await renderView(); + fireEvent.click(screen.getByTestId('term-boost-add-btn')); + const newCard = await screen.findByTestId('term-boost-new'); + + expect(screen.getByTestId('term-boost-save-btn')).toBeDisabled(); + + fireEvent.click(within(newCard).getByTestId('delete-term-boost')); + + expect(screen.queryByTestId('term-boost-new')).not.toBeInTheDocument(); + expect(updateSettingsConfig).not.toHaveBeenCalled(); + }); + + it('saves straight away when a field value boost is deleted', async () => { + await renderView(); + fireEvent.click( + within(screen.getByTestId('field-value-boost-totalVotes')).getByTestId( + 'delete-field-value-boost-btn' + ) + ); + + await waitFor(() => + expect(savedGlobalSettings().fieldValueBoosts).toEqual([ + CONFIG.globalSettings.fieldValueBoosts[1], + ]) + ); + }); + + it('saves an edited term boost with Save', async () => { + await renderView(); + const card = screen.getByTestId('term-boost-PII.Sensitive'); + act(() => { + fireEvent.keyDown(within(card).getByRole('slider'), { + key: 'ArrowRight', + }); + }); + fireEvent.click(screen.getByTestId('term-boost-save-btn')); + + await waitFor(() => + expect(savedGlobalSettings().termBoosts).toEqual([ + CONFIG.globalSettings.termBoosts[0], + { ...CONFIG.globalSettings.termBoosts[1], boost: 2.1 }, + ]) + ); + }); + + it('keeps unsaved term boost edits when another setting saves', async () => { + await renderView(); + act(() => { + fireEvent.keyDown( + within(screen.getByTestId('term-boost-PII.Sensitive')).getByRole( + 'slider' + ), + { key: 'ArrowRight' } + ); + }); + clickToggle('enable-roles-polices-in-search-switch'); + + await waitFor(() => expect(updateSettingsConfig).toHaveBeenCalled()); + + // The toggle saves the stored boosts, not the unsaved edit... + expect(savedGlobalSettings().termBoosts).toEqual( + CONFIG.globalSettings.termBoosts + ); + // ...and the edit is still on screen, waiting for its own Save. + expect( + within(screen.getByTestId('term-boost-PII.Sensitive')).getByTestId( + 'term-boost-value' + ) + ).toHaveTextContent('2.1'); + expect(screen.getByTestId('term-boost-save-btn')).toBeEnabled(); + }); + + it('saves an edited field value boost straight away', async () => { + await renderView(); + fireEvent.click( + within(screen.getByTestId('field-value-boost-totalVotes')).getByTestId( + 'edit-field-value-boost-btn' + ) + ); + const dialog = await screen.findByTestId('field-value-boost-dialog'); + fireEvent.change( + within(dialog).getByTestId('lt-input').querySelector('input') as Element, + { target: { value: '9' } } + ); + await act(async () => { + fireEvent.click(within(dialog).getByTestId('save-field-value-boost')); + }); + + await waitFor(() => + expect(savedGlobalSettings().fieldValueBoosts[0]).toEqual( + expect.objectContaining({ + field: 'totalVotes', + condition: { range: { lt: 9 } }, + }) + ) + ); + }); + + it('toasts and keeps the settings when a save fails', async () => { + (updateSettingsConfig as jest.Mock).mockRejectedValue(new Error('boom')); + await renderView(); + clickToggle('enable-roles-polices-in-search-switch'); + + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); + + expect( + screen + .getByTestId('enable-roles-polices-in-search-switch') + .querySelector('input') + ).not.toBeChecked(); + }); + + it('keeps the limit editor and the boost dialog open when a save fails', async () => { + (updateSettingsConfig as jest.Mock).mockRejectedValue(new Error('boom')); + await renderView(); + + fireEvent.click(screen.getByTestId('global-setting-edit-maxAggregateSize')); + fireEvent.change( + screen.getByTestId('global-setting-input-maxAggregateSize'), + { target: { value: '500' } } + ); + await act(async () => { + fireEvent.click( + screen.getByTestId('global-setting-save-maxAggregateSize') + ); + }); + + expect(showErrorToast).toHaveBeenCalled(); + expect( + screen.getByTestId('global-setting-input-maxAggregateSize') + ).toHaveValue(500); + + fireEvent.click( + within(screen.getByTestId('field-value-boost-totalVotes')).getByTestId( + 'edit-field-value-boost-btn' + ) + ); + const dialog = await screen.findByTestId('field-value-boost-dialog'); + await act(async () => { + fireEvent.click(within(dialog).getByTestId('save-field-value-boost')); + }); + + expect(updateSettingsConfig).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('field-value-boost-dialog')).toBeInTheDocument(); + }); + + it('leaves column indexing on when the confirmation is cancelled', async () => { + await renderView(); + clickToggle('enable-column-indexing-switch'); + fireEvent.click( + await screen.findByTestId('disable-column-indexing-dialog-cancel') + ); + + expect(updateSettingsConfig).not.toHaveBeenCalled(); + }); + + it('restores the defaults after confirmation', async () => { + await renderView(); + render(<>{headerActions}); + fireEvent.click(screen.getByTestId('reset-search-settings-btn')); + fireEvent.click( + await screen.findByTestId('reset-search-settings-dialog-confirm') + ); + + await waitFor(() => expect(restoreSettingsConfig).toHaveBeenCalled()); + await waitFor(() => expect(getSettingsByType).toHaveBeenCalledTimes(2)); + }); + + it('closes the reset confirmation with Escape without resetting', async () => { + await renderView(); + render(<>{headerActions}); + fireEvent.click(screen.getByTestId('reset-search-settings-btn')); + const dialog = await screen.findByTestId('reset-search-settings-dialog'); + fireEvent.keyDown(dialog, { key: 'Escape' }); + + await waitFor(() => + expect( + screen.queryByTestId('reset-search-settings-dialog') + ).not.toBeInTheDocument() + ); + + expect(restoreSettingsConfig).not.toHaveBeenCalled(); + }); + + it('offers no reset to non-admins', async () => { + mockIsAdmin = false; + await renderView(); + + expect(headerActions).toBeUndefined(); + }); + + it('opens the entity page from its card by keyboard', async () => { + await renderView(); + fireEvent.keyDown(screen.getByTestId('search-entity-card-tables'), { + key: 'Enter', + }); + + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'search', + isEditing: false, + itemId: 'tables', + }); + }); + + it('saves hybrid weights as complements when the edition offers them', async () => { + jest + .spyOn(searchSettingsClassBase, 'showHybridSearchWeights') + .mockReturnValue(true); + await renderView(); + const save = screen.getByTestId('hybrid-weights-save-btn'); + + expect(save).toBeDisabled(); + expect(screen.getByTestId('keyword-weight')).toHaveTextContent('0.6'); + + const thumb = within(screen.getByTestId('hybrid-search-weights')).getByRole( + 'slider' + ); + act(() => { + fireEvent.keyDown(thumb, { key: 'ArrowRight' }); + }); + fireEvent.click(save); + + await waitFor(() => + expect(savedGlobalSettings()).toEqual( + expect.objectContaining({ semanticWeight: 0.5, keywordWeight: 0.5 }) + ) + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.tsx new file mode 100644 index 000000000000..12b1ef61a9a3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchSettingsView.tsx @@ -0,0 +1,373 @@ +/* + * Copyright 2026 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 { Box, Button } from '@openmetadata/ui-core-components'; +import { Plus, RefreshCcw01 } from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FieldValueBoost, + GlobalSettings, + SearchSettings, + TermBoost, +} from '../../../../../../../generated/configuration/searchSettings'; +import { + Settings, + SettingType, +} from '../../../../../../../generated/settings/settings'; +import { useAuth } from '../../../../../../../hooks/authHooks'; +import { useApplicationStore } from '../../../../../../../hooks/useApplicationStore'; +import { + getSettingsByType, + restoreSettingsConfig, + updateSettingsConfig, +} from '../../../../../../../rest/settingConfigAPI'; +import searchSettingsClassBase from '../../../../../../../utils/SearchSettingsClassBase'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../../utils/ToastUtils'; +import ConfirmDialog from '../ConfirmDialog'; +import type { PlatformSettingsPageProps } from '../PlatformSettings.types'; +import { SettingsSkeleton } from '../SettingsFormLayout'; +import FieldValueBoostDialog from './FieldValueBoostDialog'; +import FieldValueBoostTable from './FieldValueBoostTable'; +import GlobalSearchSettings from './GlobalSearchSettings'; +import HybridWeightsSection from './HybridWeightsSection'; +import SearchEntityCards from './SearchEntityCards'; +import SearchSection from './SearchSection'; +import { + complementWeight, + upsertFieldValueBoost, + upsertTermBoost, +} from './SearchSettings.utils'; +import TermBoostCard from './TermBoostCard'; + +/** The global search settings, saved change by change as on the classic page. */ +const SearchSettingsView = ({ + onNavigate, + onSetHeaderActions, +}: PlatformSettingsPageProps) => { + const { t } = useTranslation(); + const { isAdminUser } = useAuth(); + const setAppPreferences = useApplicationStore( + (state) => state.setAppPreferences + ); + const [config, setConfig] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + const [termBoosts, setTermBoosts] = useState([]); + const [termBoostsChanged, setTermBoostsChanged] = useState(false); + const [showNewTermBoost, setShowNewTermBoost] = useState(false); + const [editingBoost, setEditingBoost] = useState<{ + boost?: FieldValueBoost; + } | null>(null); + const [confirm, setConfirm] = useState<'reset' | 'disable-columns' | null>( + null + ); + + const applyConfig = useCallback( + (next: SearchSettings, keepTermBoostDraft = false) => { + setConfig(next); + if (!keepTermBoostDraft) { + setTermBoosts(next.globalSettings?.termBoosts ?? []); + setTermBoostsChanged(false); + } + setAppPreferences({ searchConfig: next }); + }, + [setAppPreferences] + ); + + const fetchConfig = useCallback(async () => { + setIsLoading(true); + try { + applyConfig( + (await getSettingsByType(SettingType.SearchSettings)) as SearchSettings + ); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }, [applyConfig]); + + // Once per mount: under StrictMode a second response would wipe unsaved term boosts. + const hasLoadedRef = useRef(false); + useEffect(() => { + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + void fetchConfig(); + } + }, [fetchConfig]); + + /** Resolves to whether the save succeeded, so editors close only on success. */ + const updateGlobalSettings = async ( + update: Partial, + successMessage?: string + ): Promise => { + if (!config) { + return false; + } + setIsUpdating(true); + try { + const { data } = await updateSettingsConfig({ + config_type: SettingType.SearchSettings, + config_value: { + ...config, + globalSettings: { ...config.globalSettings, ...update }, + }, + } as Settings); + // Unsaved term boost edits survive saves of other settings; they are + // still saved only with their own Save button. + applyConfig( + data.config_value as SearchSettings, + termBoostsChanged && !('termBoosts' in update) + ); + showSuccessToast( + successMessage ?? + t('server.update-entity-success', { + entity: t('label.search-setting-plural'), + }) + ); + + return true; + } catch (error) { + showErrorToast(error as AxiosError); + + return false; + } finally { + setIsUpdating(false); + } + }; + + const handleReset = async () => { + setIsUpdating(true); + try { + await restoreSettingsConfig(SettingType.SearchSettings); + await fetchConfig(); + showSuccessToast( + t('server.update-entity-success', { + entity: t('label.search-setting-plural'), + }) + ); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsUpdating(false); + setConfirm(null); + } + }; + + useEffect(() => { + if (!isAdminUser) { + return undefined; + } + onSetHeaderActions( + + ); + + return () => onSetHeaderActions(undefined); + }, [isAdminUser, isUpdating, onSetHeaderActions, t]); + + // A new boost joins the list once it has both a tag and a boost. + const handleTermBoostChange = (boost: TermBoost) => { + if (!boost.value || !boost.boost) { + return; + } + setTermBoosts((prev) => upsertTermBoost(prev, boost)); + setTermBoostsChanged(true); + setShowNewTermBoost(false); + }; + + // Deleting a saved boost saves straight away, as the classic page did. + const handleTermBoostDelete = (value: string) => { + if (!termBoosts.some((boost) => boost.value === value)) { + setShowNewTermBoost(false); + + return; + } + void updateGlobalSettings({ + termBoosts: termBoosts.filter((boost) => boost.value !== value), + }); + }; + + const handleSaveFieldValueBoost = async (boost: FieldValueBoost) => { + const saved = await updateGlobalSettings({ + fieldValueBoosts: upsertFieldValueBoost( + config?.globalSettings?.fieldValueBoosts, + boost + ), + }); + if (saved) { + setEditingBoost(null); + } + }; + + const fieldValueBoostOptions = useMemo( + () => + config?.allowedFieldValueBoosts?.[0]?.fields?.map( + (field) => field.name + ) ?? [], + [config] + ); + + if (isLoading || !config) { + return ; + } + + const global = config.globalSettings ?? {}; + const fieldValueBoosts = global.fieldValueBoosts ?? []; + + return ( + + setConfirm('disable-columns')} + onUpdate={updateGlobalSettings} + /> + + {searchSettingsClassBase.showHybridSearchWeights() && ( + + updateGlobalSettings({ + semanticWeight, + keywordWeight: complementWeight(semanticWeight), + }) + } + /> + )} + + + + + + } + count={termBoosts.length} + testId="term-boosts" + title={t('label.term-boost')}> + + {termBoosts.map((boost) => ( + + ))} + {showNewTermBoost && ( + + )} + + + + setEditingBoost({})}> + {t('label.add')} + + } + count={fieldValueBoosts.length} + testId="field-value-boosts" + title={t('label.field-value-boost')}> + + updateGlobalSettings({ + fieldValueBoosts: fieldValueBoosts.filter( + (boost) => boost.field !== field + ), + }) + } + onEdit={(boost) => setEditingBoost({ boost })} + /> + + + + + {editingBoost && ( + setEditingBoost(null)} + onSave={handleSaveFieldValueBoost} + /> + )} + + setConfirm(null)} + onConfirm={handleReset} + /> + setConfirm(null)} + onConfirm={async () => { + await updateGlobalSettings({ enableColumnIndexing: false }); + setConfirm(null); + }} + /> + + ); +}; + +export default SearchSettingsView; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/TermBoostCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/TermBoostCard.test.tsx new file mode 100644 index 000000000000..8ac99ad1cc7f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/TermBoostCard.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2026 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 { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import tagClassBase from '../../../../../../../utils/TagClassBase'; +import { showErrorToast } from '../../../../../../../utils/ToastUtils'; +import TermBoostCard, { getTermBoostField } from './TermBoostCard'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../../../../../../utils/TagClassBase', () => ({ + __esModule: true, + default: { getTags: jest.fn() }, +})); + +const tag = (fullyQualifiedName: string) => ({ + data: { fullyQualifiedName }, +}); + +describe('TermBoostCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + (tagClassBase.getTags as jest.Mock).mockResolvedValue({ + data: [tag('Tier.Tier1'), tag('PII.Sensitive')], + }); + }); + + it.each([ + ['Tier.Tier1', 'tier.tagFQN'], + ['Certification.Gold', 'certification.tagLabel.tagFQN'], + ['PII.Sensitive', 'tags.tagFQN'], + ])('indexes %s under %s', (tagFqn, field) => { + expect(getTermBoostField(tagFqn)).toBe(field); + }); + + it('reports a picked tag with the field it is indexed under', async () => { + const onChange = jest.fn(); + render(); + + await waitFor(() => + expect(tagClassBase.getTags).toHaveBeenCalledWith('', 1, true) + ); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await act(async () => { + await user.click(screen.getByRole('combobox')); + }); + await act(async () => { + await user.keyboard('{ArrowDown}{Enter}'); + }); + + expect(onChange).toHaveBeenLastCalledWith({ + field: 'tier.tagFQN', + value: 'Tier.Tier1', + boost: 0, + }); + }); + + it('locks the tag of a saved boost and reports boost changes', async () => { + const onChange = jest.fn(); + const onDelete = jest.fn(); + render( + + ); + + expect( + await screen.findByTestId('autocomplete-selected-item') + ).toHaveTextContent('PII.Sensitive'); + expect(tagClassBase.getTags).not.toHaveBeenCalled(); + expect(screen.getByTestId('term-boost-value')).toHaveTextContent('2'); + + act(() => { + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' }); + }); + + expect(onChange).toHaveBeenLastCalledWith({ + field: 'tags.tagFQN', + value: 'PII.Sensitive', + boost: 2.1, + }); + + fireEvent.click(screen.getByTestId('delete-term-boost')); + + expect(onDelete).toHaveBeenCalledWith('PII.Sensitive'); + }); + + it('toasts when the tags cannot be loaded', async () => { + (tagClassBase.getTags as jest.Mock).mockRejectedValue(new Error('boom')); + render(); + + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/TermBoostCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/TermBoostCard.tsx new file mode 100644 index 000000000000..719fcf6f1016 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/TermBoostCard.tsx @@ -0,0 +1,181 @@ +/* + * Copyright 2026 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 { + Autocomplete, + Box, + ButtonUtility, + Slider, + Typography, +} from '@openmetadata/ui-core-components'; +import { Trash01 } from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import { debounce } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TermBoost } from '../../../../../../../generated/configuration/searchSettings'; +import tagClassBase from '../../../../../../../utils/TagClassBase'; +import { showErrorToast } from '../../../../../../../utils/ToastUtils'; + +const SEARCH_DEBOUNCE_MS = 300; + +export const MAX_BOOST = 100; +export const BOOST_STEP = 0.1; + +interface TagOption { + id: string; + label: string; + field: string; +} + +/** Tiers and certifications are indexed under their own fields, not tags. */ +export const getTermBoostField = (tagFqn: string) => { + if (tagFqn.startsWith('Certification.')) { + return 'certification.tagLabel.tagFQN'; + } + + return tagFqn.startsWith('Tier.') ? 'tier.tagFQN' : 'tags.tagFQN'; +}; + +interface TermBoostCardProps { + termBoost?: TermBoost; + onChange: (termBoost: TermBoost) => void; + onDelete: (tagFqn: string) => void; +} + +/** + * One term boost: pick a tag (fixed once saved) and its boost. Every change + * is reported up; the caller decides when a boost is complete enough to keep. + */ +const TermBoostCard = ({ + termBoost, + onChange, + onDelete, +}: TermBoostCardProps) => { + const { t } = useTranslation(); + const isExisting = Boolean(termBoost?.value); + const [draft, setDraft] = useState( + termBoost ?? { field: '', value: '', boost: 0 } + ); + const [options, setOptions] = useState([]); + + useEffect(() => { + if (termBoost?.value) { + setDraft(termBoost); + } + }, [termBoost]); + + const fetchTags = useCallback(async (searchText: string) => { + try { + const response = await tagClassBase.getTags(searchText, 1, true); + setOptions( + response.data.map(({ data }) => { + const fqn = data.fullyQualifiedName ?? ''; + + return { id: fqn, label: fqn, field: getTermBoostField(fqn) }; + }) + ); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, []); + + const debouncedFetch = useMemo( + () => debounce(fetchTags, SEARCH_DEBOUNCE_MS), + [fetchTags] + ); + + useEffect(() => { + if (!isExisting) { + void fetchTags(''); + } + + return () => debouncedFetch.cancel(); + }, [debouncedFetch, fetchTags, isExisting]); + + const update = (next: TermBoost) => { + setDraft(next); + onChange(next); + }; + + // Autocomplete re-syncs its selection whenever this identity changes. + const selectedItems = useMemo( + () => (draft.value ? [{ id: draft.value, label: draft.value }] : []), + [draft.value] + ); + + return ( + + update({ ...draft, field: '', value: '' })} + onItemInserted={(key) => { + const option = options.find((item) => item.id === String(key)); + if (option) { + update({ ...draft, field: option.field, value: option.id }); + } + }} + onSearchChange={debouncedFetch}> + {(item) => } + + + + + + {t('label.boost')} + + + {draft.boost} + + + + update({ ...draft, boost: Array.isArray(value) ? value[0] : value }) + } + /> + + + onDelete(draft.value)} + /> + + ); +}; + +export default TermBoostCard;