diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 47635f9dfda3..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", @@ -10143,6 +10146,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotAddForm.tsx" ], "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/BotsProfile.spec.ts", "playwright/e2e/Pages/LearningResources.spec.ts" ] @@ -10441,6 +10445,14 @@ "playwright/e2e/Features/PersonaAppLayout.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataAssetRulesSettings.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.tsx" @@ -10473,6 +10485,15 @@ "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Pages/LearningResources.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsLanding.tsx" @@ -10514,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" @@ -12144,6 +12229,7 @@ "specs": [ "playwright/e2e/Features/ColumnBulkOperations.spec.ts", "playwright/e2e/Features/ProfileGovernance.spec.ts", + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/AIProfile/ProfileTeam.spec.ts", "playwright/e2e/Pages/GlossaryTermRelationSettings.spec.ts", "playwright/e2e/Pages/LearningResources.spec.ts" @@ -12208,6 +12294,7 @@ "specs": [ "playwright/e2e/Features/IncidentManager.spec.ts", "playwright/e2e/Features/ProfileGovernance.spec.ts", + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/GlossaryTermRelationSettings.spec.ts", "playwright/e2e/Pages/LearningResources.spec.ts" ] @@ -12217,6 +12304,7 @@ "openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourcesPage.tsx" ], "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/LearningResources.spec.ts" ] }, @@ -12427,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 40057929f32b..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 @@ -143,6 +143,8 @@ test.describe( ['data-quality', 'data-quality-settings'], ['lineage', 'lineage-settings'], ['brand-url', 'brand-url-settings'], + ['learning-resources', 'learning-resources-settings'], + ['search', 'search-settings'], ['app-mode', 'default-app-mode-page'], ]; @@ -509,6 +511,8 @@ test.describe( test('data quality: adds, edits and deletes a custom dimension', async ({ page, }) => { + // Create, edit and delete against the real API, behind a list load. + test.slow(); // Dimensions are entities with unique names, so this runs against the // real API and cleans up after itself. const name = `pw_dimension_${uuid()}`; @@ -516,7 +520,9 @@ test.describe( try { await openCard(page, 'data-quality'); - await expect(page.getByTestId('dimensions-table')).toBeVisible(); + await expect(page.getByTestId('dimensions-table')).toBeVisible({ + timeout: 30_000, + }); await header(page).getByTestId('add-dimension').click(); await fillField(page, 'dimension-name', name); @@ -533,7 +539,11 @@ test.describe( await expect(row).toContainText('PW Dimension'); await expect(row).toContainText('Custom'); - await page.getByTestId(`edit-${name}`).click(); + await clickAndWaitFor( + page, + page.getByTestId(`edit-${name}`), + /\/api\/v1\/dataQuality\/dimensions\?/ + ); await expect( page.getByTestId('dimension-name').locator('input') ).toBeDisabled(); @@ -569,5 +579,292 @@ test.describe( await afterAction(); } }); + test('data asset rules: toggling a rule saves it', async ({ page }) => { + const rule = { + name: 'Single Domain', + description: 'One domain per asset.', + enabled: false, + rule: '{"==":[1,1]}', + }; + const settings = await stubSettingRoundTrip(page, 'entityRulesSettings', { + initial: { entitySemantics: [rule] }, + }); + await openPlatformSettings(page); + await openCard(page, 'data-asset-rules'); + + await expect( + page.getByTestId('data-asset-rule-Single Domain') + ).toContainText('One domain per asset.'); + await clickAndWaitFor( + page, + page.getByTestId('toggle-Single Domain'), + SETTINGS_PUT + ); + + expect(settings.puts[0].config_value).toEqual({ + entitySemantics: [{ ...rule, enabled: true }], + }); + }); + + test('data asset rules: empty state offers no Add, as adding is unsupported', async ({ + page, + }) => { + await stubSettingRoundTrip(page, 'entityRulesSettings', { + initial: { entitySemantics: [] }, + }); + await openPlatformSettings(page); + await openCard(page, 'data-asset-rules'); + + const emptyState = page.getByTestId('data-asset-rules-empty'); + await expect(emptyState).toBeVisible(); + await expect(emptyState.getByRole('button')).toHaveCount(0); + }); + + test('learning resources: adds, edits and deletes a resource', async ({ + page, + }) => { + // Create, edit and delete against the real API, behind a list load. + test.slow(); + // Learning resources are entities with unique names, so this runs + // against the real API and cleans up after itself. + const name = `pw-resource-${uuid()}`; + await openPlatformSettings(page); + + try { + await openCard(page, 'learning-resources'); + await expect(page.getByTestId('learning-resources-table')).toBeVisible({ + timeout: 30_000, + }); + + await header(page).getByTestId('create-resource').click(); + await fillField(page, 'name-input', name); + await page + .getByTestId('description-input') + .locator('textarea') + .fill('Created by Playwright.'); + await chooseSelectOption( + page.getByTestId('resource-type-select'), + page.getByRole('option', { name: 'Video', exact: true }) + ); + for (const [testId, option] of [ + ['categories-select', 'Discovery'], + ['contexts-select', 'Glossary'], + ]) { + await page.getByTestId(testId).locator('input').fill(option); + await page.getByRole('option', { name: option, exact: true }).click(); + } + await fillField( + page, + 'source-url-input', + 'https://www.youtube.com/watch?v=pw-test' + ); + await clickAndWaitFor( + page, + page.getByTestId('save-button'), + '**/api/v1/learning/resources', + 201 + ); + + await page.getByTestId('search-resources').locator('input').fill(name); + const row = page.getByTestId(name); + await expect(row).toBeVisible(); + + await clickAndWaitFor( + page, + page.getByTestId(`edit-${name}`), + '**/api/v1/learning/resources/*' + ); + await expect( + page.getByTestId('name-input').locator('input') + ).toBeDisabled(); + await page + .getByTestId('description-input') + .locator('textarea') + .fill('Edited by Playwright.'); + await clickAndWaitFor( + page, + page.getByTestId('save-button'), + '**/api/v1/learning/resources' + ); + + await page.getByTestId('search-resources').locator('input').fill(name); + await page.getByTestId(`delete-${name}`).click(); + await clickAndWaitFor( + page, + page.getByTestId('confirm-button'), + '**/api/v1/learning/resources/*' + ); + await expect(row).toHaveCount(0); + } finally { + const { apiContext, afterAction } = await getApiContext(page); + const existing = await apiContext.get( + `/api/v1/learning/resources/name/${encodeURIComponent( + name + )}?include=all` + ); + if (existing.ok()) { + const { id } = await existing.json(); + await apiContext.delete( + `/api/v1/learning/resources/${id}?hardDelete=true` + ); + } + 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/DataAssetRulesSettings.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataAssetRulesSettings.test.tsx new file mode 100644 index 000000000000..5ff0c272e09d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataAssetRulesSettings.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2023 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 { SettingType } from '../../../../../../generated/settings/settings'; +import { + getSettingsConfigFromConfigType, + updateSettingsConfig, +} from '../../../../../../rest/settingConfigAPI'; +import DataAssetRulesSettings from './DataAssetRulesSettings'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../rest/settingConfigAPI', () => ({ + getSettingsConfigFromConfigType: jest.fn(), + updateSettingsConfig: jest.fn(), +})); + +jest.mock('../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +jest.mock( + '../../../../../common/RichTextEditor/RichTextEditorPreviewNew', + () => + ({ markdown }: { markdown: string }) => + {markdown} +); + +const RULES = [ + { + name: 'Single Domain', + description: 'One domain per asset.', + enabled: true, + rule: '{"==":[1,1]}', + }, + { + name: 'Single Glossary Term', + description: 'One glossary term per table.', + enabled: false, + rule: '{"==":[1,1]}', + }, +]; + +const mockRules = (rules: unknown[]) => + (getSettingsConfigFromConfigType as jest.Mock).mockResolvedValue({ + data: { config_value: { entitySemantics: rules } }, + }); + +describe('Data Asset Rules settings', () => { + beforeEach(() => { + jest.clearAllMocks(); + (updateSettingsConfig as jest.Mock).mockResolvedValue({ data: {} }); + }); + + it('lists the rules with their enabled state', async () => { + mockRules(RULES); + render(); + + expect( + await screen.findByTestId('data-asset-rule-Single Domain') + ).toHaveTextContent('One domain per asset.'); + expect( + screen.getByTestId('toggle-Single Domain').querySelector('input') + ).toBeChecked(); + expect( + screen.getByTestId('toggle-Single Glossary Term').querySelector('input') + ).not.toBeChecked(); + }); + + it('toggling a rule saves the whole rule list with only that rule flipped', async () => { + mockRules(RULES); + render(); + const toggle = await screen.findByTestId('toggle-Single Glossary Term'); + + await act(async () => { + fireEvent.click(toggle.querySelector('input') as Element); + }); + + expect(updateSettingsConfig).toHaveBeenCalledWith({ + config_type: SettingType.EntityRulesSettings, + config_value: { + entitySemantics: [RULES[0], { ...RULES[1], enabled: true }], + }, + }); + }); + + it('shows an empty state without an Add action, as adding is not supported yet', async () => { + mockRules([]); + render(); + + expect( + await screen.findByTestId('data-asset-rules-empty') + ).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataAssetRulesSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataAssetRulesSettings.tsx new file mode 100644 index 000000000000..18dbfee9675e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataAssetRulesSettings.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2023 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, + Table, + TableCard, + Toggle, + Typography, +} from '@openmetadata/ui-core-components'; +import { FileCheck02 } from '@openmetadata/ui-core-components/icons'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SemanticsRule } from '../../../../../../generated/settings/settings'; +import RichTextEditorPreviewerNew from '../../../../../common/RichTextEditor/RichTextEditorPreviewNew'; +import { useSemanticsRulesState } from '../../../../../DataAssetRules/DataAssetRules.component'; +import { SettingsSkeleton } from './SettingsFormLayout'; + +/** Rules are toggled on and off in place; adding rules is not supported yet. */ +const DataAssetRulesSettings = () => { + const { t } = useTranslation(); + const { semanticsRules, isLoading, isSaveLoading, updateSemanticsRules } = + useSemanticsRulesState(); + + const columns = useMemo( + () => [ + { id: 'name', label: t('label.name'), className: 'tw:w-60' }, + { id: 'description', label: t('label.description') }, + { id: 'enabled', label: t('label.enabled'), className: 'tw:w-28' }, + ], + [t] + ); + + const toggleRule = (rule: SemanticsRule) => + updateSemanticsRules( + semanticsRules.map((item) => + item.name === rule.name ? { ...item, enabled: !item.enabled } : item + ) + ).catch(() => undefined); + + const renderCell = (rule: SemanticsRule, columnId: string) => { + if (columnId === 'name') { + return ( + + {rule.name} + + ); + } + + if (columnId === 'description') { + return ; + } + + return ( + toggleRule(rule)} + /> + ); + }; + + if (isLoading) { + return ; + } + + if (semanticsRules.length === 0) { + return ( + // The placeholder centres itself in its nearest positioned ancestor. +
+ } + title={t('label.no-entity', { entity: t('label.data-asset-rules') })} + /> +
+ ); + } + + return ( + + + + + {(column) => ( + + )} + + + {(rule) => ( + + {(column) => ( + + {renderCell(rule, column.id)} + + )} + + )} + +
+
+
+ ); +}; + +export default DataAssetRulesSettings; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionSettingsForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionSettingsForm.tsx index d2f3bbed02d4..1c4792e64f20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionSettingsForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionSettingsForm.tsx @@ -23,17 +23,18 @@ import { compare } from 'fast-json-patch'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { AGGREGATE_PAGE_SIZE_LARGE } from '../../../../../../constants/constants'; import { DIMENSION_COLOR_PALETTE } from '../../../../../../constants/DataQualityDimension.constants'; import { DataQualityDimension } from '../../../../../../generated/tests/dataQualityDimension'; import { createDataQualityDimension, + getDataQualityDimensions, patchDataQualityDimension, } from '../../../../../../rest/dataQualityDimensionAPI'; import { showErrorToast, showSuccessToast, } from '../../../../../../utils/ToastUtils'; -import { fetchDimensionList } from './DataQualitySettings.utils'; import type { PlatformSettingsFormProps } from './PlatformSettings.types'; import SettingsFormLayout, { SettingsFormSection, @@ -50,6 +51,10 @@ export interface DimensionFormValues { const DIMENSION_NAME_PATTERN = /^[\w-]+$/; +// No get-by-name endpoint; the form only needs the dimensions, not the counts. +const fetchDimensions = async () => + (await getDataQualityDimensions({ limit: AGGREGATE_PAGE_SIZE_LARGE })).data; + const toFormValues = ( dimension?: DataQualityDimension ): DimensionFormValues => ({ @@ -67,7 +72,7 @@ const DimensionSettingsForm = ({ }: PlatformSettingsFormProps) => { const { t } = useTranslation(); const isEditing = Boolean(itemId); - const { data, isLoading } = useSettingsFetch(fetchDimensionList); + const { data, isLoading } = useSettingsFetch(fetchDimensions); const [isSaving, setIsSaving] = useState(false); const form = useForm({ defaultValues: toFormValues() }); const [color, name, displayName] = useWatch({ @@ -76,7 +81,7 @@ const DimensionSettingsForm = ({ }); const editing = useMemo( - () => data?.dimensions.find((dimension) => dimension.name === itemId), + () => data?.find((dimension) => dimension.name === itemId), [data, itemId] ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/EmailSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/EmailSettings.tsx index 6cb708122b57..bf7da7a80310 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/EmailSettings.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/EmailSettings.tsx @@ -85,13 +85,16 @@ const EmailSettings = ({ if (!config) { return ( - } - title={t('label.no-entity', { - entity: t('label.email-configuration'), - })} - /> + // The placeholder centres itself in its nearest positioned ancestor. +
+ } + title={t('label.no-entity', { + entity: t('label.email-configuration'), + })} + /> +
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettings.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettings.utils.ts new file mode 100644 index 000000000000..7d03c7034a1a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettings.utils.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2023 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 { + CATEGORIES, + DURATIONS, + LearningResourceStatus, + PAGE_IDS, +} from '../../../../../../constants/Learning.constants'; +import { + CreateLearningResource, + LearningResource, + LearningResourceType, +} from '../../../../../../rest/learningResourceAPI'; + +export interface SelectItem { + id: string; + label: string; +} + +export interface LearningResourceFormValues { + name: string; + description: string; + resourceType: SelectItem | null; + categories: SelectItem[]; + contexts: SelectItem[]; + sourceUrl: string; + sourceProvider: string; + estimatedDuration: SelectItem | null; + status: SelectItem | null; +} + +export const CATEGORY_ITEMS: SelectItem[] = CATEGORIES.map( + ({ value, label }) => ({ id: value, label }) +); + +export const CONTEXT_ITEMS: SelectItem[] = PAGE_IDS.map(({ value, label }) => ({ + id: value, + label, +})); + +export const STATUS_ITEMS: SelectItem[] = Object.values( + LearningResourceStatus +).map((status) => ({ id: status, label: status })); + +/** "5 mins" → 300 seconds, the unit the API stores. */ +export const parseDuration = (duration: string): number => { + const match = /(\d+)/.exec(duration); + + return match ? Number.parseInt(match[1], 10) * 60 : 0; +}; + +export const DURATION_ITEMS: SelectItem[] = DURATIONS.map((duration) => ({ + id: String(parseDuration(duration)), + label: duration, +})); + +const findItems = (items: SelectItem[], ids: string[] = []) => + ids.map((id) => items.find((item) => item.id === id) ?? { id, label: id }); + +export const toLearningResourceFormValues = ( + resource?: LearningResource, + typeLabel: (type: LearningResourceType) => string = (type) => type +): LearningResourceFormValues => ({ + name: resource?.name ?? '', + description: resource?.description ?? '', + resourceType: resource + ? { id: resource.resourceType, label: typeLabel(resource.resourceType) } + : null, + categories: findItems(CATEGORY_ITEMS, resource?.categories), + contexts: findItems( + CONTEXT_ITEMS, + resource?.contexts?.map((context) => context.pageId) + ), + sourceUrl: resource?.source.url ?? '', + sourceProvider: resource?.source.provider ?? '', + estimatedDuration: resource?.estimatedDuration + ? findItems(DURATION_ITEMS, [String(resource.estimatedDuration)])[0] + : null, + status: findItems(STATUS_ITEMS, [ + resource?.status ?? LearningResourceStatus.Active, + ])[0], +}); + +/** + * Builds the create/update payload. Fields the form does not edit (difficulty, + * a context's componentId) are carried over from the resource being edited. + */ +export const toLearningResourcePayload = ( + values: LearningResourceFormValues, + original?: LearningResource +): CreateLearningResource => ({ + name: values.name, + description: values.description, + resourceType: values.resourceType?.id as LearningResourceType, + categories: values.categories.map( + (item) => item.id + ) as CreateLearningResource['categories'], + contexts: values.contexts.map(({ id }) => ({ + pageId: id, + componentId: original?.contexts?.find((context) => context.pageId === id) + ?.componentId, + })), + difficulty: original?.difficulty, + estimatedDuration: values.estimatedDuration + ? Number(values.estimatedDuration.id) + : undefined, + source: { + url: values.sourceUrl, + provider: values.sourceProvider || undefined, + }, + status: values.status?.id as CreateLearningResource['status'], +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettingsForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettingsForm.tsx new file mode 100644 index 000000000000..a9b404b2f5dd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettingsForm.tsx @@ -0,0 +1,256 @@ +/* + * Copyright 2023 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, FieldTypes, getField } from '@openmetadata/ui-core-components'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { + ResourceType, + RESOURCE_TYPE_LABEL_KEYS, + RESOURCE_TYPE_VALUES, + SOURCE_URL_PLACEHOLDERS, +} from '../../../../../../constants/Learning.constants'; +import { + createLearningResource, + getLearningResourceById, + LearningResource, + updateLearningResource, +} from '../../../../../../rest/learningResourceAPI'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../utils/ToastUtils'; +import { ResourceTypeIcon } from '../../../../../Learning/ResourceTypeIcon/ResourceTypeIcon'; +import { + CATEGORY_ITEMS, + CONTEXT_ITEMS, + DURATION_ITEMS, + LearningResourceFormValues, + STATUS_ITEMS, + toLearningResourceFormValues, + toLearningResourcePayload, +} from './LearningResourceSettings.utils'; +import type { PlatformSettingsFormProps } from './PlatformSettings.types'; +import SettingsFormLayout, { + SettingsFormSection, + SettingsSkeleton, +} from './SettingsFormLayout'; +import { isValidUrl } from './ThemeSettings.utils'; +import { useSettingsFetch } from './useSettingsFetch'; + +/** Add (no `itemId`) or edit the learning resource with id `itemId`. */ +const LearningResourceSettingsForm = ({ + showHint, + itemId, + onNavigate, +}: PlatformSettingsFormProps) => { + const { t } = useTranslation(); + const fetchResource = useCallback( + async (): Promise => + itemId ? getLearningResourceById(itemId) : undefined, + [itemId] + ); + const { data: resource, isLoading } = useSettingsFetch(fetchResource); + const [isSaving, setIsSaving] = useState(false); + const form = useForm({ + defaultValues: toLearningResourceFormValues(), + }); + const resourceType = useWatch({ + control: form.control, + name: 'resourceType', + }); + + useEffect(() => { + if (resource) { + form.reset(toLearningResourceFormValues(resource)); + } + }, [form, resource]); + + // A stale or deleted id: the fetch has already toasted, so return to the list. + useEffect(() => { + if (itemId && !isLoading && !resource) { + onNavigate({ + type: 'page', + page: 'learning-resources', + isEditing: false, + }); + } + }, [isLoading, itemId, onNavigate, resource]); + + const required = useCallback( + (labelKey: string) => ({ + required: t('label.field-required', { field: t(labelKey) }), + }), + [t] + ); + + const fields: FieldProp[] = useMemo( + () => [ + { + name: 'name', + label: t('label.name'), + type: FieldTypes.TEXT, + required: true, + placeholder: t('label.enter-entity', { entity: t('label.name') }), + // The name identifies the resource and cannot change once created. + props: { 'data-testid': 'name-input', isDisabled: Boolean(itemId) }, + rules: required('label.name'), + }, + { + name: 'description', + label: t('label.description'), + type: FieldTypes.TEXTAREA, + required: true, + props: { 'data-testid': 'description-input' }, + rules: required('label.description'), + }, + { + name: 'resourceType', + label: t('label.type'), + type: FieldTypes.SELECT, + required: true, + placeholder: t('label.select-field', { field: t('label.type') }), + props: { + 'data-testid': 'resource-type-select', + items: RESOURCE_TYPE_VALUES.map((type) => ({ + id: type, + label: t(RESOURCE_TYPE_LABEL_KEYS[type]), + icon: , + })), + }, + rules: required('label.type'), + }, + { + name: 'categories', + label: t('label.category-plural'), + type: FieldTypes.MULTI_SELECT, + required: true, + placeholder: t('label.select-type'), + props: { 'data-testid': 'categories-select', items: CATEGORY_ITEMS }, + // RHF treats an empty array as missing, so `required` covers multi-selects. + rules: required('label.category-plural'), + }, + { + name: 'contexts', + label: t('label.context'), + type: FieldTypes.MULTI_SELECT, + required: true, + placeholder: t('label.select-field', { field: t('label.context') }), + props: { 'data-testid': 'contexts-select', items: CONTEXT_ITEMS }, + rules: required('label.context'), + }, + { + name: 'sourceUrl', + label: t('label.source-url'), + type: FieldTypes.TEXT, + required: true, + placeholder: + SOURCE_URL_PLACEHOLDERS[ + (resourceType?.id as ResourceType) ?? ResourceType.Video + ], + props: { 'data-testid': 'source-url-input' }, + rules: { + ...required('label.source-url'), + validate: (value: string) => + isValidUrl(value) || t('label.invalid-url'), + }, + }, + { + name: 'sourceProvider', + label: t('label.source-provider'), + type: FieldTypes.TEXT, + placeholder: 'YouTube, Storylane, etc.', + props: { 'data-testid': 'source-provider-input' }, + }, + { + name: 'estimatedDuration', + label: t('label.duration'), + type: FieldTypes.SELECT, + placeholder: t('label.select-duration'), + props: { 'data-testid': 'duration-select', items: DURATION_ITEMS }, + }, + { + name: 'status', + label: t('label.status'), + type: FieldTypes.SELECT, + placeholder: t('label.select-status'), + props: { 'data-testid': 'status-select', items: STATUS_ITEMS }, + }, + ], + [itemId, required, resourceType, t] + ); + + const backToList = () => + onNavigate({ type: 'page', page: 'learning-resources', isEditing: false }); + + const handleSubmit = async (values: LearningResourceFormValues) => { + setIsSaving(true); + try { + const payload = toLearningResourcePayload(values, resource); + if (resource) { + await updateLearningResource(payload); + } else { + await createLearningResource(payload); + } + showSuccessToast( + t( + resource + ? 'server.entity-updated-success' + : 'server.create-entity-success', + { entity: t('label.learning-resource') } + ) + ); + backToList(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsSaving(false); + } + }; + + if (itemId && (isLoading || !resource)) { + return ( +
+ +
+ ); + } + + return ( + + + {fields.map((field) => ( + + {getField(field)} + + ))} + + + ); +}; + +export default LearningResourceSettingsForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.test.tsx new file mode 100644 index 000000000000..0f855d95925a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.test.tsx @@ -0,0 +1,254 @@ +/* + * Copyright 2023 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 { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { + createLearningResource, + deleteLearningResource, + getLearningResourceById, + getLearningResourcesList, + LearningResource, + updateLearningResource, +} from '../../../../../../rest/learningResourceAPI'; +import { showErrorToast } from '../../../../../../utils/ToastUtils'; +import { + toLearningResourceFormValues, + toLearningResourcePayload, +} from './LearningResourceSettings.utils'; +import LearningResourceSettingsForm from './LearningResourceSettingsForm'; +import LearningResourcesSettings from './LearningResourcesSettings'; + +// Stable identity: the reused list hook refetches whenever `t` changes, as +// the real i18next `t` never does. +const mockT = (key: string) => key; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }), +})); + +jest.mock('../../../../../../rest/learningResourceAPI', () => ({ + getLearningResourcesList: jest.fn(), + getLearningResourceById: jest.fn(), + createLearningResource: jest.fn(), + updateLearningResource: jest.fn(), + deleteLearningResource: jest.fn(), +})); + +jest.mock('../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +jest.mock('../../../../../../hooks/platform/usePersonaViewMode', () => ({ + usePersonaViewMode: () => 'Table', +})); + +jest.mock( + '../../../../../Learning/ResourcePlayer/ResourcePlayerModal.component', + () => ({ + ResourcePlayerModal: () =>
, + }) +); + +const RESOURCE: LearningResource = { + id: 'res-1', + name: 'intro-video', + displayName: 'Intro video', + description: 'Getting started.', + resourceType: 'Video', + categories: ['Discovery'], + difficulty: 'Intro', + source: { url: 'https://youtube.com/watch?v=1', provider: 'YouTube' }, + estimatedDuration: 300, + contexts: [{ pageId: 'glossary', componentId: 'header' }], + status: 'Active', + updatedAt: 1790000000000, +}; + +const onNavigate = jest.fn(); +let headerActions: ReactNode; +const onSetHeaderActions = (actions: ReactNode) => { + headerActions = actions; +}; + +const inputOf = (testId: string) => + screen.getByTestId(testId).querySelector('input, textarea') as Element; + +describe('Learning resources settings', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getLearningResourcesList as jest.Mock).mockResolvedValue({ + data: [RESOURCE], + paging: { total: 1 }, + }); + (getLearningResourceById as jest.Mock).mockResolvedValue(RESOURCE); + (createLearningResource as jest.Mock).mockResolvedValue({}); + (updateLearningResource as jest.Mock).mockResolvedValue({}); + (deleteLearningResource as jest.Mock).mockResolvedValue({}); + }); + + it('round-trips a resource through the form values, keeping fields the form does not edit', () => { + const payload = toLearningResourcePayload( + toLearningResourceFormValues(RESOURCE), + RESOURCE + ); + + expect(payload).toEqual({ + name: 'intro-video', + description: 'Getting started.', + resourceType: 'Video', + categories: ['Discovery'], + contexts: [{ pageId: 'glossary', componentId: 'header' }], + difficulty: 'Intro', + estimatedDuration: 300, + source: { url: 'https://youtube.com/watch?v=1', provider: 'YouTube' }, + status: 'Active', + }); + }); + + it('lists resources and routes Add and Edit to the form', async () => { + render( + + + + ); + + expect(await screen.findByTestId('intro-video')).toHaveTextContent( + 'Intro video' + ); + + render(<>{headerActions}); + fireEvent.click(screen.getByTestId('create-resource')); + + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'learning-resources', + isEditing: true, + }); + + fireEvent.click(screen.getByTestId('edit-intro-video')); + + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'learning-resources', + isEditing: true, + itemId: 'res-1', + }); + }); + + it('deletes a resource after confirmation', async () => { + render( + + + + ); + + fireEvent.click(await screen.findByTestId('delete-intro-video')); + await act(async () => { + fireEvent.click(await screen.findByTestId('confirm-button')); + }); + + expect(deleteLearningResource).toHaveBeenCalled(); + }); + + it('edit loads the resource, locks its name and saves an update', async () => { + render( + + ); + await waitFor(() => + expect(inputOf('description-input')).toHaveValue('Getting started.') + ); + + expect(inputOf('name-input')).toBeDisabled(); + + fireEvent.change(inputOf('description-input'), { + target: { value: 'Updated.' }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(updateLearningResource).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'intro-video', + description: 'Updated.', + contexts: [{ pageId: 'glossary', componentId: 'header' }], + }) + ); + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'learning-resources', + isEditing: false, + }); + }); + + it('returns to the list when the resource to edit cannot be loaded', async () => { + (getLearningResourceById as jest.Mock).mockRejectedValue( + new Error('not found') + ); + render( + + ); + + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'learning-resources', + isEditing: false, + }) + ); + + expect(showErrorToast).toHaveBeenCalled(); + }); + + it('add requires the mandatory fields and a valid source URL', async () => { + render( + + ); + await screen.findByTestId('learning-resource-form'); + + fireEvent.change(inputOf('source-url-input'), { + target: { value: 'not a url' }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(await screen.findByText('label.invalid-url')).toBeInTheDocument(); + // Name, description, type, categories and context (the URL is invalid, not empty). + expect(screen.getAllByText('label.field-required')).toHaveLength(5); + expect(createLearningResource).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.tsx new file mode 100644 index 000000000000..d935e6043354 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourcesSettings.tsx @@ -0,0 +1,407 @@ +/* + * Copyright 2023 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. + */ + +/* eslint-disable openmetadata-imports/no-lower-layer-page-imports -- reuses the classic page's data hooks */ +import { + Badge, + Box, + Button, + ButtonUtility, + Grid, + Input, + Table, + TableCard, + Typography, +} from '@openmetadata/ui-core-components'; +import { + Edit01, + Plus, + Search, + Trash01, +} from '@openmetadata/ui-core-components/icons'; +import { debounce } from 'lodash'; +import { DateTime } from 'luxon'; +import { CSSProperties, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + PAGE_SIZE_BASE, + PAGE_SIZE_LARGE, + PAGE_SIZE_MEDIUM, +} from '../../../../../../constants/constants'; +import { + MAX_VISIBLE_CONTEXTS, + MAX_VISIBLE_TAGS, + PAGE_IDS, +} from '../../../../../../constants/Learning.constants'; +import { VIEW_MODE_PAGE } from '../../../../../../constants/platform/personaAppLayout.constants'; +import { PageViewMode } from '../../../../../../generated/type/personaPreferences'; +import { usePersonaViewMode } from '../../../../../../hooks/platform/usePersonaViewMode'; +import { useLearningResourceActions } from '../../../../../../pages/LearningResourcesPage/hooks/useLearningResourceActions'; +import { + LearningResourceFilterState, + useLearningResourceFilters, +} from '../../../../../../pages/LearningResourcesPage/hooks/useLearningResourceFilters'; +import { useLearningResources } from '../../../../../../pages/LearningResourcesPage/hooks/useLearningResources'; +import { LearningResource } from '../../../../../../rest/learningResourceAPI'; +import { DeleteModal } from '../../../../../common/DeleteModal/DeleteModal'; +import NextPrevious from '../../../../../common/NextPrevious/NextPrevious'; +import ViewToggle from '../../../../../common/ViewToggle/ViewToggle'; +import { + CATEGORY_BADGE_COLORS, + LEARNING_CATEGORIES, + ResourceCategory, +} from '../../../../../Learning/Learning.interface'; +import { LearningResourceCard } from '../../../../../Learning/LearningResourceCard/LearningResourceCard.component'; +import { ResourcePlayerModal } from '../../../../../Learning/ResourcePlayer/ResourcePlayerModal.component'; +import { ResourceTypeIcon } from '../../../../../Learning/ResourceTypeIcon/ResourceTypeIcon'; +import type { PlatformSettingsPageProps } from './PlatformSettings.types'; +import { SettingsSkeleton } from './SettingsFormLayout'; + +const CARD_GRID_STYLE: CSSProperties = { + gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', +}; + +const SEARCH_DEBOUNCE_MS = 300; + +const getCategoryLabel = (category: string) => + LEARNING_CATEGORIES[category as ResourceCategory]?.label ?? category; + +const getCategoryColor = (category: string) => + CATEGORY_BADGE_COLORS[category as ResourceCategory] ?? 'gray'; + +const getContextLabel = (pageId: string) => + PAGE_IDS.find((page) => page.value === pageId)?.label ?? pageId; + +const BadgeList = ({ + labels, + max, + color, +}: { + labels: { key: string; label: string; color: string }[]; + max: number; + color: string; +}) => ( + + {labels.slice(0, max).map((item) => ( + + {item.label} + + ))} + {labels.length > max && ( + + {`+${labels.length - max}`} + + )} + +); + +const LearningResourcesSettings = ({ + onNavigate, + onSetHeaderActions, +}: PlatformSettingsPageProps) => { + const { t } = useTranslation(); + const [searchText, setSearchText] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [filterState, setFilterState] = useState( + {} + ); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + + const { resources, paging, isLoading, refetch } = useLearningResources({ + searchText, + filterState, + pageSize, + currentPage, + }); + const { + isPlayerOpen, + isDeleteModalOpen, + isDeleting, + selectedResource, + deletingResource, + handleDelete, + handleDeleteConfirm, + handleDeleteCancel, + handlePreview, + handlePlayerClose, + } = useLearningResourceActions({ onRefetch: refetch }); + const { quickFilters, filterSelectionDisplay } = useLearningResourceFilters({ + filterState, + onFilterChange: setFilterState, + }); + + const personaView = usePersonaViewMode(VIEW_MODE_PAGE.LearningResources); + const [selectedView, setSelectedView] = useState(); + const view = selectedView ?? personaView; + + const debouncedSearch = useMemo( + () => debounce(setSearchText, SEARCH_DEBOUNCE_MS), + [] + ); + useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); + + useEffect(() => { + setCurrentPage(1); + }, [searchText, filterState]); + + const openForm = (resource?: LearningResource) => + onNavigate({ + type: 'page', + page: 'learning-resources', + isEditing: true, + ...(resource ? { itemId: resource.id } : {}), + }); + + useEffect(() => { + onSetHeaderActions( + + ); + + return () => onSetHeaderActions(undefined); + }, [onNavigate, onSetHeaderActions, t]); + + const columns = useMemo( + () => [ + { id: 'name', label: t('label.content-name') }, + { + id: 'categories', + label: t('label.category-plural'), + className: 'tw:w-52', + }, + { id: 'context', label: t('label.context'), className: 'tw:w-52' }, + { id: 'updated', label: t('label.updated-at'), className: 'tw:w-32' }, + { id: 'actions', label: t('label.action-plural'), className: 'tw:w-24' }, + ], + [t] + ); + + const renderCell = (record: LearningResource, columnId: string) => { + switch (columnId) { + case 'name': + return ( + + + + {record.displayName || record.name} + + + ); + case 'categories': + return ( + ({ + key: category, + label: getCategoryLabel(category), + color: getCategoryColor(category), + }))} + max={MAX_VISIBLE_TAGS} + /> + ); + case 'context': + return ( + ({ + key: context.pageId ?? String(index), + label: getContextLabel(context.pageId), + color: 'gray', + }))} + max={MAX_VISIBLE_CONTEXTS} + /> + ); + case 'updated': + return ( + + {record.updatedAt + ? DateTime.fromMillis(record.updatedAt).toFormat('LLL d, yyyy') + : '-'} + + ); + default: + return ( + + openForm(record)} + /> + handleDelete(record)} + /> + + ); + } + }; + + const pagination = ( + + setCurrentPage(page)} + onShowSizeChange={(size) => { + setPageSize(size); + setCurrentPage(1); + }} + /> + + ); + + return ( + + + + + { + setSearchInput(value); + debouncedSearch(value); + }} + /> + {quickFilters} + + + + {filterSelectionDisplay} + + + {view === PageViewMode.Card ? ( + + {isLoading ? ( + + ) : ( + + {resources.map((resource) => ( + + ))} + + )} + + ) : ( + + + {(column) => ( + + )} + + ( + + {isLoading ? ( + + ) : ( + + {t('server.no-records-found')} + + )} + + )}> + {(record) => ( + handlePreview(record)}> + {(column) => ( + + {renderCell(record, column.id)} + + )} + + )} + +
+ )} + {pagination} +
+ + {selectedResource && ( + + )} + + {deletingResource && ( + + )} +
+ ); +}; + +export default LearningResourcesSettings; 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 17c04c4eaf75..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 @@ -12,16 +12,21 @@ */ import { + BookOpen01, Customize, Dataflow02, DataQuality, + FileCheck02, HeartRounded, Link01, 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'; @@ -82,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, @@ -89,6 +103,23 @@ export const PLATFORM_SETTINGS_PAGES: PlatformSettingsPage[] = [ descriptionKey: 'message.om-url-configuration-message', hasEditView: true, }, + { + id: 'data-asset-rules', + icon: FileCheck02, + titleKey: 'label.data-asset-rules', + descriptionKey: 'message.data-asset-rules-message', + isBeta: true, + }, + { + id: 'learning-resources', + icon: BookOpen01, + titleKey: 'label.learning-resources', + descriptionKey: 'message.learning-resources-management-description', + hasEditView: true, + hasFieldHints: false, + getEditTitle: (t, itemId) => + t(itemId ? 'label.edit-resource' : 'label.add-resource'), + }, { id: 'app-mode', icon: SwitchHorizontal01, 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 cf21877b9e30..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 @@ -24,6 +24,9 @@ export type PlatformSettingsPageId = | 'data-quality' | 'lineage' | 'brand-url' + | 'data-asset-rules' + | 'learning-resources' + | 'search' | 'app-mode'; export type PlatformSettingsView = @@ -43,15 +46,22 @@ export interface PlatformSettingsPage { descriptionKey: string; /** Read-only view first, with an Edit action that opens `/edit`. */ hasEditView?: boolean; + /** Shows a "Beta" badge on the landing card, as the classic menu did. */ + isBeta?: boolean; /** 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/PlatformSettingsLanding.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsLanding.tsx index 13d05b18d3a4..962e85b04d46 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsLanding.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsLanding.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Box, Card, Typography } from '@openmetadata/ui-core-components'; +import { Badge, Box, Card, Typography } from '@openmetadata/ui-core-components'; import { useTranslation } from 'react-i18next'; import type { PlatformSettingsPage, @@ -62,12 +62,19 @@ const PlatformSettingsLanding = ({ - - {t(page.titleKey)} - + + + {t(page.titleKey)} + + {page.isBeta && ( + + {t('label.beta')} + + )} + 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 2dac16e82738..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 @@ -64,6 +64,26 @@ jest.mock('./ProfilerSettings', () => () => ( jest.mock('./ProfilerSettingsForm', () => () => (
)); +jest.mock('./DataAssetRulesSettings', () => () => ( +
+)); +jest.mock('./LearningResourcesSettings', () => () => ( +
+)); +const mockFormMounts = jest.fn(); + +jest.mock('./LearningResourceSettingsForm', () => { + const { useState } = jest.requireActual('react'); + + return ({ itemId }: { itemId?: string }) => { + // A lazy initializer runs once per mount, never on re-render. + useState(() => mockFormMounts(itemId)); + + return ( +
+ ); + }; +}); jest.mock('./DataQualitySettings', () => () => (
)); @@ -72,6 +92,15 @@ jest.mock('./DimensionSettingsForm', () =>
)) ); +jest.mock('./search/SearchSettingsView', () => () => ( +
+)); +jest.mock( + './search/EntitySearchSettings', + () => + ({ itemId }: { itemId: string }) => +
{itemId}
+); jest.mock('./AppModeSettingsForm', () => () => (
)); @@ -107,6 +136,9 @@ describe('PlatformSettingsPanel', () => { 'health-check', 'lineage', 'brand-url', + 'data-asset-rules', + 'learning-resources', + 'search', 'app-mode', ].forEach((id) => expect( @@ -117,6 +149,17 @@ describe('PlatformSettingsPanel', () => { expect(lastHeader().title).toBe('label.platform-setting-plural'); }); + it('marks Data Asset Rules as beta, as the classic menu does', () => { + renderPanel(); + + expect( + screen.getByTestId('platform-settings-card-data-asset-rules') + ).toHaveTextContent('label.beta'); + expect( + screen.getByTestId('platform-settings-card-email') + ).not.toHaveTextContent('label.beta'); + }); + it('hides login configuration for SSO providers', () => { mockProvider = AuthProvider.Google; renderPanel(); @@ -160,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(); @@ -183,6 +252,9 @@ describe('PlatformSettingsPanel', () => { ['profiler-configuration', 'profiler-view'], ['profiler-configuration/edit', 'profiler-form'], ['data-quality', 'data-quality-view'], + ['data-asset-rules', 'data-asset-rules-view'], + ['learning-resources', 'learning-resources-view'], + ['learning-resources/edit/res-1', 'learning-resource-form'], ])('routes "%s" to its view or edit form', (subPath, testId) => { mockSubPath = subPath; renderPanel(); @@ -190,6 +262,27 @@ describe('PlatformSettingsPanel', () => { expect(screen.getByTestId(testId)).toBeInTheDocument(); }); + it('remounts the page when the route moves to another item, so it reloads', () => { + mockSubPath = 'learning-resources/edit'; + const { rerender } = renderPanel(); + + mockSubPath = 'learning-resources/edit/res-1'; + rerender( + + + + ); + + expect(mockFormMounts.mock.calls).toEqual([[undefined], ['res-1']]); + }); + + it('has no add/edit route for data asset rules', () => { + mockSubPath = 'data-asset-rules/edit'; + renderPanel(); + + expect(screen.getByTestId('data-asset-rules-view')).toBeInTheDocument(); + }); + it.each([ ['data-quality/edit', '', 'label.add-entity'], ['data-quality/edit/freshness', 'freshness', 'label.edit-entity'], 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 ffa8b37f9629..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 @@ -14,7 +14,7 @@ import { Box, Toggle, Typography } from '@openmetadata/ui-core-components'; import { Hint, Sliders02 } from '@openmetadata/ui-core-components/icons'; import type { Key, ReactNode } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useApplicationStore } from '../../../../../../hooks/useApplicationStore'; import { useSettingsHash } from '../../../../../../hooks/useSettingsHash'; @@ -23,11 +23,14 @@ import AppModeSettings from './AppModeSettings'; import AppModeSettingsForm from './AppModeSettingsForm'; import BrandUrlSettings from './BrandUrlSettings'; import BrandUrlSettingsForm from './BrandUrlSettingsForm'; +import DataAssetRulesSettings from './DataAssetRulesSettings'; import DataQualitySettings from './DataQualitySettings'; import DimensionSettingsForm from './DimensionSettingsForm'; import EmailSettings from './EmailSettings'; import EmailSettingsForm from './EmailSettingsForm'; import HealthCheckSettings from './HealthCheckSettings'; +import LearningResourceSettingsForm from './LearningResourceSettingsForm'; +import LearningResourcesSettings from './LearningResourcesSettings'; import LineageSettings from './LineageSettings'; import LineageSettingsForm from './LineageSettingsForm'; import LoginSettings from './LoginSettings'; @@ -40,6 +43,7 @@ import type { PlatformSettingsView, } from './PlatformSettings.types'; import { + getPageHeader, getVisiblePlatformSettingsPages, hashSubPathToView, viewToSubPath, @@ -47,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'; @@ -68,7 +74,15 @@ const VIEW_PAGES: Partial< 'data-quality': (props) => , 'brand-url': (props) => , lineage: (props) => , + 'data-asset-rules': () => , + 'learning-resources': (props) => , 'app-mode': (props) => , + search: (props) => + props.itemId ? ( + + ) : ( + + ), }; /** The `/edit` view of each page that has one. */ @@ -85,6 +99,7 @@ const FORM_PAGES: Partial< 'data-quality': (props) => , 'brand-url': (props) => , lineage: (props) => , + 'learning-resources': (props) => , 'app-mode': (props) => , }; @@ -139,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 = ( @@ -210,6 +218,7 @@ const PlatformSettingsPanel = ({ } return VIEW_PAGES[view.page]?.({ + itemId: view.itemId, onNavigate, onSetHeaderActions: setPageActions, }); @@ -226,7 +235,9 @@ const PlatformSettingsPanel = ({ ? 'tw:flex tw:min-h-0 tw:flex-1 tw:flex-col tw:overflow-hidden' : 'tw:min-h-0 tw:flex-1 tw:overflow-y-auto tw:p-8 tw:pt-0' }> - {content} + {/* Keyed by route so moving between items (edit A -> edit B, add -> + edit, browser back/forward) remounts the page and loads its data. */} + {content}
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/SettingsFormLayout.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/SettingsFormLayout.tsx index 061de137cc38..b1f6d3308589 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/SettingsFormLayout.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/SettingsFormLayout.tsx @@ -23,7 +23,8 @@ import type { FieldValues, UseFormReturn } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; interface SettingsFormSectionProps { - title: string; + /** Omit when the page header already names the form. */ + title?: string; children: ReactNode; } @@ -33,12 +34,14 @@ export const SettingsFormSection = ({ children, }: SettingsFormSectionProps) => ( - - {title} - + {title && ( + + {title} + + )}
{children}
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; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useSettingsFetch.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useSettingsFetch.ts index 10f72667fd93..8ff69d374ca8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useSettingsFetch.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useSettingsFetch.ts @@ -33,7 +33,15 @@ export const useSettingsFetch = (fetcher: () => Promise) => { } }, []); + // Once per mount. StrictMode runs this effect twice in development; a second + // fetch resolving after the user starts typing would let forms that reset + // on new data overwrite their edits. + const hasLoadedRef = useRef(false); useEffect(() => { + if (hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; void reload(); }, [reload]); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/hooks/useLearningResourceFilters.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/hooks/useLearningResourceFilters.tsx index 9cc4a2310110..9dfffbc510f9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/hooks/useLearningResourceFilters.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/hooks/useLearningResourceFilters.tsx @@ -19,7 +19,6 @@ import { Typography, } from '@openmetadata/ui-core-components'; import { XClose } from '@openmetadata/ui-core-components/icons'; -import { Space } from 'antd'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import FilterSelectDropdown from '../../../components/common/FilterSelectDropdown/FilterSelectDropdown'; @@ -164,7 +163,7 @@ export const useLearningResourceFilters = ( const quickFilters = useMemo( () => ( - + {FILTER_FIELDS.map((field) => ( ))} - +
), [ - filterState, optionsByKey, getSelectedKeys, handleDropdownChange, @@ -270,7 +268,7 @@ export const useLearningResourceFilters = ( {filter.value}