diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index ac150664b4c8..8d71a84a6053 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -7684,9 +7684,7 @@ "specs": [ "playwright/e2e/Features/CustomizeNavigationNewItems.spec.ts", "playwright/e2e/Features/PersonaAppLayout.spec.ts", - "playwright/e2e/Features/SettingsNavigationPage.spec.ts", - "playwright/e2e/Flow/CustomizeLandingPage.spec.ts", - "playwright/e2e/Pages/CustomThemeConfig.spec.ts" + "playwright/e2e/Flow/CustomizeLandingPage.spec.ts" ] }, { @@ -7981,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" ] @@ -7990,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" ] @@ -8029,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", @@ -10145,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" ] @@ -10443,6 +10445,30 @@ "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" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionDeleteDialog.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/EmailSettings.tsx" @@ -10459,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" @@ -10475,6 +10510,15 @@ "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettingsForm.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", + "playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/TestEmailModal.tsx" @@ -10483,6 +10527,78 @@ "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.tsx" + ], + "specs": [ + "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" @@ -11890,17 +12006,6 @@ "playwright/e2e/Flow/NotificationAlerts.spec.ts" ] }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx" - ], - "specs": [ - "playwright/e2e/Features/PersonaAppLayout.spec.ts", - "playwright/e2e/Features/SettingsNavigationPage.spec.ts", - "playwright/e2e/Flow/CustomizeLandingPage.spec.ts", - "playwright/e2e/Pages/CustomThemeConfig.spec.ts" - ] - }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/pages/Application/ApplicationPage.tsx" @@ -12010,10 +12115,7 @@ "openmetadata-ui/src/main/resources/ui/src/pages/CustomizeAppModeSidebarPage/CustomizeAppModeSidebarPage.tsx" ], "specs": [ - "playwright/e2e/Features/PersonaAppLayout.spec.ts", - "playwright/e2e/Features/SettingsNavigationPage.spec.ts", - "playwright/e2e/Flow/CustomizeLandingPage.spec.ts", - "playwright/e2e/Pages/CustomThemeConfig.spec.ts" + "playwright/e2e/Features/PersonaAppLayout.spec.ts" ] }, { @@ -12035,6 +12137,14 @@ "playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/pages/DataQualitySettingsPage/DataQualitySettingsPage.tsx" + ], + "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/SchemaTablesTab.tsx" @@ -12119,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" @@ -12183,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" ] @@ -12192,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" ] }, @@ -12316,6 +12429,7 @@ "openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx" ], "specs": [ + "playwright/e2e/Features/ProfilePlatformSettings.spec.ts", "playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts" ] }, @@ -12401,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 64d91423ec20..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 @@ -15,8 +15,10 @@ import { expect, Page } from '@playwright/test'; import { test } from '../../support/fixtures/base'; import { chooseSelectOption, + getApiContext, redirectToHomePage, toastNotification, + uuid, } from '../../utils/common'; import { clickAndWaitFor } from '../../utils/waitHelpers'; import { enableAiAppMode } from '../Utils/appMode'; @@ -85,7 +87,8 @@ const stubSettingRoundTrip = async ( const openPlatformSettings = async (page: Page) => { await enableAiAppMode(page); - await redirectToHomePage(page); + // Only the user menu is needed; home-page widget loaders are irrelevant here. + await redirectToHomePage(page, false); await page.getByTestId('ask-ai-user-menu-trigger').click(); await page.getByTestId('ai-user-menu-profile').click(); await expect(page.getByTestId('ai-profile-page')).toBeVisible(); @@ -132,11 +135,16 @@ test.describe( await openPlatformSettings(page); const pages: [string, string][] = [ + ['theme', 'theme-settings'], ['email', 'email-settings'], ['login-configuration', 'login-settings'], ['health-check', 'health-check-settings'], + ['profiler-configuration', 'profiler-settings'], + ['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'], ]; @@ -147,7 +155,11 @@ test.describe( } else { await openCard(page, cardId); } - await expect(page.getByTestId(contentTestId)).toBeVisible(); + // Each page loads its own settings; on a shared backend that can + // queue behind sibling workers (e.g. a real /system/status probe). + await expect(page.getByTestId(contentTestId)).toBeVisible({ + timeout: 30_000, + }); await header(page) .getByLabel('Breadcrumb') .getByText('Platform Settings', { exact: true }) @@ -403,5 +415,456 @@ test.describe( 'Classic' ); }); + test('theme: saves colours and logo, and Reset clears them', async ({ + page, + }) => { + const settings = await stubSettingRoundTrip( + page, + 'customUiThemePreference' + ); + await openPlatformSettings(page); + await openCard(page, 'theme'); + await expect(page.getByTestId('theme-settings')).toBeVisible(); + + await header(page).getByTestId('edit-button').click(); + await page.getByTestId('infoColor-color-input').fill('#123456'); + await fillField(page, 'customFaviconUrlPath', 'favicon.ico'); + await page.getByTestId('save-button').click(); + await expect( + page.getByText('Favicon URL is not valid url') + ).toBeVisible(); + expect(settings.puts).toHaveLength(0); + + await fillField( + page, + 'customFaviconUrlPath', + 'https://cdn.example.org/favicon.ico' + ); + await saveSettings(page); + + expect(settings.puts[0].config_value).toMatchObject({ + customLogoConfig: { + customFaviconUrlPath: 'https://cdn.example.org/favicon.ico', + }, + customTheme: { infoColor: '#123456' }, + }); + await expect(page.getByTestId('infoColor-value')).toHaveText('#123456'); + + await clickAndWaitFor( + page, + header(page).getByTestId('reset-button'), + SETTINGS_PUT + ); + expect(settings.puts[1].config_value).toMatchObject({ + customTheme: { infoColor: '', primaryColor: '' }, + customLogoConfig: { customFaviconUrlPath: '' }, + }); + }); + + test('profiler configuration: edits metric rows and sample data', async ({ + page, + }) => { + const settings = await stubSettingRoundTrip( + page, + 'profilerConfiguration', + { + initial: { + metricConfiguration: [ + { dataType: 'INT', metrics: ['max', 'min'], disabled: false }, + { dataType: 'ARRAY', disabled: true }, + ], + sampleDataConfig: { storeSampleData: false, readSampleData: true }, + }, + } + ); + await openPlatformSettings(page); + await openCard(page, 'profiler-configuration'); + + await expect(page.getByTestId('metric-row-INT')).toContainText('Max'); + await expect(page.getByTestId('metric-row-ARRAY')).toContainText( + 'Disabled' + ); + + await header(page).getByTestId('edit-button').click(); + await page.getByTestId('remove-filter-1').click(); + await page.getByTestId('add-fields').click(); + await chooseSelectOption( + page.getByTestId('metric-row-1').getByTestId('data-type-select'), + page.getByRole('option', { name: 'STRING', exact: true }) + ); + await page.getByTestId('store-sample-data-switch').click(); + await saveSettings(page); + + expect(settings.puts[0].config_value).toEqual({ + metricConfiguration: [ + { dataType: 'INT', metrics: ['max', 'min'], disabled: false }, + { dataType: 'STRING', disabled: false }, + ], + sampleDataConfig: { storeSampleData: true, readSampleData: true }, + }); + await expect(page.getByTestId('metric-row-STRING')).toBeVisible(); + await expect(page.getByTestId('store-sample-data-value')).toHaveText( + 'Enabled' + ); + }); + + 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()}`; + await openPlatformSettings(page); + + try { + await openCard(page, 'data-quality'); + await expect(page.getByTestId('dimensions-table')).toBeVisible({ + timeout: 30_000, + }); + + await header(page).getByTestId('add-dimension').click(); + await fillField(page, 'dimension-name', name); + await fillField(page, 'dimension-display-name', 'PW Dimension'); + await page.getByTestId('dimension-color-2').click(); + await clickAndWaitFor( + page, + page.getByTestId('save-button'), + '**/api/v1/dataQuality/dimensions', + 201 + ); + + const row = page.getByTestId(`dimension-${name}`); + await expect(row).toContainText('PW Dimension'); + await expect(row).toContainText('Custom'); + + await clickAndWaitFor( + page, + page.getByTestId(`edit-${name}`), + /\/api\/v1\/dataQuality\/dimensions\?/ + ); + await expect( + page.getByTestId('dimension-name').locator('input') + ).toBeDisabled(); + await fillField(page, 'dimension-display-name', 'PW Renamed'); + await clickAndWaitFor( + page, + page.getByTestId('save-button'), + '**/api/v1/dataQuality/dimensions/*' + ); + await expect(row).toContainText('PW Renamed'); + + await page.getByTestId(`delete-${name}`).click(); + await expect(page.getByTestId('delete-dimension-dialog')).toBeVisible(); + await clickAndWaitFor( + page, + page.getByTestId('confirm-delete-dimension'), + '**/api/v1/dataQuality/dimensions/*' + ); + await expect(row).toHaveCount(0); + } finally { + const { apiContext, afterAction } = await getApiContext(page); + const list = await apiContext.get( + '/api/v1/dataQuality/dimensions?limit=1000' + ); + const leftover = ((await list.json()).data ?? []).find( + (dimension: { name: string }) => dimension.name === name + ); + if (leftover) { + await apiContext.delete( + `/api/v1/dataQuality/dimensions/${leftover.id}?hardDelete=true` + ); + } + 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..328c67299441 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ConfirmDialog.tsx @@ -0,0 +1,96 @@ +/* + * 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(); + + // The confirmed action can't be cancelled mid-flight, so nothing may close the dialog. + const close = () => !isLoading && onCancel(); + + return ( + !open && close()}> + + + + + {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/DataQualitySettings.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.test.tsx new file mode 100644 index 000000000000..958e8dd5bfc3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.test.tsx @@ -0,0 +1,314 @@ +/* + * 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 { ProviderType } from '../../../../../../generated/tests/dataQualityDimension'; +import { + createDataQualityDimension, + deleteDataQualityDimension, + getDataQualityDimensions, + getDataQualityDimensionTestCaseCounts, + getDataQualityDimensionTestDefinitionCounts, + patchDataQualityDimension, +} from '../../../../../../rest/dataQualityDimensionAPI'; +import { showErrorToast } from '../../../../../../utils/ToastUtils'; +import DataQualitySettings from './DataQualitySettings'; +import DimensionSettingsForm from './DimensionSettingsForm'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../rest/dataQualityDimensionAPI', () => ({ + getDataQualityDimensions: jest.fn(), + getDataQualityDimensionTestCaseCounts: jest.fn(), + getDataQualityDimensionTestDefinitionCounts: jest.fn(), + createDataQualityDimension: jest.fn(), + patchDataQualityDimension: jest.fn(), + deleteDataQualityDimension: jest.fn(), +})); + +jest.mock('../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +const SYSTEM = { + id: 'sys-1', + name: 'accuracy', + displayName: 'Accuracy', + description: 'Values reflect reality.', + provider: ProviderType.System, + style: { color: '#175CD3' }, +}; +const CUSTOM = { + id: 'cust-1', + name: 'freshness', + displayName: 'Freshness', + description: 'Data arrives on time.', + provider: ProviderType.User, + style: { color: '#067647' }, +}; + +const onNavigate = jest.fn(); +let headerActions: ReactNode; +const onSetHeaderActions = (actions: ReactNode) => { + headerActions = actions; +}; + +const inputOf = (testId: string) => + screen.getByTestId(testId).querySelector('input, textarea') as + | HTMLInputElement + | HTMLTextAreaElement; + +const renderList = async () => { + render( + + ); + await screen.findByTestId('dimensions-table'); +}; + +describe('Data Quality settings', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getDataQualityDimensions as jest.Mock).mockResolvedValue({ + data: [SYSTEM, CUSTOM], + }); + (getDataQualityDimensionTestCaseCounts as jest.Mock).mockResolvedValue({ + 'sys-1': 6, + 'cust-1': 2, + }); + ( + getDataQualityDimensionTestDefinitionCounts as jest.Mock + ).mockResolvedValue({ 'cust-1': 1 }); + (deleteDataQualityDimension as jest.Mock).mockResolvedValue({}); + (createDataQualityDimension as jest.Mock).mockResolvedValue({}); + (patchDataQualityDimension as jest.Mock).mockResolvedValue({}); + }); + + it('lists dimensions with type, test case counts and read-only system rows', async () => { + await renderList(); + + expect(screen.getByTestId('dimension-accuracy')).toHaveTextContent( + 'label.system' + ); + expect(screen.getByTestId('dimension-accuracy')).toHaveTextContent('6'); + expect(screen.getByTestId('dimension-freshness')).toHaveTextContent( + 'label.custom' + ); + expect(screen.getByTestId('edit-accuracy')).toBeDisabled(); + expect(screen.getByTestId('delete-accuracy')).toBeDisabled(); + expect(screen.getByTestId('edit-freshness')).toBeEnabled(); + }); + + it('shows "--" rather than 0 when the counts could not be fetched', async () => { + (getDataQualityDimensionTestCaseCounts as jest.Mock).mockRejectedValue( + new Error('boom') + ); + await renderList(); + + expect(screen.getByTestId('dimension-freshness')).toHaveTextContent('--'); + }); + + it('filters by name, display name or description', async () => { + await renderList(); + + fireEvent.change(inputOf('search-dimensions'), { + target: { value: 'on time' }, + }); + + await waitFor(() => + expect(screen.queryByTestId('dimension-accuracy')).not.toBeInTheDocument() + ); + + expect(screen.getByTestId('dimension-freshness')).toBeInTheDocument(); + }); + + it('header Add and row Edit open the dimension form', async () => { + await renderList(); + render(<>{headerActions}); + + fireEvent.click(screen.getByTestId('add-dimension')); + + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'data-quality', + isEditing: true, + }); + + fireEvent.click(screen.getByTestId('edit-freshness')); + + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'data-quality', + isEditing: true, + itemId: 'freshness', + }); + }); + + it('delete warns about the affected test cases and definitions, then deletes', async () => { + await renderList(); + + fireEvent.click(screen.getByTestId('delete-freshness')); + const dialog = await screen.findByTestId('delete-dimension-dialog'); + + expect(dialog).toHaveTextContent('message.dimension-in-use-count'); + expect(dialog).toHaveTextContent( + 'message.dimension-in-use-test-definition-count' + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete-dimension')); + }); + + expect(deleteDataQualityDimension).toHaveBeenCalledWith('cust-1'); + expect(getDataQualityDimensions).toHaveBeenCalledTimes(2); + }); + + it('cannot be closed while the delete is in flight', async () => { + let finishDelete: () => void = () => undefined; + (deleteDataQualityDimension as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + finishDelete = resolve; + }) + ); + await renderList(); + fireEvent.click(screen.getByTestId('delete-freshness')); + await screen.findByTestId('delete-dimension-dialog'); + + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete-dimension')); + }); + + expect( + screen.queryByRole('button', { name: /close/i }) + ).not.toBeInTheDocument(); + + fireEvent.keyDown(screen.getByTestId('delete-dimension-dialog'), { + key: 'Escape', + }); + + expect(screen.getByTestId('delete-dimension-dialog')).toBeInTheDocument(); + + await act(async () => { + finishDelete(); + }); + + await waitFor(() => + expect( + screen.queryByTestId('delete-dimension-dialog') + ).not.toBeInTheDocument() + ); + }); + + it('creates a dimension with its colour and returns to the list', async () => { + render(); + await screen.findByTestId('dimension-form'); + + fireEvent.change(inputOf('dimension-name'), { + target: { value: 'timeliness' }, + }); + fireEvent.click(screen.getByTestId('dimension-color-2')); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(createDataQualityDimension).toHaveBeenCalledWith({ + name: 'timeliness', + displayName: undefined, + description: undefined, + style: { color: '#067647' }, + }); + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'data-quality', + isEditing: false, + }); + }); + + it('rejects a name with spaces', async () => { + render(); + await screen.findByTestId('dimension-form'); + + fireEvent.change(inputOf('dimension-name'), { + target: { value: 'not valid' }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect( + await screen.findByText('message.dimension-name-invalid') + ).toBeInTheDocument(); + expect(createDataQualityDimension).not.toHaveBeenCalled(); + }); + + it('edits an existing dimension with a JSON patch and a locked name', async () => { + render( + + ); + await waitFor(() => + expect(inputOf('dimension-display-name')).toHaveValue('Freshness') + ); + + expect(inputOf('dimension-name')).toBeDisabled(); + + fireEvent.change(inputOf('dimension-display-name'), { + target: { value: 'Timeliness' }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(patchDataQualityDimension).toHaveBeenCalledWith('cust-1', [ + { op: 'replace', path: '/displayName', value: 'Timeliness' }, + ]); + }); + + it('a stale edit link returns to the list with an error', async () => { + render( + + ); + + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'data-quality', + isEditing: false, + }) + ); + + expect(showErrorToast).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.tsx new file mode 100644 index 000000000000..b7b6022816c6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.tsx @@ -0,0 +1,234 @@ +/* + * 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, + Button, + EmptyPlaceholder, + Input, + Table, + TableCard, + Typography, +} from '@openmetadata/ui-core-components'; +import { + DataQuality, + Plus, + Search, +} from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DataQualityDimension } from '../../../../../../generated/tests/dataQualityDimension'; +import { deleteDataQualityDimension } from '../../../../../../rest/dataQualityDimensionAPI'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../utils/ToastUtils'; +import { + countFor, + fetchDimensionList, + filterDimensions, +} from './DataQualitySettings.utils'; +import { + DimensionActionsCell, + DimensionNameCell, + DimensionTypeCell, +} from './DimensionCells'; +import DimensionDeleteDialog from './DimensionDeleteDialog'; +import type { PlatformSettingsPageProps } from './PlatformSettings.types'; +import { SettingsSkeleton } from './SettingsFormLayout'; +import { useSettingsFetch } from './useSettingsFetch'; + +const DataQualitySettings = ({ + onNavigate, + onSetHeaderActions, +}: PlatformSettingsPageProps) => { + const { t } = useTranslation(); + const { data, isLoading, reload } = useSettingsFetch(fetchDimensionList); + const [searchTerm, setSearchTerm] = useState(''); + const [deleting, setDeleting] = useState(); + const [isDeleting, setIsDeleting] = useState(false); + + const dimensions = useMemo( + () => filterDimensions(data?.dimensions ?? [], searchTerm), + [data, searchTerm] + ); + + useEffect(() => { + onSetHeaderActions( + + ); + + return () => onSetHeaderActions(undefined); + }, [onNavigate, onSetHeaderActions, t]); + + const columns = useMemo( + () => [ + { id: 'name', label: t('label.dimension') }, + { id: 'description', label: t('label.description') }, + { id: 'type', label: t('label.type'), className: 'tw:w-28' }, + { + id: 'testCases', + label: t('label.test-case-plural'), + className: 'tw:w-28', + }, + { + id: 'actions', + label: t('label.action-plural'), + className: 'tw:w-24', + }, + ], + [t] + ); + + const handleDelete = async () => { + if (!deleting?.id) { + return; + } + setIsDeleting(true); + try { + await deleteDataQualityDimension(deleting.id); + showSuccessToast( + t('server.entity-deleted-successfully', { + entity: t('label.dimension'), + }) + ); + setDeleting(undefined); + await reload(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsDeleting(false); + } + }; + + const renderCell = (dimension: DataQualityDimension, columnId: string) => { + switch (columnId) { + case 'name': + return ; + case 'description': + return ( + + {dimension.description || '--'} + + ); + case 'type': + return ; + case 'testCases': + // '--' rather than 0: an unknown count must not read as "unused". + return countFor(data?.testCaseCounts, dimension) ?? '--'; + default: + return ( + + onNavigate({ + type: 'page', + page: 'data-quality', + isEditing: true, + itemId: item.name, + }) + } + /> + ); + } + }; + + if (isLoading && !data) { + return ; + } + + return ( + + + + + + + + {(column) => ( + + )} + + ( + } + title={ + searchTerm + ? t('label.no-matching-results') + : t('label.no-entity', { + entity: t('label.dimension-plural'), + }) + } + /> + )}> + {(dimension) => ( + + {(column) => ( + + {renderCell(dimension, column.id)} + + )} + + )} + +
+
+ + setDeleting(undefined)} + onConfirm={handleDelete} + /> +
+ ); +}; + +export default DataQualitySettings; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.utils.ts new file mode 100644 index 000000000000..53630df9dfa4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DataQualitySettings.utils.ts @@ -0,0 +1,64 @@ +/* + * 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 { AGGREGATE_PAGE_SIZE_LARGE } from '../../../../../../constants/constants'; +import { DataQualityDimension } from '../../../../../../generated/tests/dataQualityDimension'; +import { + getDataQualityDimensions, + getDataQualityDimensionTestCaseCounts, + getDataQualityDimensionTestDefinitionCounts, +} from '../../../../../../rest/dataQualityDimensionAPI'; + +/** + * A count map is `undefined` when its request failed: unknown, not zero. Test + * definitions reference a dimension by name, so they are counted separately + * from test cases. + */ +export interface DimensionListState { + dimensions: DataQualityDimension[]; + testCaseCounts?: Record; + testDefinitionCounts?: Record; +} + +export const fetchDimensionList = async (): Promise => { + const [{ data }, testCaseCounts, testDefinitionCounts] = await Promise.all([ + getDataQualityDimensions({ limit: AGGREGATE_PAGE_SIZE_LARGE }), + // A missing count must not hide the dimension list itself. + getDataQualityDimensionTestCaseCounts().catch(() => undefined), + getDataQualityDimensionTestDefinitionCounts().catch(() => undefined), + ]); + + return { dimensions: data, testCaseCounts, testDefinitionCounts }; +}; + +export const countFor = ( + counts: Record | undefined, + dimension: DataQualityDimension | undefined +): number | undefined => counts?.[dimension?.id ?? '']; + +export const filterDimensions = ( + dimensions: DataQualityDimension[], + searchTerm: string +) => { + const term = searchTerm.trim().toLowerCase(); + + if (!term) { + return dimensions; + } + + return dimensions.filter((dimension) => + [dimension.name, dimension.displayName, dimension.description].some( + (value) => value?.toLowerCase().includes(term) + ) + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionCells.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionCells.tsx new file mode 100644 index 000000000000..fa7c990501d2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionCells.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 { + Badge, + Box, + ButtonUtility, + Typography, +} from '@openmetadata/ui-core-components'; +import { Edit01, Trash01 } from '@openmetadata/ui-core-components/icons'; +import { useTranslation } from 'react-i18next'; +import { DIMENSION_COLOR_PALETTE } from '../../../../../../constants/DataQualityDimension.constants'; +import { + DataQualityDimension, + ProviderType, +} from '../../../../../../generated/tests/dataQualityDimension'; + +const isSystemDimension = (dimension: DataQualityDimension) => + dimension.provider === ProviderType.System; + +export const DimensionNameCell = ({ + dimension, +}: { + dimension: DataQualityDimension; +}) => ( + + + + + {dimension.displayName ?? dimension.name} + + + {dimension.name} + + + +); + +export const DimensionTypeCell = ({ + dimension, +}: { + dimension: DataQualityDimension; +}) => { + const { t } = useTranslation(); + const isSystem = isSystemDimension(dimension); + + return ( + + {isSystem ? t('label.system') : t('label.custom')} + + ); +}; + +interface DimensionActionsCellProps { + dimension: DataQualityDimension; + onEdit: (dimension: DataQualityDimension) => void; + onDelete: (dimension: DataQualityDimension) => void; +} + +/** System dimensions ship with the platform and are read-only. */ +export const DimensionActionsCell = ({ + dimension, + onEdit, + onDelete, +}: DimensionActionsCellProps) => { + const { t } = useTranslation(); + const isSystem = isSystemDimension(dimension); + const lockedTooltip = t('message.system-dimensions-are-read-only'); + const entity = t('label.dimension'); + + return ( + + onEdit(dimension)} + /> + onDelete(dimension)} + /> + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionDeleteDialog.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionDeleteDialog.tsx new file mode 100644 index 000000000000..1b8bef5bf3be --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionDeleteDialog.tsx @@ -0,0 +1,125 @@ +/* + * 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, + Button, + Dialog, + Modal, + ModalOverlay, + Typography, +} from '@openmetadata/ui-core-components'; +import { isUndefined } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { DataQualityDimension } from '../../../../../../generated/tests/dataQualityDimension'; +import { getEntityName } from '../../../../../../utils/EntityNameUtils'; + +interface DimensionDeleteDialogProps { + dimension?: DataQualityDimension; + /** `undefined` means the count could not be fetched: reported as unknown, not none. */ + testCaseCount?: number; + testDefinitionCount?: number; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +const DimensionDeleteDialog = ({ + dimension, + testCaseCount, + testDefinitionCount, + isDeleting, + onCancel, + onConfirm, +}: DimensionDeleteDialogProps) => { + const { t } = useTranslation(); + const isImpactUnknown = + isUndefined(testCaseCount) || isUndefined(testDefinitionCount); + + // A delete in flight can't be cancelled, so nothing may close the dialog. + const close = () => !isDeleting && onCancel(); + + return ( + !open && close()}> + + + + + + {t('message.delete-dimension-confirmation')} + + {isImpactUnknown && ( + + {t('message.dimension-impact-unknown')} + + )} + {Boolean(testCaseCount) && ( + + + {t('message.dimension-in-use-count', { + count: testCaseCount, + })} + {' '} + {t('message.dimension-delete-fallback')} + + )} + {Boolean(testDefinitionCount) && ( + + + {t('message.dimension-in-use-test-definition-count', { + count: testDefinitionCount, + })} + {' '} + {t('message.dimension-delete-test-definition-fallback')} + + )} + + + +
+ + +
+
+
+
+
+ ); +}; + +export default DimensionDeleteDialog; 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 new file mode 100644 index 000000000000..1c4792e64f20 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/DimensionSettingsForm.tsx @@ -0,0 +1,245 @@ +/* + * 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, + Typography, +} from '@openmetadata/ui-core-components'; +import { AxiosError } from 'axios'; +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 type { PlatformSettingsFormProps } from './PlatformSettings.types'; +import SettingsFormLayout, { + SettingsFormSection, + SettingsSkeleton, +} from './SettingsFormLayout'; +import { useSettingsFetch } from './useSettingsFetch'; + +export interface DimensionFormValues { + name: string; + displayName: string; + description: string; + color: string; +} + +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 => ({ + name: dimension?.name ?? '', + displayName: dimension?.displayName ?? '', + description: dimension?.description ?? '', + color: dimension?.style?.color ?? DIMENSION_COLOR_PALETTE[0], +}); + +/** Add (no `itemId`) or edit the dimension named `itemId`. */ +const DimensionSettingsForm = ({ + showHint, + itemId, + onNavigate, +}: PlatformSettingsFormProps) => { + const { t } = useTranslation(); + const isEditing = Boolean(itemId); + const { data, isLoading } = useSettingsFetch(fetchDimensions); + const [isSaving, setIsSaving] = useState(false); + const form = useForm({ defaultValues: toFormValues() }); + const [color, name, displayName] = useWatch({ + control: form.control, + name: ['color', 'name', 'displayName'], + }); + + const editing = useMemo( + () => data?.find((dimension) => dimension.name === itemId), + [data, itemId] + ); + + const backToList = () => + onNavigate({ type: 'page', page: 'data-quality', isEditing: false }); + + // Runs once per loaded list, so a not-found link toasts once. `t` is read + // through a ref: it is not data this effect reacts to. + const tRef = useRef(t); + tRef.current = t; + useEffect(() => { + if (!isEditing || !data) { + return; + } + if (editing) { + form.reset(toFormValues(editing)); + } else { + // Stale deep link: the dimension was deleted. + showErrorToast( + tRef.current('server.entity-fetch-error', { + entity: tRef.current('label.dimension'), + }) + ); + onNavigate({ type: 'page', page: 'data-quality', isEditing: false }); + } + }, [data, editing, form, isEditing, onNavigate]); + + const fields: FieldProp[] = useMemo( + () => [ + { + name: 'name', + label: t('label.name'), + type: FieldTypes.TEXT, + required: true, + // The name is referenced by the API and every test case relationship. + doc: isEditing + ? t('message.dimension-name-is-fixed-after-creation') + : t('message.dimension-name-help'), + props: { 'data-testid': 'dimension-name', isDisabled: isEditing }, + rules: { + required: t('label.field-required', { field: t('label.name') }), + pattern: { + value: DIMENSION_NAME_PATTERN, + message: t('message.dimension-name-invalid'), + }, + }, + }, + { + name: 'displayName', + label: t('label.display-name'), + type: FieldTypes.TEXT, + doc: t('message.dimension-display-name-help'), + props: { 'data-testid': 'dimension-display-name' }, + }, + { + name: 'description', + label: t('label.description'), + type: FieldTypes.TEXTAREA, + placeholder: t('message.dimension-description-placeholder'), + props: { 'data-testid': 'dimension-description' }, + }, + { + name: 'color', + label: t('label.color'), + type: FieldTypes.COLOR_PICKER, + props: { + 'data-testid': 'dimension-color', + colors: DIMENSION_COLOR_PALETTE, + }, + }, + ], + [isEditing, t] + ); + + const handleSubmit = async (values: DimensionFormValues) => { + setIsSaving(true); + try { + if (editing) { + const updated: DataQualityDimension = { + ...editing, + displayName: values.displayName || undefined, + description: values.description || undefined, + style: { ...editing.style, color: values.color }, + }; + await patchDataQualityDimension( + editing.id ?? '', + compare(editing, updated) + ); + } else { + await createDataQualityDimension({ + name: values.name, + displayName: values.displayName || undefined, + description: values.description || undefined, + style: { color: values.color }, + }); + } + showSuccessToast( + t( + editing + ? 'server.update-entity-success' + : 'server.create-entity-success', + { entity: t('label.dimension') } + ) + ); + backToList(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsSaving(false); + } + }; + + if (isEditing && (isLoading || !editing)) { + return ( +
+ +
+ ); + } + + return ( + + + + {fields.map((field) => ( +
{getField(field)}
+ ))} +
+
+ + + + + + + {displayName || name || t('label.dimension')} + + + + {t('message.data-quality-dimensions-description')} + + + +
+ ); +}; + +export default DimensionSettingsForm; 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/HexColorField.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/HexColorField.tsx new file mode 100644 index 000000000000..c9ad48fa5d5c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/HexColorField.tsx @@ -0,0 +1,74 @@ +/* + * 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, Input } from '@openmetadata/ui-core-components'; +import { useTranslation } from 'react-i18next'; +import { HEX_COLOR_CODE_REGEX } from '../../../../../../constants/regex.constants'; + +interface HexColorFieldProps { + name: string; + label: string; + value: string; + isInvalid?: boolean; + onChange: (value: string) => void; + onBlur?: () => void; +} + +const FALLBACK_SWATCH = '#000000'; + +/** + * Free-form HEX colour: a native colour swatch for picking plus a text input + * for typing, both bound to the same value. The core ColorPickerField only + * offers a fixed palette, and the theme accepts any colour. + */ +const HexColorField = ({ + name, + label, + value, + isInvalid, + onChange, + onBlur, +}: HexColorFieldProps) => { + const { t } = useTranslation(); + // only accepts #rrggbb. + const swatchValue = + HEX_COLOR_CODE_REGEX.test(value) && value.length === 7 + ? value + : FALLBACK_SWATCH; + + return ( + + onChange(event.target.value)} + /> + + + ); +}; + +export default HexColorField; 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 c70d453aa644..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,19 +12,34 @@ */ 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'; export const PLATFORM_SETTINGS_HASH_TAB = 'platform-settings'; export const PLATFORM_SETTINGS_PAGES: PlatformSettingsPage[] = [ + { + id: 'theme', + icon: Customize, + titleKey: 'label.theme', + descriptionKey: 'message.appearance-configuration-message', + hasEditView: true, + }, { id: 'email', icon: Mail01, @@ -46,6 +61,25 @@ export const PLATFORM_SETTINGS_PAGES: PlatformSettingsPage[] = [ titleKey: 'label.health-check', descriptionKey: 'message.page-sub-header-for-om-health-configuration', }, + { + id: 'profiler-configuration', + icon: RunProfiler, + titleKey: 'label.profiler-configuration', + descriptionKey: 'message.page-sub-header-for-profiler-configuration', + hasEditView: true, + hasFieldHints: false, + }, + { + id: 'data-quality', + icon: DataQuality, + titleKey: 'label.data-quality', + descriptionKey: 'message.page-sub-header-for-data-quality-settings', + hasEditView: true, + getEditTitle: (t, itemId) => + t(itemId ? 'label.edit-entity' : 'label.add-entity', { + entity: t('label.dimension'), + }), + }, { id: 'lineage', icon: Dataflow02, @@ -53,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, @@ -60,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 eac9e98431b1..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 @@ -11,20 +11,33 @@ * limitations under the License. */ +import type { TFunction } from 'i18next'; import type { FC } from 'react'; import type { AuthProvider } from '../../../../../../generated/settings/settings'; export type PlatformSettingsPageId = + | 'theme' | 'email' | 'login-configuration' | 'health-check' + | 'profiler-configuration' + | 'data-quality' | 'lineage' | 'brand-url' + | 'data-asset-rules' + | 'learning-resources' + | 'search' | 'app-mode'; export type PlatformSettingsView = | { type: 'landing' } - | { type: 'page'; page: PlatformSettingsPageId; isEditing: boolean }; + | { + type: 'page'; + page: PlatformSettingsPageId; + isEditing: boolean; + /** List pages: the item being edited; absent on `edit` means "add". */ + itemId?: string; + }; export interface PlatformSettingsPage { id: PlatformSettingsPageId; @@ -33,18 +46,28 @@ 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; } export interface PlatformSettingsFormProps { showHint: boolean; + itemId?: string; onNavigate: (view: PlatformSettingsView) => 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 7df1e804e3cd..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, @@ -48,6 +51,23 @@ describe('PlatformSettings.utils', () => { { type: 'page', page: 'health-check', isEditing: false }, ], ['lineage/edit', { type: 'page', page: 'lineage', isEditing: true }], + [ + 'data-quality/edit/accuracy', + { + type: 'page', + page: 'data-quality', + isEditing: true, + 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); }); @@ -69,6 +89,14 @@ describe('PlatformSettings.utils', () => { expect( viewToSubPath({ type: 'page', page: 'health-check', isEditing: false }) ).toBe('health-check'); + expect( + viewToSubPath({ + type: 'page', + page: 'data-quality', + isEditing: true, + itemId: 'freshness', + }) + ).toBe('data-quality/edit/freshness'); }); it('treats an empty number input as unset rather than zero', () => { @@ -88,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 c48f1d96f35a..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 @@ -37,17 +37,26 @@ export const hashSubPathToView = ( subPath: string, pages: PlatformSettingsPage[] ): PlatformSettingsView => { - const [pageId, mode] = subPath.split('/'); + const [pageId, mode, ...rest] = subPath.split('/'); const page = pages.find((item) => item.id === pageId); if (!page) { return LANDING_VIEW; } + const isEditing = Boolean(page.hasEditView) && mode === EDIT_SEGMENT; + 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', page: page.id, - isEditing: Boolean(page.hasEditView) && mode === EDIT_SEGMENT, + isEditing, + ...(itemId ? { itemId } : {}), }; }; @@ -58,7 +67,13 @@ export const viewToSubPath = ( return undefined; } - return view.isEditing ? `${view.page}/${EDIT_SEGMENT}` : view.page; + if (!view.isEditing) { + return view.itemId ? `${view.page}/${view.itemId}` : view.page; + } + + return view.itemId + ? `${view.page}/${EDIT_SEGMENT}/${view.itemId}` + : `${view.page}/${EDIT_SEGMENT}`; }; /** Number inputs hold strings; an empty input means "unset", not zero. */ @@ -78,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 b0b24aeccbbb..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 @@ -56,6 +56,51 @@ jest.mock('./BrandUrlSettingsForm', () => () => (
)); jest.mock('./AppModeSettings', () => () =>
); +jest.mock('./ThemeSettings', () => () =>
); +jest.mock('./ThemeSettingsForm', () => () =>
); +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', () => () => ( +
+)); +jest.mock('./DimensionSettingsForm', () => + jest.fn(({ itemId }: { itemId?: string }) => ( +
+ )) +); +jest.mock('./search/SearchSettingsView', () => () => ( +
+)); +jest.mock( + './search/EntitySearchSettings', + () => + ({ itemId }: { itemId: string }) => +
{itemId}
+); jest.mock('./AppModeSettingsForm', () => () => (
)); @@ -83,11 +128,17 @@ describe('PlatformSettingsPanel', () => { renderPanel(); [ + 'theme', 'email', 'login-configuration', + 'profiler-configuration', + 'data-quality', 'health-check', 'lineage', 'brand-url', + 'data-asset-rules', + 'learning-resources', + 'search', 'app-mode', ].forEach((id) => expect( @@ -98,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(); @@ -141,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(); @@ -159,6 +247,14 @@ describe('PlatformSettingsPanel', () => { ['lineage/edit', 'lineage-form'], ['app-mode', 'app-mode-view'], ['app-mode/edit', 'app-mode-form'], + ['theme', 'theme-view'], + ['theme/edit', 'theme-form'], + ['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(); @@ -166,6 +262,44 @@ 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'], + ])( + 'routes "%s" to the dimension form with its own title', + (subPath, itemId, title) => { + mockSubPath = subPath; + renderPanel(); + + expect(screen.getByTestId('dimension-form')).toHaveAttribute( + 'data-item-id', + itemId + ); + expect(lastHeader().title).toBe(title); + } + ); + it('omits the show-hint toggle on forms without field docs', () => { mockSubPath = 'app-mode/edit'; renderPanel(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/PlatformSettingsPanel.tsx index 022ced029e7c..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,9 +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'; @@ -38,11 +43,18 @@ import type { PlatformSettingsView, } from './PlatformSettings.types'; import { + getPageHeader, getVisiblePlatformSettingsPages, hashSubPathToView, viewToSubPath, } from './PlatformSettings.utils'; 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'; interface PlatformSettingsPanelProps { onHeaderChange?: (override: ProfileHeaderOverride | null) => void; @@ -54,12 +66,23 @@ const VIEW_PAGES: Partial< (props: PlatformSettingsPageProps) => ReactNode > > = { + theme: (props) => , email: (props) => , 'login-configuration': (props) => , 'health-check': (props) => , + 'profiler-configuration': (props) => , + '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. */ @@ -69,10 +92,14 @@ const FORM_PAGES: Partial< (props: PlatformSettingsFormProps) => ReactNode > > = { + theme: (props) => , email: (props) => , 'login-configuration': (props) => , + 'profiler-configuration': (props) => , + 'data-quality': (props) => , 'brand-url': (props) => , lineage: (props) => , + 'learning-resources': (props) => , 'app-mode': (props) => , }; @@ -127,14 +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 = 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 = ( @@ -188,10 +210,15 @@ const PlatformSettingsPanel = ({ } if (showsForm) { - return FORM_PAGES[view.page]?.({ showHint, onNavigate }); + return FORM_PAGES[view.page]?.({ + showHint, + onNavigate, + itemId: view.itemId, + }); } return VIEW_PAGES[view.page]?.({ + itemId: view.itemId, onNavigate, onSetHeaderActions: setPageActions, }); @@ -208,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/ProfilerSettings.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.test.tsx new file mode 100644 index 000000000000..72cf45e28c34 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.test.tsx @@ -0,0 +1,218 @@ +/* + * 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 { values } from 'lodash'; +import { + DataType, + MetricType, +} from '../../../../../../generated/configuration/profilerConfiguration'; +import { SettingType } from '../../../../../../generated/settings/settings'; +import { + getSettingsConfigFromConfigType, + updateSettingsConfig, +} from '../../../../../../rest/settingConfigAPI'; +import ProfilerSettings from './ProfilerSettings'; +import ProfilerSettingsForm from './ProfilerSettingsForm'; + +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(), +})); + +const mockSparkConfig = jest.fn(); + +jest.mock( + '../../../../../../pages/ProfilerConfigurationPage/ProfilerConfigurationClassBase', + () => ({ + getSparkAgentConfigComponent: () => mockSparkConfig(), + }) +); + +const onNavigate = jest.fn(); +const STORED = { + metricConfiguration: [ + { dataType: DataType.Int, metrics: values(MetricType) }, + { + dataType: DataType.Array, + metrics: [MetricType.Max], + disabled: true, + }, + ], + sampleDataConfig: { storeSampleData: false, readSampleData: true }, +}; + +const mockStored = (configValue?: unknown) => + (getSettingsConfigFromConfigType as jest.Mock).mockResolvedValue({ + data: { config_value: configValue }, + }); + +describe('Profiler configuration settings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSparkConfig.mockReturnValue(undefined); + (updateSettingsConfig as jest.Mock).mockResolvedValue({ data: {} }); + }); + + it('view shows each data type, "All" for a full set and the disabled state', async () => { + mockStored(STORED); + render( + + ); + + const intRow = await screen.findByTestId('metric-row-INT'); + + expect(intRow).toHaveTextContent('label.all'); + expect(screen.getByTestId('metric-row-ARRAY')).toHaveTextContent( + 'label.disabled' + ); + expect(screen.getByTestId('store-sample-data-value')).toHaveTextContent( + 'label.disabled' + ); + expect(screen.getByTestId('read-sample-data-value')).toHaveTextContent( + 'label.enabled' + ); + }); + + it('view falls back to the defaults and renders the Collate Spark section when present', async () => { + mockStored(undefined); + mockSparkConfig.mockReturnValue(() =>
); + render( + + ); + + expect( + await screen.findByTestId('metric-configuration-empty') + ).toBeInTheDocument(); + expect(screen.getByTestId('store-sample-data-value')).toHaveTextContent( + 'label.enabled' + ); + expect(screen.getByTestId('spark-config')).toBeInTheDocument(); + }); + + it('form saves rows, expanding "All" to every metric, and returns to the view', async () => { + mockStored(STORED); + render(); + await screen.findByTestId('metric-row-1'); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(updateSettingsConfig).toHaveBeenCalledWith({ + config_type: SettingType.ProfilerConfiguration, + config_value: { + metricConfiguration: [ + { + dataType: DataType.Int, + metrics: values(MetricType), + disabled: false, + }, + { + dataType: DataType.Array, + metrics: [MetricType.Max], + disabled: true, + }, + ], + // Storing is off, so reading keeps its own stored value. + sampleDataConfig: { storeSampleData: false, readSampleData: true }, + }, + }); + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'profiler-configuration', + isEditing: false, + }); + }); + + it('removing a row drops it from the saved configuration', async () => { + mockStored(STORED); + render(); + await screen.findByTestId('metric-row-1'); + + fireEvent.click(screen.getByTestId('remove-filter-0')); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + const payload = (updateSettingsConfig as jest.Mock).mock.calls[0][0]; + + expect(payload.config_value.metricConfiguration).toEqual([ + { dataType: DataType.Array, metrics: [MetricType.Max], disabled: true }, + ]); + }); + + it('a new row needs a data type before it can be saved', async () => { + mockStored(STORED); + render(); + await screen.findByTestId('metric-row-1'); + + fireEvent.click(screen.getByTestId('add-fields')); + await waitFor(() => + expect(screen.getByTestId('metric-row-2')).toBeInTheDocument() + ); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect( + await screen.findByText('message.field-text-is-required') + ).toBeInTheDocument(); + expect(updateSettingsConfig).not.toHaveBeenCalled(); + }); + + it('enabling storage of sample data also enables reading it', async () => { + mockStored({ + ...STORED, + sampleDataConfig: { storeSampleData: false, readSampleData: false }, + }); + render(); + await screen.findByTestId('metric-row-1'); + + fireEvent.click( + screen.getByTestId('store-sample-data-switch').querySelector('input') ?? + screen.getByTestId('store-sample-data-switch') + ); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + const payload = (updateSettingsConfig as jest.Mock).mock.calls[0][0]; + + expect(payload.config_value.sampleDataConfig).toEqual({ + storeSampleData: true, + readSampleData: true, + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.tsx new file mode 100644 index 000000000000..45cf9703edcb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.tsx @@ -0,0 +1,118 @@ +/* + * 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 { Badge, Box, Typography } from '@openmetadata/ui-core-components'; +import { startCase } from 'lodash'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +// eslint-disable-next-line openmetadata-imports/no-lower-layer-page-imports -- Spark extension point lives there +import profilerConfigurationClassBase from '../../../../../../pages/ProfilerConfigurationPage/ProfilerConfigurationClassBase'; +import SettingsSection, { ReadOnlyRow } from '../../components/SettingsSection'; +import type { PlatformSettingsPageProps } from './PlatformSettings.types'; +import { fetchProfilerConfig, isAllMetrics } from './ProfilerSettings.utils'; +import { SettingsSkeleton } from './SettingsFormLayout'; +import SettingValue from './SettingValue'; +import { useEditHeaderAction } from './useEditHeaderAction'; +import { useSettingsFetch } from './useSettingsFetch'; + +const ProfilerSettings = (props: PlatformSettingsPageProps) => { + const { t } = useTranslation(); + const { data: config, isLoading } = useSettingsFetch(fetchProfilerConfig); + useEditHeaderAction('profiler-configuration', isLoading, props); + + // Collate contributes the Spark agent section; OSS has none. + const SparkAgentConfig = useMemo( + () => profilerConfigurationClassBase.getSparkAgentConfigComponent(), + [] + ); + + if (isLoading || !config) { + return ; + } + + const enabledLabel = (value?: boolean) => + value ? t('label.enabled') : t('label.disabled'); + + return ( + + + {config.metricConfiguration.length === 0 && ( + + + + )} + {config.metricConfiguration.map((row, index) => ( + + + {row.disabled && ( + + {t('label.disabled')} + + )} + {isAllMetrics(row.metrics) ? ( + + {t('label.all')} + + ) : ( + row.metrics?.map((metric) => ( + + {startCase(metric)} + + )) + )} + {!row.disabled && !row.metrics?.length && } + + + ))} + + + + + + + + + + + + + {t('message.sample-data-ingestion-config-description')} + + + + {SparkAgentConfig && } + + ); +}; + +export default ProfilerSettings; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.utils.test.ts new file mode 100644 index 000000000000..eadd12a56ebf --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.utils.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { values } from 'lodash'; +import { + DataType, + MetricType, +} from '../../../../../../generated/configuration/profilerConfiguration'; +import { + ALL_METRICS, + toProfilerConfig, + toProfilerFormValues, + withProfilerDefaults, +} from './ProfilerSettings.utils'; + +const ALL = values(MetricType); + +describe('ProfilerSettings.utils', () => { + it('falls back to the platform defaults for missing parts', () => { + expect(withProfilerDefaults(undefined)).toEqual({ + metricConfiguration: [], + sampleDataConfig: { storeSampleData: true, readSampleData: true }, + }); + }); + + it('shows a row holding every metric as the single "All" choice', () => { + const values = toProfilerFormValues( + withProfilerDefaults({ + metricConfiguration: [{ dataType: DataType.Int, metrics: ALL }], + }), + 'All' + ); + + expect(values.metricConfiguration[0].metrics).toEqual([ + { id: ALL_METRICS, label: 'All' }, + ]); + }); + + it('expands "All" back to every metric on save, and keeps explicit picks as-is', () => { + const config = toProfilerConfig({ + metricConfiguration: [ + { + dataType: { id: DataType.Int, label: DataType.Int }, + metrics: [{ id: ALL_METRICS, label: 'All' }], + disabled: false, + }, + { + dataType: { id: DataType.Array, label: DataType.Array }, + metrics: [{ id: MetricType.Max, label: 'Max' }], + disabled: true, + }, + ], + storeSampleData: true, + readSampleData: false, + }); + + expect(config.metricConfiguration?.[0].metrics).toEqual(ALL); + expect(config.metricConfiguration?.[1]).toEqual({ + dataType: DataType.Array, + metrics: [MetricType.Max], + disabled: true, + }); + expect(config.sampleDataConfig).toEqual({ + storeSampleData: true, + readSampleData: false, + }); + }); + + it('omits metrics on a row with none picked, as the classic form did', () => { + const config = toProfilerConfig({ + metricConfiguration: [ + { + dataType: { id: DataType.Int, label: DataType.Int }, + metrics: [], + disabled: true, + }, + ], + storeSampleData: false, + readSampleData: false, + }); + + expect(config.metricConfiguration?.[0].metrics).toBeUndefined(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.utils.ts new file mode 100644 index 000000000000..1342ddd7a7f1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettings.utils.ts @@ -0,0 +1,128 @@ +/* + * 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 { isEmpty, startCase } from 'lodash'; +import { DEFAULT_PROFILER_CONFIG_VALUE } from '../../../../../../constants/profiler.constant'; +import { + DataType, + MetricConfigurationDefinition, + MetricType, + ProfilerConfiguration, +} from '../../../../../../generated/configuration/profilerConfiguration'; +import { SettingType } from '../../../../../../generated/settings/settings'; +import { getSettingsConfigFromConfigType } from '../../../../../../rest/settingConfigAPI'; + +/** Picking "All" stores every metric, exactly like the classic tree select's parent node. */ +export const ALL_METRICS = 'all'; + +export interface SelectItem { + id: string; + label: string; +} + +export interface MetricRowValues { + dataType: SelectItem | null; + metrics: SelectItem[]; + disabled: boolean; +} + +export interface ProfilerFormValues { + metricConfiguration: MetricRowValues[]; + storeSampleData: boolean; + readSampleData: boolean; +} + +const ALL_METRIC_TYPES = Object.values(MetricType); + +export const METRIC_ITEMS: SelectItem[] = ALL_METRIC_TYPES.map((metric) => ({ + id: metric, + label: startCase(metric), +})); + +export const DATA_TYPE_ITEMS: SelectItem[] = Object.values(DataType).map( + (type) => ({ + id: type, + label: type, + }) +); + +export const isAllMetrics = (metrics?: string[]) => + Boolean(metrics?.length) && + ALL_METRIC_TYPES.every((metric) => metrics?.includes(metric)); + +/** Missing parts fall back to the platform defaults, as the classic page does. */ +export const withProfilerDefaults = ( + config?: ProfilerConfiguration +): Required => ({ + metricConfiguration: isEmpty(config?.metricConfiguration) + ? DEFAULT_PROFILER_CONFIG_VALUE.metricConfiguration + : (config?.metricConfiguration as MetricConfigurationDefinition[]), + sampleDataConfig: + config?.sampleDataConfig ?? DEFAULT_PROFILER_CONFIG_VALUE.sampleDataConfig, +}); + +export const fetchProfilerConfig = async () => { + const { data } = await getSettingsConfigFromConfigType( + SettingType.ProfilerConfiguration + ); + + return withProfilerDefaults( + data?.config_value as ProfilerConfiguration | undefined + ); +}; + +export const toProfilerFormValues = ( + config: Required, + allLabel: string +): ProfilerFormValues => ({ + metricConfiguration: config.metricConfiguration.map((row) => ({ + dataType: row.dataType ? { id: row.dataType, label: row.dataType } : null, + metrics: isAllMetrics(row.metrics) + ? [{ id: ALL_METRICS, label: allLabel }] + : (row.metrics ?? []).map( + (metric) => + METRIC_ITEMS.find((item) => item.id === metric) ?? { + id: metric, + label: startCase(metric), + } + ), + disabled: Boolean(row.disabled), + })), + storeSampleData: Boolean(config.sampleDataConfig?.storeSampleData), + readSampleData: Boolean(config.sampleDataConfig?.readSampleData), +}); + +export const toProfilerConfig = ( + values: ProfilerFormValues +): ProfilerConfiguration => ({ + metricConfiguration: values.metricConfiguration.map((row) => { + const metricIds = row.metrics.map((item) => item.id); + + let metrics: MetricType[] | undefined = metricIds as MetricType[]; + if (metricIds.includes(ALL_METRICS)) { + metrics = ALL_METRIC_TYPES; + } else if (!metricIds.length) { + metrics = undefined; + } + + return { + dataType: row.dataType?.id as DataType, + metrics, + disabled: row.disabled, + }; + }), + sampleDataConfig: { + storeSampleData: values.storeSampleData, + readSampleData: values.readSampleData, + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettingsForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettingsForm.tsx new file mode 100644 index 000000000000..6a4e0f453bae --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ProfilerSettingsForm.tsx @@ -0,0 +1,265 @@ +/* + * 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, + Button, + ButtonUtility, + FieldTypes, + getField, + Typography, +} from '@openmetadata/ui-core-components'; +import { Plus, XClose } from '@openmetadata/ui-core-components/icons'; +import { AxiosError } from 'axios'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { SettingType } from '../../../../../../generated/settings/settings'; +import { updateSettingsConfig } from '../../../../../../rest/settingConfigAPI'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../utils/ToastUtils'; +import type { PlatformSettingsFormProps } from './PlatformSettings.types'; +import { + ALL_METRICS, + DATA_TYPE_ITEMS, + fetchProfilerConfig, + METRIC_ITEMS, + ProfilerFormValues, + toProfilerConfig, + toProfilerFormValues, +} from './ProfilerSettings.utils'; +import SettingsFormLayout, { + SettingsFormSection, + SettingsSkeleton, +} from './SettingsFormLayout'; +import { useSettingsFetch } from './useSettingsFetch'; + +const EMPTY_VALUES: ProfilerFormValues = { + metricConfiguration: [], + storeSampleData: true, + readSampleData: true, +}; + +const ProfilerSettingsForm = ({ + showHint, + onNavigate, +}: PlatformSettingsFormProps) => { + const { t } = useTranslation(); + const allLabel = t('label.all'); + // Read through a ref so a language change relabels the "All" chip without + // resetting the form and discarding edits. + const allLabelRef = useRef(allLabel); + allLabelRef.current = allLabel; + const { data: config, isLoading } = useSettingsFetch(fetchProfilerConfig); + const [isSaving, setIsSaving] = useState(false); + const form = useForm({ defaultValues: EMPTY_VALUES }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'metricConfiguration', + }); + const rows = useWatch({ control: form.control, name: 'metricConfiguration' }); + const storeSampleData = useWatch({ + control: form.control, + name: 'storeSampleData', + }); + + useEffect(() => { + if (config) { + form.reset(toProfilerFormValues(config, allLabelRef.current)); + } + }, [config, form]); + + // Storing sample data needs it read first, so enabling one enables both. + useEffect(() => { + if (storeSampleData) { + form.setValue('readSampleData', true); + } + }, [form, storeSampleData]); + + const metricItems = useMemo( + () => [{ id: ALL_METRICS, label: allLabel }, ...METRIC_ITEMS], + [allLabel] + ); + + // A data type can be configured once; types used by other rows are disabled. + const usedDataTypes = new Set( + (rows ?? []).map((row) => row?.dataType?.id).filter(Boolean) + ); + + const rowFields = (index: number): FieldProp[] => { + const current = rows?.[index]; + + return [ + { + name: `metricConfiguration.${index}.dataType`, + label: t('label.data-type'), + type: FieldTypes.SELECT, + required: true, + placeholder: t('label.select-field', { field: t('label.data-type') }), + props: { + 'data-testid': 'data-type-select', + 'aria-label': t('label.data-type'), + items: DATA_TYPE_ITEMS.map((item) => ({ + ...item, + isDisabled: + usedDataTypes.has(item.id) && current?.dataType?.id !== item.id, + })), + }, + rules: { + required: t('message.field-text-is-required', { + fieldText: t('label.data-type'), + }), + }, + }, + { + name: `metricConfiguration.${index}.metrics`, + label: t('label.metric-type'), + type: FieldTypes.MULTI_SELECT, + placeholder: t('label.select-field', { field: t('label.metric-type') }), + props: { + 'data-testid': 'metric-type-select', + 'aria-label': t('label.metric-type'), + items: metricItems, + isDisabled: Boolean(current?.disabled), + maxVisibleItems: 5, + }, + }, + { + name: `metricConfiguration.${index}.disabled`, + label: t('label.disable'), + type: FieldTypes.SWITCH, + props: { 'data-testid': 'disabled-switch' }, + }, + ]; + }; + + const sampleDataFields: FieldProp[] = [ + { + name: 'storeSampleData', + label: t('label.enable-storing-of-sample-data'), + type: FieldTypes.SWITCH, + props: { 'data-testid': 'store-sample-data-switch' }, + }, + { + name: 'readSampleData', + label: t('label.enable-reading-of-sample-data'), + type: FieldTypes.SWITCH, + props: { 'data-testid': 'read-sample-data-switch' }, + }, + ]; + + const backToView = () => + onNavigate({ + type: 'page', + page: 'profiler-configuration', + isEditing: false, + }); + + const handleSubmit = async (values: ProfilerFormValues) => { + setIsSaving(true); + try { + await updateSettingsConfig({ + config_type: SettingType.ProfilerConfiguration, + config_value: toProfilerConfig(values), + }); + showSuccessToast( + t('server.update-entity-success', { + entity: t('label.profiler-configuration'), + }) + ); + backToView(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + + + {t('message.metric-configuration-description')} + + {fields.map((row, index) => { + const [dataType, metrics, disabled] = rowFields(index); + + return ( + + {getField(dataType)} + {getField(metrics)} + {getField(disabled)} + remove(index)} + /> + + ); + })} + + + + + + + {t('message.sample-data-ingestion-config-description')} + + {sampleDataFields.map((field) => ( +
{getField(field)}
+ ))} +
+
+ ); +}; + +export default ProfilerSettingsForm; 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/TestEmailModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/TestEmailModal.tsx index ccd9b64d5c95..c0d0cd8b1da7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/TestEmailModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/TestEmailModal.tsx @@ -84,16 +84,21 @@ const TestEmailModal = ({ onClose }: TestEmailModalProps) => { } }; + const close = () => !isSending && onClose(); + return ( - !open && onClose()}> + !open && close()}> + onClose={close}> ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../../../rest/settingConfigAPI', () => ({ + updateSettingsConfig: jest.fn(), +})); + +jest.mock('../../../../../../utils/DataQuality/FormFieldDocs', () => ({ + loadFormFieldDocs: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../../../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +jest.mock('../../../../../common/BrandImage/BrandImage', () => + jest.fn(({ dataTestId, src }) => ( + + )) +); + +const mockSetApplicationConfig = jest.fn(); +const APPLICATION_CONFIG = { + customLogoConfig: { + customLogoUrlPath: 'https://cdn.example.com/logo.svg', + customMonogramUrlPath: '', + customFaviconUrlPath: '', + }, + customTheme: { + primaryColor: '#1570ef', + hoverColor: '#d1e9ff', + selectedColor: '#175cd3', + errorColor: '#d92d20', + successColor: '#039855', + warningColor: '#f79009', + infoColor: '#2e90fa', + panelBackgroundColor: '', + }, +}; + +jest.mock('../../../../../../hooks/useApplicationStore', () => ({ + useApplicationStore: () => ({ + applicationConfig: APPLICATION_CONFIG, + setApplicationConfig: mockSetApplicationConfig, + }), +})); + +const onNavigate = jest.fn(); +let headerActions: ReactNode; +const onSetHeaderActions = (actions: ReactNode) => { + headerActions = actions; +}; + +const colorInput = (name: string) => + screen.getByTestId(`${name}-color-input`) as HTMLInputElement; + +describe('Theme settings', () => { + beforeEach(() => { + jest.clearAllMocks(); + (updateSettingsConfig as jest.Mock).mockResolvedValue({ data: {} }); + }); + + it('shows the live logo URLs and colours, with "Not set" for blanks', () => { + render( + + ); + + expect(screen.getByTestId('customLogoUrlPath-value')).toHaveTextContent( + 'https://cdn.example.com/logo.svg' + ); + expect(screen.getByTestId('customMonogramUrlPath-value')).toHaveTextContent( + 'label.not-set' + ); + expect(screen.getByTestId('primaryColor-value')).toHaveTextContent( + '#1570ef' + ); + expect(screen.getByTestId('panelBackgroundColor-value')).toHaveTextContent( + 'label.not-set' + ); + }); + + it('Reset clears every logo and colour', async () => { + render( + + ); + render(<>{headerActions}); + + await act(async () => { + fireEvent.click(screen.getByTestId('reset-button')); + }); + + expect(updateSettingsConfig).toHaveBeenCalledWith({ + config_type: SettingType.CustomUIThemePreference, + config_value: EMPTY_THEME_CONFIG, + }); + expect(mockSetApplicationConfig).toHaveBeenCalled(); + }); + + it('picking a primary colour re-derives hover and selected', async () => { + render(); + + fireEvent.change(colorInput('primaryColor'), { + target: { value: '#7f56d9' }, + }); + + await waitFor(() => + expect(colorInput('hoverColor')).not.toHaveValue('#d1e9ff') + ); + + expect(colorInput('selectedColor')).not.toHaveValue('#175cd3'); + }); + + it('rejects an invalid HEX code and an invalid logo URL', async () => { + render(); + + fireEvent.change(colorInput('errorColor'), { target: { value: 'red' } }); + fireEvent.change( + screen + .getByTestId('customFaviconUrlPath') + .querySelector('input') as HTMLInputElement, + { target: { value: 'favicon.ico' } } + ); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect( + await screen.findByText('message.hex-color-validation') + ).toBeInTheDocument(); + expect( + screen.getByText('message.entity-is-not-valid-url') + ).toBeInTheDocument(); + expect(updateSettingsConfig).not.toHaveBeenCalled(); + }); + + it('saves the theme, updates the live app config and returns to the view', async () => { + render(); + + fireEvent.change(colorInput('infoColor'), { target: { value: '#123456' } }); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + const payload = (updateSettingsConfig as jest.Mock).mock.calls[0][0]; + + expect(payload.config_type).toBe(SettingType.CustomUIThemePreference); + expect(payload.config_value.customTheme.infoColor).toBe('#123456'); + expect(payload.config_value.customLogoConfig.customLogoUrlPath).toBe( + 'https://cdn.example.com/logo.svg' + ); + expect(mockSetApplicationConfig).toHaveBeenCalledWith(payload.config_value); + expect(onNavigate).toHaveBeenCalledWith({ + type: 'page', + page: 'theme', + isEditing: false, + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.tsx new file mode 100644 index 000000000000..c8e19ae76434 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.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, Button, Typography } from '@openmetadata/ui-core-components'; +import { AxiosError } from 'axios'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Settings, + SettingType, +} from '../../../../../../generated/settings/settings'; +import { useApplicationStore } from '../../../../../../hooks/useApplicationStore'; +import { updateSettingsConfig } from '../../../../../../rest/settingConfigAPI'; +import { getThemeConfig } from '../../../../../../utils/ThemeUtils'; +import { showErrorToast } from '../../../../../../utils/ToastUtils'; +import BrandImage from '../../../../../common/BrandImage/BrandImage'; +import SettingsSection, { ReadOnlyRow } from '../../components/SettingsSection'; +import type { PlatformSettingsPageProps } from './PlatformSettings.types'; +import SettingValue from './SettingValue'; +import { + EMPTY_THEME_CONFIG, + LOGO_URL_FIELDS, + THEME_COLOR_FIELDS, +} from './ThemeSettings.utils'; +import { useEditHeaderAction } from './useEditHeaderAction'; + +/** Reads the live theme from the app store, which is what the app renders with. */ +const ThemeSettings = (props: PlatformSettingsPageProps) => { + const { t } = useTranslation(); + const { applicationConfig, setApplicationConfig } = useApplicationStore(); + const [isResetting, setIsResetting] = useState(false); + + const resetAction = useMemo(() => { + const handleReset = async () => { + setIsResetting(true); + try { + await updateSettingsConfig({ + config_type: SettingType.CustomUIThemePreference, + config_value: EMPTY_THEME_CONFIG, + } as Settings); + setApplicationConfig({ + ...EMPTY_THEME_CONFIG, + customTheme: getThemeConfig(EMPTY_THEME_CONFIG.customTheme), + }); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsResetting(false); + } + }; + + return ( + + ); + }, [isResetting, setApplicationConfig, t]); + + useEditHeaderAction('theme', false, props, resetAction); + + return ( + + + {LOGO_URL_FIELDS.map(({ name, labelKey, isMonogram }) => { + const url = applicationConfig?.customLogoConfig?.[name]; + + return ( + + + + + + + ); + })} + + + + {THEME_COLOR_FIELDS.map(({ name, labelKey }) => { + const color = applicationConfig?.customTheme?.[name]; + + return ( + + + {color && ( + + )} + {color ? ( + + {color} + + ) : ( + + )} + + + ); + })} + + + ); +}; + +export default ThemeSettings; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.utils.test.ts new file mode 100644 index 000000000000..106463ed7464 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.utils.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { + EMPTY_THEME_CONFIG, + isValidUrl, + toThemeConfig, + toThemeFormValues, +} from './ThemeSettings.utils'; + +describe('ThemeSettings.utils', () => { + it('round-trips a stored theme through the form values', () => { + const stored = { + customLogoConfig: { + customLogoUrlPath: 'https://cdn.example.com/logo.svg', + customMonogramUrlPath: '', + customFaviconUrlPath: '', + }, + customTheme: { + primaryColor: '#1570ef', + hoverColor: '#d1e9ff', + selectedColor: '#175cd3', + errorColor: '', + successColor: '', + warningColor: '', + infoColor: '', + panelBackgroundColor: '', + }, + }; + + expect(toThemeConfig(toThemeFormValues(stored))).toEqual(stored); + }); + + it('saves unset values as empty strings, which is also what Reset sends', () => { + expect(EMPTY_THEME_CONFIG.customTheme.primaryColor).toBe(''); + expect(EMPTY_THEME_CONFIG.customLogoConfig.customLogoUrlPath).toBe(''); + expect(Object.keys(EMPTY_THEME_CONFIG.customTheme)).toHaveLength(8); + }); + + it('accepts absolute URLs only', () => { + expect(isValidUrl('https://cdn.example.com/logo.svg')).toBe(true); + expect(isValidUrl('logo.svg')).toBe(false); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.utils.ts new file mode 100644 index 000000000000..dd9982c287dc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettings.utils.ts @@ -0,0 +1,96 @@ +/* + * 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 { LogoConfiguration } from '../../../../../../generated/configuration/logoConfiguration'; +import { UIThemePreference } from '../../../../../../generated/configuration/uiThemePreference'; + +export type ThemeColorKey = keyof NonNullable; +export type LogoUrlKey = keyof LogoConfiguration; +export type ThemeFormValues = Record; + +/** Order and labels match the classic Theme page. */ +export const THEME_COLOR_FIELDS: { name: ThemeColorKey; labelKey: string }[] = [ + { name: 'primaryColor', labelKey: 'label.primary-color' }, + { name: 'selectedColor', labelKey: 'label.selected-color' }, + { name: 'hoverColor', labelKey: 'label.hover-color' }, + { name: 'panelBackgroundColor', labelKey: 'label.panel-background-color' }, + { name: 'errorColor', labelKey: 'label.error-color' }, + { name: 'successColor', labelKey: 'label.success-color' }, + { name: 'warningColor', labelKey: 'label.warning-color' }, + { name: 'infoColor', labelKey: 'label.info-color' }, +]; + +export const LOGO_URL_FIELDS: { + name: LogoUrlKey; + labelKey: string; + isMonogram: boolean; +}[] = [ + { name: 'customLogoUrlPath', labelKey: 'label.logo-url', isMonogram: false }, + { + name: 'customMonogramUrlPath', + labelKey: 'label.monogram-url', + isMonogram: true, + }, + { + name: 'customFaviconUrlPath', + labelKey: 'label.favicon-url', + isMonogram: true, + }, +]; + +export const toThemeFormValues = ( + config?: Pick +): ThemeFormValues => { + const values = {} as ThemeFormValues; + LOGO_URL_FIELDS.forEach(({ name }) => { + values[name] = config?.customLogoConfig?.[name] ?? ''; + }); + THEME_COLOR_FIELDS.forEach(({ name }) => { + values[name] = config?.customTheme?.[name] ?? ''; + }); + + return values; +}; + +const pickValues = ( + keys: K[], + values: ThemeFormValues +) => + Object.fromEntries(keys.map((key) => [key, values[key] ?? ''])) as Record< + K, + string + >; + +/** Unset fields are saved as '' (not omitted), matching the classic page and its Reset. */ +export const toThemeConfig = (values: ThemeFormValues) => ({ + customLogoConfig: pickValues( + LOGO_URL_FIELDS.map(({ name }) => name), + values + ), + customTheme: pickValues( + THEME_COLOR_FIELDS.map(({ name }) => name), + values + ), +}); + +export const EMPTY_THEME_CONFIG = toThemeConfig(toThemeFormValues()); + +export const isValidUrl = (value: string) => { + try { + new URL(value); + + return true; + } catch { + return false; + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettingsForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettingsForm.tsx new file mode 100644 index 000000000000..f6be93edf645 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/ThemeSettingsForm.tsx @@ -0,0 +1,203 @@ +/* + * 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, + FormField, + FormItemLabel, + getField, + HintText, +} from '@openmetadata/ui-core-components'; +import { AxiosError } from 'axios'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { HEX_COLOR_CODE_REGEX } from '../../../../../../constants/regex.constants'; +import { + Settings, + SettingType, +} from '../../../../../../generated/settings/settings'; +import { useApplicationStore } from '../../../../../../hooks/useApplicationStore'; +import { updateSettingsConfig } from '../../../../../../rest/settingConfigAPI'; +import { generatePalette } from '../../../../../../styles/colorPallet'; +import { + showErrorToast, + showSuccessToast, +} from '../../../../../../utils/ToastUtils'; +import BrandImage from '../../../../../common/BrandImage/BrandImage'; +import HexColorField from './HexColorField'; +import type { PlatformSettingsFormProps } from './PlatformSettings.types'; +import SettingsFormLayout, { SettingsFormSection } from './SettingsFormLayout'; +import { + isValidUrl, + LOGO_URL_FIELDS, + ThemeFormValues, + THEME_COLOR_FIELDS, + toThemeConfig, + toThemeFormValues, +} from './ThemeSettings.utils'; +import { useFormFieldDocs } from './useFormFieldDocs'; + +// Shades of the primary palette the classic page derives hover/selected from. +const HOVER_SHADE = 2; +const SELECTED_SHADE = 8; + +const ThemeSettingsForm = ({ + showHint, + onNavigate, +}: PlatformSettingsFormProps) => { + const { t } = useTranslation(); + const docs = useFormFieldDocs('CustomLogoConfiguration'); + const { applicationConfig, setApplicationConfig } = useApplicationStore(); + const [isSaving, setIsSaving] = useState(false); + const form = useForm({ + defaultValues: toThemeFormValues(applicationConfig), + }); + const logoUrls = useWatch({ + control: form.control, + name: LOGO_URL_FIELDS.map(({ name }) => name), + }); + + useEffect(() => { + form.reset(toThemeFormValues(applicationConfig)); + }, [applicationConfig, form]); + + const logoFields: FieldProp[] = useMemo( + () => + LOGO_URL_FIELDS.map(({ name, labelKey }) => ({ + name, + label: t(labelKey), + type: FieldTypes.TEXT, + doc: docs[name], + props: { 'data-testid': name }, + rules: { + validate: (value: string) => + !value || + isValidUrl(value) || + t('message.entity-is-not-valid-url', { entity: t(labelKey) }), + }, + })), + [docs, t] + ); + + // Picking a primary colour re-derives hover and selected, as the classic page does. + const handlePrimaryChange = (value: string) => { + if (!HEX_COLOR_CODE_REGEX.test(value)) { + return; + } + const palette = generatePalette(value); + form.setValue('hoverColor', palette[HOVER_SHADE], { shouldDirty: true }); + form.setValue('selectedColor', palette[SELECTED_SHADE], { + shouldDirty: true, + }); + }; + + const backToView = () => + onNavigate({ type: 'page', page: 'theme', isEditing: false }); + + const handleSubmit = async (values: ThemeFormValues) => { + setIsSaving(true); + try { + const configValue = toThemeConfig(values); + await updateSettingsConfig({ + config_type: SettingType.CustomUIThemePreference, + config_value: configValue, + } as Settings); + setApplicationConfig(configValue); + showSuccessToast( + t('server.update-entity-success', { entity: t('label.theme') }) + ); + backToView(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsSaving(false); + } + }; + + return ( + + + {logoFields.map((field, index) => { + const { isMonogram } = LOGO_URL_FIELDS[index]; + + return ( + +
{getField(field)}
+ +
+ ); + })} +
+ + + {THEME_COLOR_FIELDS.map(({ name, labelKey }) => ( + + !value || + HEX_COLOR_CODE_REGEX.test(value) || + t('message.hex-color-validation'), + }}> + {({ field, fieldState }) => ( + + + { + field.onChange(value); + if (name === 'primaryColor') { + handlePrimaryChange(value); + } + }} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + ))} + +
+ ); +}; + +export default ThemeSettingsForm; 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/useEditHeaderAction.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useEditHeaderAction.tsx index 474dade60642..c2f3b0280f76 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useEditHeaderAction.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useEditHeaderAction.tsx @@ -11,37 +11,45 @@ * limitations under the License. */ -import { Button } from '@openmetadata/ui-core-components'; +import { Box, Button } from '@openmetadata/ui-core-components'; import { Edit01 } from '@openmetadata/ui-core-components/icons'; -import { useEffect } from 'react'; +import { ReactNode, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import type { PlatformSettingsPageId, PlatformSettingsPageProps, } from './PlatformSettings.types'; -/** Puts the "Edit" action in the modal header for a read-only settings view. */ +/** + * Puts the "Edit" action in the modal header for a read-only settings view, + * after any page-specific `extraActions`. Memoize `extraActions`: a new node + * every render re-runs the effect, which re-renders the panel and the page. + */ export const useEditHeaderAction = ( page: PlatformSettingsPageId, isLoading: boolean, - { onNavigate, onSetHeaderActions }: PlatformSettingsPageProps + { onNavigate, onSetHeaderActions }: PlatformSettingsPageProps, + extraActions?: ReactNode ) => { const { t } = useTranslation(); useEffect(() => { onSetHeaderActions( isLoading ? undefined : ( - + + {extraActions} + + ) ); return () => onSetHeaderActions(undefined); - }, [isLoading, onNavigate, onSetHeaderActions, page, t]); + }, [extraActions, isLoading, onNavigate, onSetHeaderActions, page, t]); }; 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/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 020a7158516b..161f429fe085 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -1131,6 +1131,7 @@ "equation": "معادلة", "equivalence": "تكافؤ", "error": "خطأ", + "error-color": "لون الخطأ", "error-plural": "أخطاء", "error-plural-lowercase": "أخطاء", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "الصفحة الرئيسية", "hour": "ساعة", "hourly": "كل ساعة", + "hover-color": "لون التمرير", "how-to-fix": "كيفية الإصلاح", "http-config-source": "مصدر تكوين HTTP", "http-method": "طريقة HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "الحوادث الفردية", "inference": "استدلال", "info": "معلومات", + "info-color": "لون المعلومات", "ingest-sample-data": "استيعاب بيانات العينة", "ingestion": "الاستيعاب", "ingestion-lowercase": "الاستيعاب", @@ -2413,6 +2416,7 @@ "previous-version": "الإصدار السابق", "pricing": "التسعير", "primary": "أساسي", + "primary-color": "اللون الأساسي", "primary-key": "المفتاح الأساسي", "primary-key-plural": "المفاتيح الأساسية", "primary-shards": "الشظايا الأساسية", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "تحديد مستخدم لتصحيح صلاحياته", "select-users-or-team": "تحديد مستخدمون أو فريق", "selected": "المحدد", + "selected-color": "اللون المحدد", "selected-data-asset": "الأصل المحدد", "selected-entity": "{{entity}} محدد", "selected-lowercase": "محدد", @@ -3096,6 +3101,7 @@ "submitting": "جارٍ الإرسال", "subscription": "اشتراك", "success": "نجاح", + "success-color": "لون النجاح", "success-rate": "معدل النجاح", "successful": "ناجح", "successful-event-plural": "الأحداث الناجحة", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "يريد الوصول إلى حسابك {{username}}", "warn": "تحذير", "warning": "تحذير", + "warning-color": "لون التحذير", "warning-plural": "تحذيرات", "warning-plural-lowercase": "تحذيرات", "weak": "ضعيفة", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index ac7de9346e19..fa07cc67684c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -1131,6 +1131,7 @@ "equation": "Gleichung", "equivalence": "Äquivalenz", "error": "Fehler", + "error-color": "Fehlerfarbe", "error-plural": "Fehler", "error-plural-lowercase": "Fehler", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Startseite", "hour": "Stunde", "hourly": "Stündlich", + "hover-color": "Hover-Farbe", "how-to-fix": "So beheben Sie das Problem", "http-config-source": "HTTP-Konfigurationsquelle", "http-method": "HTTP Methode", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Einzelne Vorfälle", "inference": "Inferenz", "info": "Info", + "info-color": "Infofarbe", "ingest-sample-data": "Beispieldaten einlesen", "ingestion": "Erfassung", "ingestion-lowercase": "erfassung", @@ -2413,6 +2416,7 @@ "previous-version": "Vorherigen Version", "pricing": "Preise", "primary": "Primär", + "primary-color": "Primärfarbe", "primary-key": "Primärschlüssel", "primary-key-plural": "Primärschlüssel", "primary-shards": "Primärsplitter", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Wählen Sie einen Benutzer aus, um dessen Berechtigungen zu debuggen", "select-users-or-team": "Wählen Sie Benutzer oder ein Team aus", "selected": "Ausgewählt", + "selected-color": "Auswahlfarbe", "selected-data-asset": "Ausgewähltes Datenasset", "selected-entity": "{{entity}} ausgewählt", "selected-lowercase": "ausgewählt", @@ -3096,6 +3101,7 @@ "submitting": "Wird gesendet", "subscription": "Abonnement", "success": "Erfolg", + "success-color": "Erfolgsfarbe", "success-rate": "Erfolgsquote", "successful": "Erfolgreich", "successful-event-plural": "Erfolgreiche Ereignisse", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "möchte auf deinen Account {{username}} zugreifen", "warn": "Warnung", "warning": "Warnung", + "warning-color": "Warnfarbe", "warning-plural": "Warnungen", "warning-plural-lowercase": "Warnungen", "weak": "Schwach", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index b339d221e8f0..6a3c43328ef9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -1131,6 +1131,7 @@ "equation": "Equation", "equivalence": "Equivalence", "error": "Error", + "error-color": "Error Color", "error-plural": "Errors", "error-plural-lowercase": "errors", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Homepage", "hour": "Hour", "hourly": "Hourly", + "hover-color": "Hover Color", "how-to-fix": "How to fix", "http-config-source": "HTTP Config Source", "http-method": "HTTP Method", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Individual incidents", "inference": "Inference", "info": "Info", + "info-color": "Info Color", "ingest-sample-data": "Ingest Sample Data", "ingestion": "Ingestion", "ingestion-lowercase": "ingestion", @@ -2413,6 +2416,7 @@ "previous-version": "Previous Version", "pricing": "Pricing", "primary": "Primary", + "primary-color": "Primary Color", "primary-key": "Primary Key", "primary-key-plural": "Primary Keys", "primary-shards": "Primary Shards", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Select a user to debug their permissions", "select-users-or-team": "Select Users or a Team", "selected": "Selected", + "selected-color": "Selected Color", "selected-data-asset": "Selected Data Asset", "selected-entity": "Selected {{entity}}", "selected-lowercase": "selected", @@ -3096,6 +3101,7 @@ "submitting": "Submitting", "subscription": "Subscription", "success": "Success", + "success-color": "Success Color", "success-rate": "Success rate", "successful": "Successful", "successful-event-plural": "Successful Events", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "wants to access your {{username}} account", "warn": "Warn", "warning": "Warning", + "warning-color": "Warning Color", "warning-plural": "Warnings", "warning-plural-lowercase": "warnings", "weak": "Weak", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index c622a764bfa3..d2af5fcbef89 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -1131,6 +1131,7 @@ "equation": "Ecuación", "equivalence": "Equivalencia", "error": "Error", + "error-color": "Color de error", "error-plural": "Errores", "error-plural-lowercase": "errores", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Página de Inicio", "hour": "Hora", "hourly": "Cada hora", + "hover-color": "Color al pasar el cursor", "how-to-fix": "Cómo solucionarlo", "http-config-source": "Fuente de configuración HTTP", "http-method": "Método HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Incidentes individuales", "inference": "Inferencia", "info": "Información", + "info-color": "Color de información", "ingest-sample-data": "Ingesta de datos de muestra", "ingestion": "Ingesta", "ingestion-lowercase": "ingesta", @@ -2413,6 +2416,7 @@ "previous-version": "Versión Anterior", "pricing": "Precio", "primary": "Primario", + "primary-color": "Color principal", "primary-key": "Clave primaria", "primary-key-plural": "Claves primarias", "primary-shards": "Fragmentos primarios", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Seleccionar usuario para depurar permisos", "select-users-or-team": "Seleccionar usuarios o un equipo", "selected": "Seleccionadas", + "selected-color": "Color seleccionado", "selected-data-asset": "Activo seleccionado", "selected-entity": "{{entity}} seleccionado", "selected-lowercase": "seleccionado", @@ -3096,6 +3101,7 @@ "submitting": "Enviando", "subscription": "Subscripción", "success": "Éxito", + "success-color": "Color de éxito", "success-rate": "Tasa de éxito", "successful": "Exitoso", "successful-event-plural": "Eventos exitosos", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "quiere acceder a su cuenta {{username}}", "warn": "Advertencia", "warning": "Advertencia", + "warning-color": "Color de advertencia", "warning-plural": "Advertencias", "warning-plural-lowercase": "advertencias", "weak": "Débil", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 73c6acd5feb6..18bc689387c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -1131,6 +1131,7 @@ "equation": "Équation", "equivalence": "Équivalence", "error": "Erreur", + "error-color": "Couleur d'erreur", "error-plural": "Erreurs", "error-plural-lowercase": "erreurs", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Page d'Accueil", "hour": "Heure", "hourly": "Horaire", + "hover-color": "Couleur de survol", "how-to-fix": "Comment corriger", "http-config-source": "Source de Configuration HTTP", "http-method": "Méthode HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Incidents individuels", "inference": "Inférence", "info": "Info", + "info-color": "Couleur d'information", "ingest-sample-data": "Ingestion des Exemples de Données", "ingestion": "Ingestion", "ingestion-lowercase": "ingestion", @@ -2413,6 +2416,7 @@ "previous-version": "Version Précédente", "pricing": "Tarification", "primary": "Principal", + "primary-color": "Couleur principale", "primary-key": "Clé Primaire", "primary-key-plural": "Clés primaires", "primary-shards": "Fragments primaires", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Sélectionner un utilisateur pour déboguer ses permissions", "select-users-or-team": "Sélectionner des Utilisateurs ou une Équipe", "selected": "Sélectionnées", + "selected-color": "Couleur de sélection", "selected-data-asset": "Actif Sélectionné", "selected-entity": "{{entity}} Sélectionné(e)", "selected-lowercase": "sélectionné", @@ -3096,6 +3101,7 @@ "submitting": "Soumission en cours", "subscription": "Abonnement", "success": "Succès", + "success-color": "Couleur de succès", "success-rate": "Taux de réussite", "successful": "Réussi", "successful-event-plural": "Événements réussis", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "souhaite accéder à votre compte {{username}}", "warn": "Avertissement", "warning": "Attention", + "warning-color": "Couleur d'avertissement", "warning-plural": "Avertissements", "warning-plural-lowercase": "avertissements", "weak": "Faible", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index ecbe8b4209dd..81cf4b2d0644 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -1131,6 +1131,7 @@ "equation": "Ecuación", "equivalence": "Equivalencia", "error": "Erro", + "error-color": "Cor de erro", "error-plural": "Erros", "error-plural-lowercase": "erros", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Páxina de inicio", "hour": "Hora", "hourly": "Cada hora", + "hover-color": "Cor ao pasar o rato", "how-to-fix": "Como corrixir", "http-config-source": "Fonte de configuración HTTP", "http-method": "Método HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Incidentes individuais", "inference": "Inferencia", "info": "Información", + "info-color": "Cor de información", "ingest-sample-data": "Inxerir datos de mostra", "ingestion": "Inxestión", "ingestion-lowercase": "inxestión", @@ -2413,6 +2416,7 @@ "previous-version": "Versión Anterior", "pricing": "Prezos", "primary": "Primaria", + "primary-color": "Cor principal", "primary-key": "Chave primaria", "primary-key-plural": "Chaves Primarias", "primary-shards": "Shards primarios", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Seleccionar un usuario para depurar os seus permisos", "select-users-or-team": "Seleccionar usuarios ou un equipo", "selected": "Seleccionadas", + "selected-color": "Cor seleccionada", "selected-data-asset": "Activo seleccionado", "selected-entity": "{{entity}} seleccionado", "selected-lowercase": "seleccionado", @@ -3096,6 +3101,7 @@ "submitting": "Enviando", "subscription": "Subscrición", "success": "Éxito", + "success-color": "Cor de éxito", "success-rate": "Taxa de éxito", "successful": "Éxito", "successful-event-plural": "Eventos exitosos", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "quere acceder á túa conta de {{username}}", "warn": "Aviso", "warning": "Advertencia", + "warning-color": "Cor de aviso", "warning-plural": "Advertencias", "warning-plural-lowercase": "avisos", "weak": "Feble", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index c344eee5a899..3603a1a37664 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -1131,6 +1131,7 @@ "equation": "משוואה", "equivalence": "שקילות", "error": "שגיאה", + "error-color": "צבע שגיאה", "error-plural": "שגיאות", "error-plural-lowercase": "שגיאות", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "דף הבית", "hour": "שעה", "hourly": "שעתי", + "hover-color": "צבע ריחוף", "how-to-fix": "כיצד לתקן", "http-config-source": "מקור תצורת HTTP", "http-method": "שיטת HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "תקריות בודדות", "inference": "הסקה", "info": "מידע", + "info-color": "צבע מידע", "ingest-sample-data": "טען נתוני דוגמא (Sample)", "ingestion": "תהליך טעינה", "ingestion-lowercase": "תהליך טעינה", @@ -2413,6 +2416,7 @@ "previous-version": "גרסה קודמת", "pricing": "מחירים", "primary": "ראשי", + "primary-color": "צבע ראשי", "primary-key": "מפתח ראשי", "primary-key-plural": "מפתחות ראשיים", "primary-shards": "שברי ראשי", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "בחר משתמש לניפוי באגים של ההרשאות שלו", "select-users-or-team": "בחר משתמשים או צוות", "selected": "נבחרו", + "selected-color": "צבע נבחר", "selected-data-asset": "נכס נבחר", "selected-entity": "נבחר {{entity}}", "selected-lowercase": "נבחר", @@ -3096,6 +3101,7 @@ "submitting": "שולח", "subscription": "מינוי", "success": "הצלחה", + "success-color": "צבע הצלחה", "success-rate": "שיעור הצלחה", "successful": "מוצלח", "successful-event-plural": "אירועים מוצלחים", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "רוצה לגשת לחשבון {{username}} שלך", "warn": "אזהרה", "warning": "אזהרה", + "warning-color": "צבע אזהרה", "warning-plural": "אזהרות", "warning-plural-lowercase": "אזהרות", "weak": "חלשה", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 0da265e89a35..4d6da93eedb3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -1131,6 +1131,7 @@ "equation": "数式", "equivalence": "等価", "error": "エラー", + "error-color": "エラー色", "error-plural": "エラー", "error-plural-lowercase": "エラー", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "ホームページ", "hour": "時間", "hourly": "毎時", + "hover-color": "ホバー色", "how-to-fix": "修正方法", "http-config-source": "HTTP設定ソース", "http-method": "HTTPメソッド", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "個別のインシデント", "inference": "推論", "info": "情報", + "info-color": "情報色", "ingest-sample-data": "サンプルデータをインジェスト", "ingestion": "インジェスト", "ingestion-lowercase": "インジェスト", @@ -2413,6 +2416,7 @@ "previous-version": "前のバージョン", "pricing": "価格", "primary": "主キー", + "primary-color": "プライマリ色", "primary-key": "主キー", "primary-key-plural": "主キー", "primary-shards": "プライマリシャード", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "パーミッションをデバッグするユーザーを選択", "select-users-or-team": "ユーザーまたはチームを選択してください", "selected": "選択済み", + "selected-color": "選択色", "selected-data-asset": "選択されたデータアセット", "selected-entity": "選択された {{entity}}", "selected-lowercase": "選択済み", @@ -3096,6 +3101,7 @@ "submitting": "送信中", "subscription": "サブスクリプション", "success": "成功", + "success-color": "成功色", "success-rate": "成功率", "successful": "成功しました", "successful-event-plural": "成功イベント", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "{{username}} アカウントへのアクセスを要求しています", "warn": "警告", "warning": "警告", + "warning-color": "警告色", "warning-plural": "警告", "warning-plural-lowercase": "警告", "weak": "弱い", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index c3bff871a5d6..27ff98e1f457 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -1131,6 +1131,7 @@ "equation": "방정식", "equivalence": "동등성", "error": "오류", + "error-color": "오류 색상", "error-plural": "오류들", "error-plural-lowercase": "오류", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "홈페이지", "hour": "시간", "hourly": "매시간", + "hover-color": "호버 색상", "how-to-fix": "해결 방법", "http-config-source": "HTTP 설정 소스", "http-method": "HTTP 메서드", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "개별 사고", "inference": "추론", "info": "정보", + "info-color": "정보 색상", "ingest-sample-data": "샘플 데이터 수집", "ingestion": "수집", "ingestion-lowercase": "수집", @@ -2413,6 +2416,7 @@ "previous-version": "이전 버전", "pricing": "가격", "primary": "기본", + "primary-color": "기본 색상", "primary-key": "기본 키", "primary-key-plural": "기본 키", "primary-shards": "프라이머리 샤드", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "사용자를 선택하여 권한을 디버그하세요", "select-users-or-team": "사용자 또는 팀 선택", "selected": "선택됨", + "selected-color": "선택 색상", "selected-data-asset": "선택된 데이터 자산", "selected-entity": "선택된 {{entity}}", "selected-lowercase": "선택됨", @@ -3096,6 +3101,7 @@ "submitting": "제출 중", "subscription": "구독", "success": "성공", + "success-color": "성공 색상", "success-rate": "성공률", "successful": "성공적", "successful-event-plural": "성공한 이멘트들", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "{{username}} 계정에 접근하려고 합니다", "warn": "경고", "warning": "경고", + "warning-color": "경고 색상", "warning-plural": "경고들", "warning-plural-lowercase": "경고", "weak": "약함", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 52fff4f105c9..908b71c73f06 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -1131,6 +1131,7 @@ "equation": "समीकरण", "equivalence": "समानता", "error": "त्रुटी", + "error-color": "त्रुटी रंग", "error-plural": "त्रुटी", "error-plural-lowercase": "त्रुटी", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "मुख्यपृष्ठ", "hour": "तास", "hourly": "दर तासाला", + "hover-color": "हॉवर रंग", "how-to-fix": "कसे दुरुस्त करावे", "http-config-source": "HTTP कॉन्फिग स्रोत", "http-method": "HTTP पद्धत", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "वैयक्तिक घटना", "inference": "अनुमान", "info": "माहिती", + "info-color": "माहिती रंग", "ingest-sample-data": "नमुना डेटा अंतर्ग्रहण करा", "ingestion": "अंतर्ग्रहण", "ingestion-lowercase": "अंतर्ग्रहण", @@ -2413,6 +2416,7 @@ "previous-version": "मागील आवृत्ती", "pricing": "किंमत", "primary": "प्राथमिक", + "primary-color": "प्राथमिक रंग", "primary-key": "प्राथमिक की", "primary-key-plural": "प्राथमिक कळा", "primary-shards": "प्राथमिक शार्ड्स", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "त्यांच्या परवानग्या डीबग करण्यासाठी वापरकर्ता निवडा", "select-users-or-team": "वापरकर्ते किंवा संघ निवडा", "selected": "निवडलेले", + "selected-color": "निवडलेला रंग", "selected-data-asset": "चयनित डेटा ॲसेट", "selected-entity": "निवडलेले {{entity}}", "selected-lowercase": "निवडलेले", @@ -3096,6 +3101,7 @@ "submitting": "सबमिट करत आहे", "subscription": "सदस्यता", "success": "यश", + "success-color": "यश रंग", "success-rate": "यश दर", "successful": "यशस्वी", "successful-event-plural": "यशस्वी घटना", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "तुमच्या {{username}} खात्यात प्रवेश करू इच्छित आहे", "warn": "इशारा", "warning": "इशारा", + "warning-color": "चेतावणी रंग", "warning-plural": "चेतावण्या", "warning-plural-lowercase": "इशारे", "weak": "कमकुवत", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 4d56f9d7f017..189db2a33474 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -1131,6 +1131,7 @@ "equation": "Vergelijking", "equivalence": "Equivalentie", "error": "Fout", + "error-color": "Foutkleur", "error-plural": "Fouten", "error-plural-lowercase": "fouten", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Startpagina", "hour": "Uur", "hourly": "Elk uur", + "hover-color": "Hoverkleur", "how-to-fix": "Hoe op te lossen", "http-config-source": "HTTP Configuratiebron", "http-method": "HTTP-methode", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Afzonderlijke incidenten", "inference": "Inferentie", "info": "Info", + "info-color": "Infokleur", "ingest-sample-data": "Voorbeelddata inladen", "ingestion": "Ingestie", "ingestion-lowercase": "ingestie", @@ -2413,6 +2416,7 @@ "previous-version": "Vorige versie", "pricing": "Prijsstelling", "primary": "Primaire", + "primary-color": "Primaire kleur", "primary-key": "Primaire sleutel", "primary-key-plural": "Primaire sleutels", "primary-shards": "Primaire shards", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Selecteer een gebruiker om hun machtigingen te debuggen", "select-users-or-team": "Gebruikers of team selecteren", "selected": "Geselecteerd", + "selected-color": "Geselecteerde kleur", "selected-data-asset": "Geselecteerd Actief", "selected-entity": "Geselecteerde {{entity}}", "selected-lowercase": "geselecteerd", @@ -3096,6 +3101,7 @@ "submitting": "Bezig met verzenden", "subscription": "Abonnement", "success": "Succes", + "success-color": "Succeskleur", "success-rate": "Slagingspercentage", "successful": "Succesvol", "successful-event-plural": "Succesvolle gebeurtenissen", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "wil toegang tot je {{username}} account", "warn": "Waarschuwing", "warning": "Waarschuwing", + "warning-color": "Waarschuwingskleur", "warning-plural": "Waarschuwingen", "warning-plural-lowercase": "waarschuwingen", "weak": "Zwak", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index ff60043ffbc7..8f05ab1a9c85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -1131,6 +1131,7 @@ "equation": "معادله", "equivalence": "معادل", "error": "خطا", + "error-color": "رنگ خطا", "error-plural": "خطاها", "error-plural-lowercase": "خطاها", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "صفحه اصلی", "hour": "ساعت", "hourly": "ساعتی", + "hover-color": "رنگ هاور", "how-to-fix": "نحوه رفع", "http-config-source": "منبع پیکربندی HTTP", "http-method": "روش HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "حادثه‌های جداگانه", "inference": "استنتاج", "info": "اطلاعات", + "info-color": "رنگ اطلاعات", "ingest-sample-data": "وارد کردن داده نمونه", "ingestion": "ورود", "ingestion-lowercase": "ورود", @@ -2413,6 +2416,7 @@ "previous-version": "نسخه قبلی", "pricing": "قیمت‌گذاری", "primary": "اصلی", + "primary-color": "رنگ اصلی", "primary-key": "کلید اصلی", "primary-key-plural": "کلیدهای اصلی", "primary-shards": "شاردهای اصلی", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "کاربری را برای اشکال‌زدایی مجوزهایش انتخاب کنید", "select-users-or-team": "انتخاب کاربران یا یک تیم", "selected": "انتخاب‌شده", + "selected-color": "رنگ انتخاب‌شده", "selected-data-asset": "دارایی انتخاب شده", "selected-entity": "انتخاب شده {{entity}}", "selected-lowercase": "انتخاب شده", @@ -3096,6 +3101,7 @@ "submitting": "در حال ارسال", "subscription": "اشتراک", "success": "موفقیت", + "success-color": "رنگ موفقیت", "success-rate": "نرخ موفقیت", "successful": "موفق", "successful-event-plural": "رویدادهای موفق", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "می‌خواهد به حساب {{username}} شما دسترسی پیدا کند", "warn": "هشدار", "warning": "هشدار", + "warning-color": "رنگ هشدار", "warning-plural": "هشدارها", "warning-plural-lowercase": "هشدارها", "weak": "ضعیف", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 9089492b8dd4..949f7bfdf9b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -1131,6 +1131,7 @@ "equation": "Equação", "equivalence": "Equivalência", "error": "Erro", + "error-color": "Cor de erro", "error-plural": "Erros", "error-plural-lowercase": "erros", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Página inicial", "hour": "Hora", "hourly": "A cada hora", + "hover-color": "Cor ao passar o mouse", "how-to-fix": "Como corrigir", "http-config-source": "Fonte de Configuração HTTP", "http-method": "Método HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Incidentes individuais", "inference": "Inferência", "info": "Informação", + "info-color": "Cor de informação", "ingest-sample-data": "Ingestão de Dados de Amostra", "ingestion": "Ingestão", "ingestion-lowercase": "ingestão", @@ -2413,6 +2416,7 @@ "previous-version": "Versão anterior", "pricing": "Preços", "primary": "Primário", + "primary-color": "Cor primária", "primary-key": "Chave Primária", "primary-key-plural": "Chaves primárias", "primary-shards": "Fragmentos Primários", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Selecione um usuário para depurar suas permissões", "select-users-or-team": "Selecione usuários ou uma equipe", "selected": "Selecionadas", + "selected-color": "Cor selecionada", "selected-data-asset": "Ativo selecionado", "selected-entity": "Selecionado {{entity}}", "selected-lowercase": "selecionado", @@ -3096,6 +3101,7 @@ "submitting": "Enviando", "subscription": "Assinatura", "success": "Sucesso", + "success-color": "Cor de sucesso", "success-rate": "Taxa de sucesso", "successful": "Sucesso", "successful-event-plural": "Eventos bem-sucedidos", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "quer acessar sua conta {{username}}", "warn": "Aviso", "warning": "Aviso", + "warning-color": "Cor de aviso", "warning-plural": "Avisos", "warning-plural-lowercase": "avisos", "weak": "Fraca", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 118f2d5d5034..341d5ed735be 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -1131,6 +1131,7 @@ "equation": "Equação", "equivalence": "Equivalência", "error": "Erro", + "error-color": "Cor de erro", "error-plural": "Erros", "error-plural-lowercase": "erros", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Página inicial", "hour": "Hora", "hourly": "A cada hora", + "hover-color": "Cor ao passar o rato", "how-to-fix": "Como corrigir", "http-config-source": "Fonte de Configuração HTTP", "http-method": "Método HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Incidentes individuais", "inference": "Inferência", "info": "Informação", + "info-color": "Cor de informação", "ingest-sample-data": "Ingestão de Dados de Amostra", "ingestion": "Ingestão", "ingestion-lowercase": "ingestão", @@ -2413,6 +2416,7 @@ "previous-version": "Versão Anterior", "pricing": "Preços", "primary": "Primário", + "primary-color": "Cor primária", "primary-key": "Chave Primária", "primary-key-plural": "Chaves primárias", "primary-shards": "Shards primários", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Selecione um utilizador para depurar as suas permissões", "select-users-or-team": "Selecionar Utilizadores ou uma Equipa", "selected": "Selecionadas", + "selected-color": "Cor selecionada", "selected-data-asset": "Ativo selecionado", "selected-entity": "{{entity}} Selecionado(a)", "selected-lowercase": "selecionado", @@ -3096,6 +3101,7 @@ "submitting": "A submeter", "subscription": "Assinatura", "success": "Sucesso", + "success-color": "Cor de sucesso", "success-rate": "Taxa de sucesso", "successful": "Bem-sucedido", "successful-event-plural": "Eventos Bem-sucedidos", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "quer acessar sua conta {{username}}", "warn": "Aviso", "warning": "Aviso", + "warning-color": "Cor de aviso", "warning-plural": "Avisos", "warning-plural-lowercase": "avisos", "weak": "Fraca", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 3775d9383621..a9d8d214195b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -1131,6 +1131,7 @@ "equation": "Уравнение", "equivalence": "Эквивалентность", "error": "Ошибка", + "error-color": "Цвет ошибки", "error-plural": "Ошибки", "error-plural-lowercase": "ошибки", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Домашняя страница", "hour": "Час", "hourly": "Ежечасно", + "hover-color": "Цвет при наведении", "how-to-fix": "Как исправить", "http-config-source": "Источник конфигурации HTTP", "http-method": "HTTP-метод", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Отдельные инциденты", "inference": "Логический вывод", "info": "Информация", + "info-color": "Цвет информации", "ingest-sample-data": "Получение демонстрационных данных", "ingestion": "Извлечение", "ingestion-lowercase": "извлечение", @@ -2413,6 +2416,7 @@ "previous-version": "Предыдущая версия", "pricing": "Цены", "primary": "Основной", + "primary-color": "Основной цвет", "primary-key": "Первичный ключ", "primary-key-plural": "Первичные ключи", "primary-shards": "Первичные осколки", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Выберите пользователя для отладки его прав", "select-users-or-team": "Выберите пользователей или команду", "selected": "Выбранные", + "selected-color": "Цвет выделения", "selected-data-asset": "Выбранный объект данных", "selected-entity": "Выбран объект «{{entity}}»", "selected-lowercase": "выбрано", @@ -3096,6 +3101,7 @@ "submitting": "Отправка", "subscription": "Подписка", "success": "Успешно", + "success-color": "Цвет успеха", "success-rate": "Доля успешных", "successful": "Успешно", "successful-event-plural": "Успешные события", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "хочет получить доступ к аккаунту {{username}}", "warn": "Предупреждение", "warning": "Предупреждение", + "warning-color": "Цвет предупреждения", "warning-plural": "Предупреждения", "warning-plural-lowercase": "предупреждения", "weak": "Слабый", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json index 8e8ddf59a3d7..5d23131e546d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json @@ -1131,6 +1131,7 @@ "equation": "Ekvation", "equivalence": "Ekvivalens", "error": "Fel", + "error-color": "Felfärg", "error-plural": "Fel", "error-plural-lowercase": "fel", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Startsida", "hour": "Timme", "hourly": "Varje timme", + "hover-color": "Hovringsfärg", "how-to-fix": "Så här åtgärdar du", "http-config-source": "HTTP-konfigurationskälla", "http-method": "HTTP-metod", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Enskilda incidenter", "inference": "Inferens", "info": "Info", + "info-color": "Infofärg", "ingest-sample-data": "Mata in exempeldata", "ingestion": "Inmatning", "ingestion-lowercase": "inmatning", @@ -2413,6 +2416,7 @@ "previous-version": "Föregående version", "pricing": "Priser", "primary": "Primär", + "primary-color": "Primärfärg", "primary-key": "Primärnyckel", "primary-key-plural": "Primärnycklar", "primary-shards": "Primära shards", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "Välj en användare för att felsöka deras behörigheter", "select-users-or-team": "Välj användare eller ett team", "selected": "Valda", + "selected-color": "Vald färg", "selected-data-asset": "Vald datatillgång", "selected-entity": "Vald {{entity}}", "selected-lowercase": "vald", @@ -3096,6 +3101,7 @@ "submitting": "Skickar", "subscription": "Prenumeration", "success": "Lyckades", + "success-color": "Framgångsfärg", "success-rate": "Andel lyckade", "successful": "Lyckades", "successful-event-plural": "Lyckade händelser", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "vill komma åt ditt {{username}}-konto", "warn": "Varning", "warning": "Varning", + "warning-color": "Varningsfärg", "warning-plural": "Varningar", "warning-plural-lowercase": "varningar", "weak": "Svagt", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index f392bb060d40..9d850fad174c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -1131,6 +1131,7 @@ "equation": "สมการ", "equivalence": "ความเท่าเทียม", "error": "ข้อผิดพลาด", + "error-color": "สีข้อผิดพลาด", "error-plural": "ข้อผิดพลาด", "error-plural-lowercase": "ข้อผิดพลาด", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "หน้าแรก", "hour": "ชั่วโมง", "hourly": "รายชั่วโมง", + "hover-color": "สีเมื่อวางเมาส์", "how-to-fix": "วิธีแก้ไข", "http-config-source": "แหล่งที่มาของการตั้งค่า HTTP", "http-method": "วิธี HTTP", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "เหตุการณ์แต่ละรายการ", "inference": "การอนุมาน", "info": "ข้อมูล", + "info-color": "สีข้อมูล", "ingest-sample-data": "นำเข้าข้อมูลตัวอย่าง", "ingestion": "การนำเข้า", "ingestion-lowercase": "การนำเข้า", @@ -2413,6 +2416,7 @@ "previous-version": "เวอร์ชันก่อนหน้า", "pricing": "ราคา", "primary": "หลัก", + "primary-color": "สีหลัก", "primary-key": "คีย์หลัก", "primary-key-plural": "คีย์หลัก", "primary-shards": "ชาร์ดหลัก", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "เลือกผู้ใช้เพื่อตรวจสอบสิทธิ์ของพวกเขา", "select-users-or-team": "เลือกผู้ใช้หรือทีม", "selected": "ที่เลือก", + "selected-color": "สีที่เลือก", "selected-data-asset": "สินทรัพย์ข้อมูลที่เลือก", "selected-entity": "เลือก {{entity}} แล้ว", "selected-lowercase": "ถูกเลือก", @@ -3096,6 +3101,7 @@ "submitting": "กำลังส่ง", "subscription": "การสมัครสมาชิก", "success": "สำเร็จ", + "success-color": "สีสำเร็จ", "success-rate": "อัตราความสำเร็จ", "successful": "สำเร็จ", "successful-event-plural": "เหตุการณ์ที่สำเร็จ", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "ต้องการเข้าถึงบัญชี {{username}} ของคุณ", "warn": "เตือน", "warning": "คำเตือน", + "warning-color": "สีคำเตือน", "warning-plural": "คำเตือนหลายรายการ", "warning-plural-lowercase": "คำเตือน", "weak": "อ่อน", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 1dd28140ee19..c17509648a41 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -1131,6 +1131,7 @@ "equation": "Denklem", "equivalence": "Denklik", "error": "Hata", + "error-color": "Hata Rengi", "error-plural": "Hatalar", "error-plural-lowercase": "hatalar", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "Ana Sayfa", "hour": "Saat", "hourly": "Saatlik", + "hover-color": "Üzerine Gelme Rengi", "how-to-fix": "Nasıl düzeltilir", "http-config-source": "HTTP Yapılandırma Kaynağı", "http-method": "HTTP Metodu", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "Tekil olaylar", "inference": "Çıkarım", "info": "Bilgi", + "info-color": "Bilgi Rengi", "ingest-sample-data": "Örnek Veri Al", "ingestion": "Alım", "ingestion-lowercase": "alım", @@ -2413,6 +2416,7 @@ "previous-version": "Önceki Sürüm", "pricing": "Fiyatlandırma", "primary": "Birincil", + "primary-color": "Birincil Renk", "primary-key": "Birincil Anahtar", "primary-key-plural": "Birincil Anahtarlar", "primary-shards": "Birincil Parçalar", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "İzinlerini hata ayıklamak için bir kullanıcı seçin", "select-users-or-team": "Kullanıcı veya Ekip Seçin", "selected": "Seçilenler", + "selected-color": "Seçili Renk", "selected-data-asset": "Seçili Veri Varlığı", "selected-entity": "Seçili {{entity}}", "selected-lowercase": "seçili", @@ -3096,6 +3101,7 @@ "submitting": "Gönderiliyor", "subscription": "Abonelik", "success": "Başarılı", + "success-color": "Başarı Rengi", "success-rate": "Başarı oranı", "successful": "Başarılı", "successful-event-plural": "Başarılı Olaylar", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "{{username}} hesabınıza erişmek istiyor", "warn": "Uyarı", "warning": "Uyarı", + "warning-color": "Uyarı Rengi", "warning-plural": "Uyarılar", "warning-plural-lowercase": "uyarılar", "weak": "Zayıf", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index af17745ef78b..92c8ec670e0f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -1131,6 +1131,7 @@ "equation": "方程", "equivalence": "等价", "error": "错误", + "error-color": "错误颜色", "error-plural": "错误", "error-plural-lowercase": "错误", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "主页", "hour": "小时", "hourly": "每小时", + "hover-color": "悬停颜色", "how-to-fix": "如何修复", "http-config-source": "HTTP 配置源", "http-method": "HTTP 方法", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "单个事件", "inference": "推理", "info": "信息", + "info-color": "信息颜色", "ingest-sample-data": "提取样本数据", "ingestion": "提取", "ingestion-lowercase": "提取", @@ -2413,6 +2416,7 @@ "previous-version": "以前的版本", "pricing": "定价", "primary": "主键", + "primary-color": "主色", "primary-key": "主键", "primary-key-plural": "主键集合", "primary-shards": "主碎片", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "选择一个用户来调试其权限", "select-users-or-team": "选择用户或团队", "selected": "已选", + "selected-color": "选中颜色", "selected-data-asset": "已选择数据资产", "selected-entity": "已选择{{entity}}", "selected-lowercase": "已选择", @@ -3096,6 +3101,7 @@ "submitting": "提交中", "subscription": "订阅", "success": "成功", + "success-color": "成功颜色", "success-rate": "成功率", "successful": "成功", "successful-event-plural": "成功事件", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "希望访问您的 {{username}} 账号", "warn": "警告", "warning": "警告", + "warning-color": "警告颜色", "warning-plural": "警告", "warning-plural-lowercase": "警告", "weak": "弱", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index e93367a01f30..674f7ba13d59 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -1131,6 +1131,7 @@ "equation": "方程式", "equivalence": "等價", "error": "錯誤", + "error-color": "錯誤顏色", "error-plural": "錯誤", "error-plural-lowercase": "錯誤", "esc": "Esc", @@ -1454,6 +1455,7 @@ "homepage": "首頁", "hour": "小時", "hourly": "每小時", + "hover-color": "懸停顏色", "how-to-fix": "如何修正", "http-config-source": "HTTP 組態來源", "http-method": "HTTP 方法", @@ -1535,6 +1537,7 @@ "individual-incident-plural": "個別事件", "inference": "推論", "info": "資訊", + "info-color": "資訊顏色", "ingest-sample-data": "擷取範例資料", "ingestion": "擷取", "ingestion-lowercase": "擷取", @@ -2413,6 +2416,7 @@ "previous-version": "上一版本", "pricing": "定價", "primary": "主要", + "primary-color": "主要顏色", "primary-key": "主鍵", "primary-key-plural": "主鍵", "primary-shards": "主要分片", @@ -2861,6 +2865,7 @@ "select-user-to-debug-permissions": "選取使用者以偵錯其權限", "select-users-or-team": "選擇使用者或團隊", "selected": "已選", + "selected-color": "選取顏色", "selected-data-asset": "已選取的資料資產", "selected-entity": "已選取的 {{entity}}", "selected-lowercase": "已選取", @@ -3096,6 +3101,7 @@ "submitting": "提交中", "subscription": "訂閱", "success": "成功", + "success-color": "成功顏色", "success-rate": "成功率", "successful": "成功", "successful-event-plural": "成功的事件", @@ -3524,6 +3530,7 @@ "wants-to-access-your-account": "想要存取您的 {{username}} 帳戶", "warn": "警告", "warning": "警告", + "warning-color": "警告顏色", "warning-plural": "警告", "warning-plural-lowercase": "警告", "weak": "弱", 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}