From 70278e6d963902c2d26f9d4d344cefab78ac7a29 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 01:52:56 +0530 Subject: [PATCH 01/32] feat(ui): migrate standalone antd Select and Input to core components Moves every antd Select/Input that is not bound to an antd Form onto @openmetadata/ui-core-components (Select, Select.ComboBox, Autocomplete, Input, PasswordInput), including the legacy RJSF widgets and SSO array templates. Form-bound controls stay with the Form migration. - Searchable/async selects use Autocomplete (chips, server search) or Select.ComboBox; single-value clear keeps a "None"/"All" item or clears on empty input. - Deletes the unused CustomUnitSelect and antd-only LESS overrides. - NavBar Cmd+K now focuses the global search (its ref was never attached). - Updates unit tests and Playwright specs for the core DOM, prunes the ESLint suppressions and lowers the pinned antd/positional counts. --- .../e2e/Features/ExploreQuickFilters.spec.ts | 17 +- .../e2e/Features/OnlineUsers.spec.ts | 27 +- .../e2e/Features/SSOConfiguration.spec.ts | 53 ++- .../SampleDataTableOperations.spec.ts | 16 +- .../playwright/e2e/Flow/MetricSearch.spec.ts | 19 +- .../ui/playwright/e2e/Flow/Navbar.spec.ts | 6 +- .../playwright/e2e/Flow/PersonaFlow.spec.ts | 82 ++-- .../e2e/Pages/CustomProperties.spec.ts | 11 +- .../e2e/Pages/Lineage/PlatformLineage.spec.ts | 21 +- .../e2e/Pages/SearchSettings.spec.ts | 24 +- .../playwright/e2e/Pages/UserDetails.spec.ts | 17 +- .../ui/playwright/e2e/Pages/Users.spec.ts | 55 ++- .../playwright/utils/customizeLandingPage.ts | 12 +- .../ui/playwright/utils/entityPanel.ts | 62 +-- .../resources/ui/playwright/utils/lineage.ts | 6 +- .../resources/ui/playwright/utils/navbar.ts | 38 +- .../SampleDataTable.component.tsx | 20 +- .../SchemaTable/SchemaTable.component.tsx | 28 +- .../AppPipelineModel/AddPipeLineModal.tsx | 7 +- .../EntityLineage/CustomNodeV1.test.tsx | 15 + .../EntitySuggestionOption.component.tsx | 18 +- .../EntitySuggestionOption.interface.ts | 1 - .../entity-suggestion-option.less | 12 - .../LineageSearchSelect.test.tsx | 93 +---- .../LineageSearchSelect.tsx | 267 ++++--------- .../NodeChildren/NodeChildren.component.tsx | 42 +-- .../NodeSuggestions.component.tsx | 145 +++---- .../EntityLineage/entity-lineage.style.less | 30 -- .../Entity/EntityLineage/node-suggestion.less | 34 -- .../GlobalSearchBar/GlobalSearchBar.tsx | 66 ++-- .../GlobalSearchBar/global-search-bar.less | 6 - .../PersonaSelectableList.component.test.tsx | 102 ++--- .../PersonaSelectableList.component.tsx | 158 +++----- .../ui/src/components/NavBar/NavBar.tsx | 5 +- .../EntitySearchSettings.tsx | 67 ++-- .../FieldConfiguration/FieldConfiguration.tsx | 22 +- .../GlobalSettingsItem/GlobalSettingsItem.tsx | 22 +- .../SearchPreview/SearchPreview.tsx | 17 +- .../SearchPreview/search-preview.less | 26 -- .../Settings/Bot/BotDetails/AuthMechanism.tsx | 7 +- .../Bot/BotDetails/BotDetails.component.tsx | 17 +- .../TeamsHeadingLabel.component.tsx | 16 +- .../UserProfileRoles.component.tsx | 157 +++----- ...SsoConfigurationFormArrayFieldTemplate.tsx | 85 ++--- .../SsoRolesSelectField.tsx | 49 ++- ...nfiguration-form-array-field-template.less | 150 -------- .../CustomUnitSelect.test.tsx | 353 ------------------ .../CustomUnitSelect/CustomUnitSelect.tsx | 207 ---------- .../WorkflowArrayFieldTemplate.test.tsx | 32 +- .../WorkflowArrayFieldTemplate.tsx | 71 ++-- .../workflow-array-field-template.less | 70 ---- .../LdapRoleMappingWidget.tsx | 86 +++-- .../ldap-role-mapping-widget.less | 20 - .../JsonSchemaWidgets/PasswordWidget.test.tsx | 48 +-- .../JsonSchemaWidgets/PasswordWidget.tsx | 18 +- .../JsonSchemaWidgets/SelectWidget.test.tsx | 84 ++--- .../JsonSchemaWidgets/SelectWidget.tsx | 164 +++++--- .../common/RolesCard/RolesCard.component.tsx | 61 +-- .../common/RolesCard/RolesCard.interfaces.ts | 1 - .../ui/src/constants/Lineage.constants.ts | 2 - .../pages/OnlineUsersPage/OnlineUsersPage.tsx | 33 +- .../PlatformLineage/PlatformLineage.test.tsx | 35 +- .../pages/PlatformLineage/PlatformLineage.tsx | 137 ++++--- .../AddAttributeModal/AddAttributeModal.tsx | 15 +- .../src/main/resources/ui/src/styles/app.less | 8 - .../ui/src/utils/SearchSettingsUtils.ts | 4 +- 66 files changed, 1177 insertions(+), 2422 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/entity-suggestion-option.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/node-suggestion.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/workflow-array-field-template.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/LdapRoleMappingWidget/ldap-role-mapping-widget.less diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts index 07a326d322894..34fcdc9ce409e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts @@ -739,22 +739,7 @@ test.describe('Metric search result highlight', () => { state: 'visible', }); await page.getByTestId('global-search-selector').click(); - await page.getByTestId('global-search-select-dropdown').waitFor({ - state: 'visible', - }); - - await page - .getByTestId('global-search-select-dropdown') - .locator('.rc-virtual-list-holder') - .evaluate((el) => { - el.scrollTop = el.scrollHeight; - }); - - const metricOption = page.getByTestId( - 'global-search-select-option-Metric' - ); - await metricOption.waitFor({ state: 'visible' }); - await metricOption.click(); + await page.getByTestId('global-search-select-option-Metric').click(); const searchResponse = page.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OnlineUsers.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OnlineUsers.spec.ts index 560b3e9dced88..f7b1fb7d26d4f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OnlineUsers.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OnlineUsers.spec.ts @@ -95,7 +95,9 @@ test.describe('Online Users Feature', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { // Current selection should show "Last 24 hours" by default const timeFilterDropdown = page.getByTestId('time-window-select'); - await expect(timeFilterDropdown).toContainText('Last 24 hours'); + await expect(timeFilterDropdown.getByRole('button')).toHaveText( + 'Last 24 hours' + ); }); test('Should update user activity time when user navigates', async ({ @@ -151,22 +153,19 @@ test.describe('Online Users Feature', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { const timeFilterDropdown = page.getByTestId('time-window-select'); // Verify default filter is "Last 24 hours" - await expect(timeFilterDropdown).toContainText('Last 24 hours'); + await expect(timeFilterDropdown.getByRole('button')).toHaveText( + 'Last 24 hours' + ); // Click on the time filter dropdown await timeFilterDropdown.click(); - // Wait for dropdown to fully render - await expect(page.locator('.ant-select-dropdown:visible')).toBeVisible(); - await expect( - page.locator('.ant-select-dropdown:visible [title="Last 5 minutes"]') + page.getByRole('option', { name: 'Last 5 minutes' }) ).toBeVisible(); + await expect(page.getByRole('option', { name: 'Last hour' })).toBeVisible(); await expect( - page.locator('.ant-select-dropdown:visible [title="Last hour"]') - ).toBeVisible(); - await expect( - page.locator('.ant-select-dropdown:visible [title="Last 24 hours"]') + page.getByRole('option', { name: 'Last 24 hours' }) ).toBeVisible(); const onlineRes = page.waitForResponse( @@ -174,15 +173,15 @@ test.describe('Online Users Feature', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { ); // Select a different time window - await page - .locator('.ant-select-dropdown:visible [title="Last hour"]') - .click(); + await page.getByRole('option', { name: 'Last hour' }).click(); const onlineResponse = await onlineRes; expect(onlineResponse.status()).toBe(200); // Verify the filter has changed - await expect(timeFilterDropdown).toContainText('Last hour'); + await expect(timeFilterDropdown.getByRole('button')).toHaveText( + 'Last hour' + ); // The table should exist (with or without data) await expect(page.getByTestId('online-users-table')).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts index 1dcd4a3fa05aa..592569ed55394 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts @@ -738,17 +738,17 @@ test.describe('SSO Configuration Tests', () => { // Open the roles dropdown — options are loaded from the API const roleInput = adminsMapping.rolesSelect.getByRole('combobox'); - await roleInput.focus(); - await roleInput.press('ArrowDown'); - const roleOption = page - .locator('.ant-select-dropdown:visible') - .getByTitle('Data Consumer', { exact: true }); + await roleInput.click(); + const roleOption = page.getByRole('option', { + name: 'Data Consumer', + exact: true, + }); await expect(roleOption).toBeVisible(); await roleOption.click(); await roleInput.press('Escape'); await expect( - adminsMapping.rolesSelect.getByTitle('Data Consumer', { exact: true }) - ).toBeVisible(); + adminsMapping.rolesSelect.getByTestId('autocomplete-selected-item') + ).toHaveText('Data Consumer'); // Add a second mapping with a duplicate DN — both rows show an error const duplicateMapping = await addMapping(); @@ -792,49 +792,48 @@ test.describe('SSO Configuration Tests', () => { const field = page.getByTestId( 'sso-configuration-form-array-field-template-authReassignRoles' ); - const dropdown = page.locator('.ant-select-dropdown:visible'); + const dropdown = page.getByRole('listbox'); + const selectedRoles = field.getByTestId('autocomplete-selected-item'); // Field renders as a combobox (not a plain tags input) await expect(field).toBeVisible(); await expect(field.getByRole('combobox')).toBeVisible(); // Opening the dropdown shows API-fetched role options - await field.click(); + await field.getByRole('combobox').click(); await expect(dropdown).toBeVisible(); - await expect(dropdown.locator('.ant-select-item-option')).not.toHaveCount( - 0 - ); + await expect(dropdown.getByRole('option')).not.toHaveCount(0); // Select a named role — it appears as a selection tag - await dropdown.getByTitle('Data Consumer', { exact: true }).click(); - await expect(field.locator('.ant-select-selection-item')).toHaveCount(1); + await dropdown + .getByRole('option', { name: 'Data Consumer', exact: true }) + .click(); + await expect(selectedRoles).toHaveCount(1); // Remove the selected role via its remove button - await field.locator('.ant-select-selection-item-remove').click(); - await expect(field.locator('.ant-select-selection-item')).toHaveCount(0); + await selectedRoles.getByRole('button').click(); + await expect(selectedRoles).toHaveCount(0); // Typing filters the visible options - await field.click(); + await field.getByRole('combobox').click(); const dataRoleSearchResponse = page.waitForResponse( '/api/v1/roles/search?*' ); - await field.locator('input').fill('Data'); + await field.getByRole('combobox').fill('Data'); await dataRoleSearchResponse; - await expect( - dropdown.locator( - '.ant-select-item-option:not(.ant-select-item-option-disabled)' - ) - ).not.toHaveCount(0, { timeout: 15000 }); + await expect(dropdown.getByRole('option')).not.toHaveCount(0, { + timeout: 15000, + }); // Pressing Enter on a non-existent value does not create an arbitrary tag - await field.locator('input').clear(); + await field.getByRole('combobox').clear(); const missingRoleSearchResponse = page.waitForResponse( '/api/v1/roles/search?*' ); - await field.locator('input').fill('NonExistentRoleXYZ123'); + await field.getByRole('combobox').fill('NonExistentRoleXYZ123'); await missingRoleSearchResponse; - await field.locator('input').press('Enter'); - await expect(field.locator('.ant-select-selection-item')).toHaveCount(0); + await field.getByRole('combobox').press('Enter'); + await expect(selectedRoles).toHaveCount(0); }); test('should not display role mapping widget for non-LDAP providers', async ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SampleDataTableOperations.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SampleDataTableOperations.spec.ts index ef09cf1719433..dc8ce7d386b98 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SampleDataTableOperations.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SampleDataTableOperations.spec.ts @@ -142,7 +142,9 @@ test.describe('Sample Data Tab - Download and Delete Functionality', () => { await expect(page.getByTestId('row-limit-option-10')).toBeVisible(); await page.getByTestId('row-limit-option-10').click(); - await expect(page.getByTestId('row-limit-select')).toContainText('10'); + await expect( + page.getByTestId('row-limit-select').getByRole('button') + ).toHaveText('10'); }); await test.step('Change row limit to 1000', async () => { @@ -151,7 +153,9 @@ test.describe('Sample Data Tab - Download and Delete Functionality', () => { await expect(page.getByTestId('row-limit-option-1000')).toBeVisible(); await page.getByTestId('row-limit-option-1000').click(); - await expect(page.getByTestId('row-limit-select')).toContainText('1000'); + await expect( + page.getByTestId('row-limit-select').getByRole('button') + ).toHaveText('1000'); }); await test.step('Reset row limit back to 100', async () => { @@ -160,7 +164,9 @@ test.describe('Sample Data Tab - Download and Delete Functionality', () => { await expect(page.getByTestId('row-limit-option-100')).toBeVisible(); await page.getByTestId('row-limit-option-100').click(); - await expect(page.getByTestId('row-limit-select')).toContainText('100'); + await expect( + page.getByTestId('row-limit-select').getByRole('button') + ).toHaveText('100'); }); }); @@ -400,7 +406,9 @@ test.describe('Sample Data Tab - Download and Delete Functionality', () => { await expect(page.getByTestId('row-limit-option-10')).toBeVisible(); await page.getByTestId('row-limit-option-10').click(); - await expect(page.getByTestId('row-limit-select')).toContainText('10'); + await expect( + page.getByTestId('row-limit-select').getByRole('button') + ).toHaveText('10'); }); await test.step('Switch to Schema tab and back to Sample Data', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricSearch.spec.ts index 0abb682b9ca20..ba3ef082a3c2d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricSearch.spec.ts @@ -77,24 +77,7 @@ test.describe( await test.step('Select Metric search index and search', async () => { await page.getByTestId('global-search-selector').click(); - // Wait for dropdown to be visible - await page.getByTestId('global-search-select-dropdown').waitFor({ - state: 'visible', - }); - - // Scroll within the dropdown to find Metric option - const dropdownMenu = page.locator( - '.global-search-select-menu .rc-virtual-list-holder' - ); - await dropdownMenu.evaluate((el) => { - el.scrollTop = el.scrollHeight; - }); - - const metricOption = page.getByTestId( - 'global-search-select-option-Metric' - ); - await metricOption.waitFor({ state: 'visible' }); - await metricOption.click(); + await page.getByTestId('global-search-select-option-Metric').click(); const searchQuery = `AcceleratedConnection WBA Ethernet ${metricSuffix}`; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Navbar.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Navbar.spec.ts index 2021a6758cd5e..9455a46a5b4bd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Navbar.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Navbar.spec.ts @@ -36,9 +36,9 @@ for (const searchItem of navbarSearchItems) { label ); - await expect(page.getByTestId('global-search-selector')).toContainText( - label - ); + await expect( + page.getByTestId('global-search-selector').getByRole('button') + ).toHaveText(label); const searchRes = page.waitForResponse( `/api/v1/search/query?q=*&index=${searchIndex}**` diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts index 4c96ad8b9ab8e..2786753931325 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts @@ -500,25 +500,25 @@ test.describe.serial('Team persona setting flow', () => { // Wait for dropdown to open and options to load await adminPage.getByTestId('default-persona-select-list').waitFor(); - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); + await adminPage.getByRole('listbox').waitFor({ state: 'visible' }); const personaResponse = await personasLoadResponse; expect(personaResponse.status()).toBe(200); - const option = adminPage.locator( - `.ant-select-dropdown:visible [title="${teamPersona.responseData.displayName}"]` - ); + const option = adminPage.getByRole('option', { + name: teamPersona.responseData.displayName, + }); await expect(option).toBeVisible(); await option.click(); + const selectedPersona = adminPage + .getByTestId('default-persona-select-list') + .getByTestId('autocomplete-selected-item'); + // Verify the selected option is correct - await expect( - adminPage.locator( - `span.ant-select-selection-item[title="${teamPersona.responseData.displayName}"]` - ) - ).toBeVisible(); + await expect(selectedPersona).toContainText( + teamPersona.responseData.displayName + ); const teamPatchResponse = adminPage.waitForResponse('/api/v1/teams/*'); @@ -529,9 +529,7 @@ test.describe.serial('Team persona setting flow', () => { await teamPatchResponse; // Ensure dropdown closed - await expect( - adminPage.locator('.ant-select-dropdown:visible') - ).not.toBeVisible(); + await expect(adminPage.getByRole('listbox')).not.toBeVisible(); // Verify persona renders in team UI await expect(adminPage.getByTestId('team-persona')).toContainText( @@ -544,12 +542,12 @@ test.describe.serial('Team persona setting flow', () => { ); await adminPage.getByTestId('default-edit-user-persona').click(); await adminPage.getByTestId('default-persona-select-list').waitFor(); - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); const personaResponse2 = await personasLoadResponse2; expect(personaResponse2.status()).toBe(200); + // Single select: the current default is removed before picking another. + await selectedPersona.getByRole('button').click(); + // Typing filters via the server-side search endpoint, not client-side. const personaSearchResponse = adminPage.waitForResponse( (response) => @@ -557,29 +555,24 @@ test.describe.serial('Team persona setting flow', () => { response.request().method() === 'GET' ); await adminPage - .locator('[data-testid="default-persona-select-list"] input') + .getByTestId('default-persona-select-list') + .getByRole('combobox') .fill(teamPersona2.responseData.displayName); const searchResponse = await personaSearchResponse; expect(searchResponse.status()).toBe(200); // Click the new persona (teamPersona2) - const userPersonaOption = adminPage.locator( - `.ant-select-dropdown:visible [title="${teamPersona2.responseData.displayName}"]` - ); + const userPersonaOption = adminPage.getByRole('option', { + name: teamPersona2.responseData.displayName, + }); await expect(userPersonaOption).toBeVisible(); await userPersonaOption.click(); - // Verify the new option replaces the old option in the dropdown selection display - await expect( - adminPage.locator( - `span.ant-select-selection-item[title="${teamPersona2.responseData.displayName}"]` - ) - ).toBeVisible(); - await expect( - adminPage.locator( - `span.ant-select-selection-item[title="${teamPersona.responseData.displayName}"]` - ) - ).not.toBeVisible(); + // Verify the new option replaces the old option in the selection display + await expect(selectedPersona).toHaveCount(1); + await expect(selectedPersona).toContainText( + teamPersona2.responseData.displayName + ); // Save it and re-verify const teamPatchSwitchResponse = @@ -598,13 +591,15 @@ test.describe.serial('Team persona setting flow', () => { response.url().includes('/api/v1/personas') ); await adminPage.getByTestId('default-edit-user-persona').click(); - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); await personasLoadResponse3; - const revertOption = adminPage.locator( - `.ant-select-dropdown:visible [title="${teamPersona.responseData.displayName}"]` - ); + await selectedPersona.getByRole('button').click(); + await adminPage + .getByTestId('default-persona-select-list') + .getByRole('combobox') + .click(); + const revertOption = adminPage.getByRole('option', { + name: teamPersona.responseData.displayName, + }); await expect(revertOption).toBeVisible(); await revertOption.click(); const teamPatchRevertResponse = @@ -692,15 +687,10 @@ test.describe.serial('Team persona setting flow', () => { await adminPage.getByTestId('default-persona-select-list').waitFor(); - // Hover over the select to reveal the clear button, then click it await adminPage - .locator('[data-testid="default-persona-select-list"]') - .hover(); - - await adminPage - .locator( - '[data-testid="default-persona-select-list"] .ant-select-clear' - ) + .getByTestId('default-persona-select-list') + .getByTestId('autocomplete-selected-item') + .getByRole('button') .click(); const defaultPersonaChangeResponse = diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts index 1c55339bac20e..34de5ebbe41a7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts @@ -3377,12 +3377,7 @@ pickEntityMatrix( const matchTypeSelect = page.getByTestId('match-type-select'); await matchTypeSelect.click(); - await page - .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') - .waitFor({ state: 'visible' }); - await page - .locator('.ant-select-item-option[title="Standard Match"]') - .click(); + await page.getByRole('option', { name: 'Standard Match' }).click(); const searchSettingsSaveResponse = page.waitForResponse( '/api/v1/system/settings' @@ -3522,9 +3517,7 @@ pickEntityMatrix( const matchTypeSelect = page.getByTestId('match-type-select'); await matchTypeSelect.click(); - await page - .locator('.ant-select-item-option[title="Phrase Match"]') - .click(); + await page.getByRole('option', { name: 'Phrase Match' }).click(); const searchSettingsSaveResponse = page.waitForResponse( '/api/v1/system/settings' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts index ac9bfbcacc380..546568452f47c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts @@ -21,7 +21,6 @@ import { getDefaultAdminAPIContext, redirectToHomePage, uuid, - waitForAntdPopupToSettle, } from '../../../utils/common'; import { waitForAllLoadersToDisappear } from '../../../utils/entity'; import { @@ -99,14 +98,13 @@ test.describe('Entity Lineage tab', () => { req.url().includes('/api/v1/search/query') && req.url().includes('deleted=false') ); - await page.fill( - '[data-testid="search-entity-select"] .ant-select-selection-search-input', - table.entity.name - ); + await page + .getByTestId('search-entity-select') + .getByRole('combobox') + .fill(table.entity.name); await searchRequest; - await page.locator('.ant-select-dropdown').waitFor(); - await waitForAntdPopupToSettle(page); + await page.getByRole('listbox').waitFor(); const nodeFqn = get(table, 'entityResponseData.fullyQualifiedName', ''); const dbFqn = get( @@ -142,12 +140,11 @@ test.describe('Entity Lineage tab', () => { await page.getByTestId('search-entity-select').waitFor(); await page.click('[data-testid="search-entity-select"]'); - await page.fill( - '[data-testid="search-entity-select"] .ant-select-selection-search-input', - db - ); + await page + .getByTestId('search-entity-select') + .getByRole('combobox') + .fill(db); await page.getByTestId(`node-suggestion-${dbFqn}`).waitFor(); - await waitForAntdPopupToSettle(page); const databaseSceneResponse = waitForResponseWithStatus( page, (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index a12b4d5378c2d..45406b5538e1c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -265,34 +265,25 @@ test.describe('Search Settings', () => { // Match Type const matchTypeSelect = page.getByTestId('match-type-select'); await matchTypeSelect.click(); - await page - .locator('.ant-select-dropdown:visible') - .getByTitle('Fuzzy Match') - .click(); + await page.getByRole('option', { name: 'Fuzzy Match' }).click(); // Score Mode const scoreModeSelect = page.getByTestId('score-mode-select'); await scoreModeSelect.click(); - await page - .locator('.ant-select-dropdown:visible') - .getByTitle('Max') - .click(); + await page.getByRole('option', { name: 'Max' }).click(); // Boost Mode const boostModeSelect = page.getByTestId('boost-mode-select'); await boostModeSelect.click(); - await page - .locator('.ant-select-dropdown:visible') - .getByTitle('Replace') - .click(); + await page.getByRole('option', { name: 'Replace' }).click(); // Save await page.getByTestId('save-btn').click(); await toastNotification(page, /Search Settings updated successfully/); - await expect(scoreModeSelect).toHaveText('Max'); - await expect(boostModeSelect).toHaveText('Replace'); + await expect(scoreModeSelect.getByRole('button')).toHaveText('Max'); + await expect(boostModeSelect.getByRole('button')).toHaveText('Replace'); }); test('Highlight toggle is offered for analyzed fields', async ({ @@ -817,10 +808,7 @@ test.describe('Search Settings', () => { const matchTypeSelect = page.getByTestId('match-type-select'); await matchTypeSelect.click(); - await page - .locator('.ant-select-dropdown:visible') - .getByTitle('Exact Match') - .click(); + await page.getByRole('option', { name: 'Exact Match' }).click(); const saveSettings = page.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts index 235552753f1f9..8844abcd8296a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts @@ -466,32 +466,23 @@ test.describe('User with different Roles', () => { adminPage.getByTestId('profile-edit-roles-select') ).toBeVisible(); - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); + await adminPage.getByRole('listbox').waitFor({ state: 'visible' }); const applicationRolesResponse = adminPage.waitForResponse( '/api/v1/roles/search?*' ); await adminPage .getByTestId('profile-edit-roles-select') - .locator('input') + .getByRole('combobox') .fill('Application'); await applicationRolesResponse; await adminPage - .locator('.ant-select-item-option-content') - .getByText('Application bot role', { exact: true }) - .waitFor({ state: 'visible' }); - await adminPage - .locator('.ant-select-item-option-content') - .getByText('Application bot role', { exact: true }) + .getByRole('option', { name: 'Application bot role', exact: true }) .click(); await adminPage.keyboard.press('Escape'); - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'hidden', - }); + await adminPage.getByRole('listbox').waitFor({ state: 'hidden' }); const saveTeamsResponse = adminPage.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts index 002d45f4bc8ad..d07baef919e04 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts @@ -945,12 +945,21 @@ test.describe('User Profile Dropdown Persona Interactions', () => { .click(); await adminPage.getByTestId('default-persona-select-list').waitFor(); - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); + await adminPage + .getByTestId('default-persona-select-list') + .getByTestId('autocomplete-selected-item') + .getByRole('button') + .click(); + await adminPage + .getByTestId('default-persona-select-list') + .getByRole('combobox') + .click(); + await adminPage.getByRole('listbox').waitFor({ state: 'visible' }); // Select the second persona as default - await adminPage.getByTitle(persona2.data.displayName).click(); + await adminPage + .getByRole('option', { name: persona2.data.displayName }) + .click(); const defaultPersonaChangeResponse = adminPage.waitForResponse('/api/v1/users/*'); @@ -1011,7 +1020,9 @@ test.describe('User Profile Dropdown Persona Interactions', () => { .click(); await adminPage.getByTestId('default-persona-select-list').waitFor(); await adminPage - .locator('[data-testid="default-persona-select-list"] .ant-select-clear') + .getByTestId('default-persona-select-list') + .getByTestId('autocomplete-selected-item') + .getByRole('button') .click(); const removePersonaResponse = adminPage.waitForResponse('/api/v1/users/*'); @@ -1149,9 +1160,18 @@ test.describe('User Profile Persona Interactions', () => { await adminPage.getByTestId('persona-select-list').waitFor(); // Clear all selected personas - await adminPage - .locator('[data-testid="persona-select-list"] .ant-select-clear') - .click(); + const selectedPersonaChips = adminPage + .getByTestId('persona-select-list') + .getByTestId('autocomplete-selected-item'); + // Only the first few chips render at once, so re-read after each removal. + let [chipName] = await selectedPersonaChips.allTextContents(); + while (chipName) { + await selectedPersonaChips + .filter({ hasText: chipName }) + .getByRole('button') + .click(); + [chipName] = await selectedPersonaChips.allTextContents(); + } const updateUserPromise = adminPage.waitForResponse( (response) => @@ -1197,16 +1217,11 @@ test.describe('User Profile Persona Interactions', () => { await adminPage.getByTestId('default-persona-select-list').waitFor(); // Wait for dropdown to open and options to load - await adminPage.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); + await adminPage.getByRole('listbox').waitFor({ state: 'visible' }); - // Select specific persona for default - try test ID first, fallback to role selector - const defaultPersonaOptionTestId = adminPage.getByTitle( - persona1.responseData.displayName - ); - - await defaultPersonaOptionTestId.click(); + await adminPage + .getByRole('option', { name: persona1.responseData.displayName }) + .click(); // Save the changes const savePersonaResponse = adminPage.waitForResponse('/api/v1/users/*'); @@ -1275,9 +1290,9 @@ test.describe('User Profile Persona Interactions', () => { // Clear the selected default persona await adminPage - .locator( - '[data-testid="default-persona-select-list"] .ant-select-clear' - ) + .getByTestId('default-persona-select-list') + .getByTestId('autocomplete-selected-item') + .getByRole('button') .click(); const defaultPersonaChangeResponse = diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 597c85652c2b2..ad74d53f20166 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -328,8 +328,16 @@ export const setUserDefaultPersona = async ( const setDefaultPersona = page.waitForResponse('/api/v1/users/*'); - // Click on the persona option by text within the dropdown - await page.click(`.ant-select-dropdown:visible [title="${personaName}"]`); + const personaSelect = page.getByTestId('default-persona-select-list'); + const currentDefault = personaSelect.getByTestId( + 'autocomplete-selected-item' + ); + // Single select: an existing default is removed before picking another. + if ((await currentDefault.count()) > 0) { + await currentDefault.getByRole('button').click(); + } + await personaSelect.getByRole('combobox').click(); + await page.getByRole('option', { name: personaName }).click(); await page .locator('[data-testid="user-profile-default-persona-edit-save"]') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts index f96cd695db5f2..c7b22cda1f25c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts @@ -41,56 +41,18 @@ export const getEntityFqn = ( ).entityResponseData?.fullyQualifiedName; }; -const findOptionByScrolling = async (page: Page, endpoint: string) => { - const maxTries = 12; // Bound the scan; the list is ~25 options over ~3 viewports. - const filterName = ENDPOINT_TO_FILTER_MAP[endpoint]; - const dropdown = page - .getByTestId('global-search-select-dropdown') - .locator('.rc-virtual-list-holder'); - const option = page.getByTestId(`global-search-select-option-${filterName}`); - - // The filter list is virtualised, so only the ~11 rows around the current - // offset exist in the DOM: every option below "Container" — Stored Procedure, - // Data Product, API Endpoint, API Collection, Metric and the rest — is absent - // until scrolled into range. - // - // waitFor() rather than isVisible(): isVisible() resolves immediately and - // never retries, so probing it straight after scrollBy() races the re-render - // of the virtual window. That is why every entity type past the initial - // window was flaky under CI load while those inside it never were. When the - // option is already rendered this resolves instantly, so the timeout is only - // ever paid once per scroll step. - for (let tries = 0; tries < maxTries; tries++) { - const appeared = await option - .waitFor({ state: 'visible', timeout: 1_000 }) - .then(() => true) - .catch(() => false); - - if (appeared) { - await option.click(); - - return true; - } - - // Advance a whole viewport so the scan covers the list in a few steps, and - // detect the end from the clamped scrollTop rather than a fixed try count. - const movedBy = await dropdown.evaluate((element) => { - const before = element.scrollTop; - element.scrollBy(0, element.clientHeight); - - return element.scrollTop - before; - }); +const selectSearchIndexOption = async (page: Page, endpoint: string) => { + const option = page.getByTestId( + `global-search-select-option-${ENDPOINT_TO_FILTER_MAP[endpoint]}` + ); - if (movedBy === 0) { - break; // Already at the bottom: the option is not in this list. - } + // The core Select renders every option, so a missing one is genuinely absent. + if ((await option.count()) === 0) { + return false; } + await option.click(); - await dropdown.evaluate((element) => { - element.scrollTop = 0; - }); - - return false; + return true; }; export const openEntitySummaryPanel = async ({ @@ -136,10 +98,8 @@ export const openEntitySummaryPanel = async ({ state: 'visible', }); await page.getByTestId('global-search-selector').click(); - await page.getByTestId('global-search-select-dropdown').waitFor({ - state: 'visible', - }); - if (!(await findOptionByScrolling(page, endpoint))) { + await page.getByRole('listbox').waitFor({ state: 'visible' }); + if (!(await selectSearchIndexOption(page, endpoint))) { return false; } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts index d4a1774a22e1c..69fa843348105 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts @@ -1467,10 +1467,8 @@ export const verifyPlatformLineageForEntity = async ( await page .getByTestId('search-entity-select') - .locator('div') - .filter({ hasText: /^Search entity to view lineage$/ }) - .click(); - await page.getByTestId('search-entity-select').locator('input').fill(fromFqn); + .getByRole('combobox') + .fill(fromFqn); const focusSceneResponse = page.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/navbar.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/navbar.ts index 2d1a4c5d2adcb..dd2b28ad5ede1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/navbar.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/navbar.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect, Locator, Page } from '@playwright/test'; +import { Locator, Page } from '@playwright/test'; export const navbarSearchItems = [ { @@ -112,39 +112,5 @@ export const selectOption = async ( await page.mouse.move(1280, 0); await dropdownLocator.click(); - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); - - // Logic to scroll to find the option - // Since antd dropdown only ingests 10 option at a time in the DOM. - await page.getByTestId('global-search-select-dropdown').hover(); - - const maxScrollAttempts = 15; // Prevent infinite loop - let scrollAttempts = 0; - const scrollStep = 50; // Pixels to scroll each time - - while (scrollAttempts < maxScrollAttempts) { - // Check if option is visible in current view - const optionLocator = page.getByTestId( - `global-search-select-option-${optionTitle}` - ); - - const isOptionVisible = await optionLocator - .first() - .isVisible() - .catch(() => false); - - if (isOptionVisible) { - await optionLocator.first().click(); - - return; - } - - // Scroll down - await page.mouse.wheel(0, scrollStep); - - // eslint-disable-next-line playwright/no-wait-for-timeout -- DOM update delay after scroll - await page.waitForTimeout(100); - - scrollAttempts++; - } + await page.getByTestId(`global-search-select-option-${optionTitle}`).click(); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx index 6968ea41c4db3..85d18dc6f263e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx @@ -11,8 +11,7 @@ * limitations under the License. */ -import { Box, Typography } from '@openmetadata/ui-core-components'; -import { Select } from 'antd'; +import { Box, Select, Typography } from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import classNames from 'classnames'; @@ -337,17 +336,20 @@ const SampleDataTable: FC = ({ itemClassName="layout-space-item"> {t('label.row-limit')}: diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx index 616708caefc9b..8fdbb6978cae9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx @@ -17,10 +17,10 @@ import { Dropdown, Grid, Label, + Select, Tooltip, Typography, } from '@openmetadata/ui-core-components'; -import { Select } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; import { groupBy, isEmpty, isEqual, isUndefined, omit } from 'lodash'; @@ -123,6 +123,8 @@ const ModalWithMarkdownEditor = withSuspenseFallback( ) ); +const NO_CONSTRAINT_KEY = 'none'; + const SchemaTable = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -902,18 +904,30 @@ const SchemaTable = () => { })} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx index ba1ae2d2cae8a..409294e8250a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx @@ -13,10 +13,10 @@ import { Button, + Input, SimpleModal, Typography, } from '@openmetadata/ui-core-components'; -import { Input } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; @@ -153,10 +153,11 @@ const AddPipeLineModal = ({ })} onCancel={onModalCancel}> handleChange(e.target.value)} + onChange={handleChange} />
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx index 7048d39e29ac7..711723fc280bb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx @@ -156,6 +156,21 @@ const setColumnsInCurrentPagesMock = jest.fn((updater) => { }); jest.mock('@openmetadata/ui-core-components', () => ({ + Input: ({ + inputDataTestId, + value, + onChange, + }: { + inputDataTestId?: string; + value?: string; + onChange?: (value: string) => void; + }) => ( + onChange?.(e.target.value)} + /> + ), ButtonUtility: jest .fn() .mockImplementation( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx index 4cabdeceb8e08..984398aa2e810 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Badge, Button } from '@openmetadata/ui-core-components'; +import { Badge } from '@openmetadata/ui-core-components'; import classNames from 'classnames'; import { get } from 'lodash'; import { FC, useMemo } from 'react'; @@ -19,12 +19,10 @@ import { getEntityIcon } from '../../../../utils/EntityIconUtils'; import { getEntityName } from '../../../../utils/EntityNameUtils'; import serviceUtilClassBase from '../../../../utils/ServiceUtilClassBase'; import { SourceType } from '../../../SearchedData/SearchedData.interface'; -import './entity-suggestion-option.less'; import { EntitySuggestionOptionProps } from './EntitySuggestionOption.interface'; const EntitySuggestionOption: FC = ({ entity, heading, - onSelectHandler, className, showEntityTypeBadge = false, }) => { @@ -52,17 +50,7 @@ const EntitySuggestionOption: FC = ({ }, [entity]); return ( - +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts index 1bb522de502a3..a5c663f279b17 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.interface.ts @@ -15,7 +15,6 @@ import { EntityReference } from '../../../../generated/entity/type'; export interface EntitySuggestionOptionProps { entity: EntityReference; heading?: string; - onSelectHandler?: (value: EntityReference) => void; className?: string; showEntityTypeBadge?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/entity-suggestion-option.less b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/entity-suggestion-option.less deleted file mode 100644 index 0cd40a8fc51d3..0000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntitySuggestionOption/entity-suggestion-option.less +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2025 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx index 70271560b1b44..90e19672c994c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx @@ -10,13 +10,7 @@ * 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 { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { EntityType } from '../../../../enums/entity.enum'; import { LineageLayer } from '../../../../generated/settings/settings'; @@ -120,38 +114,26 @@ describe('LineageSearchSelect', () => { ); }); - it('should render select with options', async () => { - const { container } = render(); - await waitFor(() => { - expect(screen.getByTestId('lineage-search')).toBeInTheDocument(); - }); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - await act(async () => { - const selectElm = container.querySelector('.ant-select-selector'); - selectElm && userEvent.click(selectElm); - }); + const openOptions = async () => { + render(); - const option1 = await screen.findByTestId('option-test1'); + expect(screen.getByTestId('lineage-search')).toBeInTheDocument(); - expect(option1).toBeInTheDocument(); - }); - - it('should call onNodeClick and center the node', async () => { - const { container } = render(); - await waitFor(() => { - expect(screen.getByTestId('lineage-search')).toBeInTheDocument(); - }); + await user.click(screen.getByRole('combobox')); + }; - await act(async () => { - const selectElm = container.querySelector('.ant-select-selector'); - selectElm && userEvent.click(selectElm); - }); + it('should render select with options', async () => { + await openOptions(); - const option1 = await screen.findByTestId('option-test1'); + expect(await screen.findByTestId('option-test1')).toBeInTheDocument(); + }); - expect(option1).toBeInTheDocument(); + it('should call onNodeClick and center the node', async () => { + await openOptions(); - fireEvent.click(option1); + await user.click(await screen.findByTestId('option-test1')); expect(mockNodeClick).toHaveBeenCalled(); // The laid-out position from React Flow, not the origin the provider's copy @@ -165,23 +147,11 @@ describe('LineageSearchSelect', () => { }); it('should call onColumnClick', async () => { - const { container } = render(); - await waitFor(() => { - expect(screen.getByTestId('lineage-search')).toBeInTheDocument(); - }); - - await act(async () => { - const selectElm = container.querySelector('.ant-select-selector'); - selectElm && userEvent.click(selectElm); - }); - - const column = await screen.findByTestId('option-column1'); + await openOptions(); - expect(column).toBeInTheDocument(); + await user.click(await screen.findByTestId('option-column1')); - fireEvent.click(column); - - expect(mockColumnClick).toHaveBeenCalled(); + expect(mockColumnClick).toHaveBeenCalledWith('column1'); }); it('should not render when platform lineage is enabled', () => { @@ -203,33 +173,4 @@ describe('LineageSearchSelect', () => { expect(container).toBeEmptyDOMElement(); }); - - it('should handle dropdown visibility change', async () => { - (useLineageStore as unknown as jest.Mock).mockImplementation( - mockStoreImplementation({ isPlatformLineage: false }) - ); - const { container } = render(); - await waitFor(() => { - expect(screen.getByTestId('lineage-search')).toBeInTheDocument(); - }); - - // Open dropdown - await act(async () => { - const selectElm = container.querySelector('.ant-select-selector'); - selectElm && userEvent.click(selectElm); - }); - - // Close dropdown - await act(async () => { - const selectElm = container.querySelector('.ant-select-selector'); - selectElm && userEvent.click(selectElm); - }); - - // Verify search value is cleared - const searchInput = container.querySelector( - '.ant-select-selection-search-input' - ) as HTMLInputElement; - - expect(searchInput?.value).toBe(''); - }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx index 058c778497ecb..9a4ddaa0fcdd8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx @@ -10,28 +10,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { RightOutlined } from '@ant-design/icons'; -import { Box, Typography } from '@openmetadata/ui-core-components'; -import { Select } from 'antd'; -import { DefaultOptionType } from 'antd/lib/select'; -import { debounce } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Autocomplete, + type SelectItemType, +} from '@openmetadata/ui-core-components'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Node } from 'reactflow'; import { useShallow } from 'zustand/react/shallow'; -import { - DEBOUNCE_TIMEOUT, - INITIAL_NODE_ITEMS_LENGTH, - NODE_ITEMS_PAGE_SIZE, - ZOOM_TRANSITION_DURATION, -} from '../../../../constants/Lineage.constants'; -import { Column } from '../../../../generated/entity/data/table'; +import { ZOOM_TRANSITION_DURATION } from '../../../../constants/Lineage.constants'; import { LineagePlatformView } from '../../../../hooks/lineage/types'; import { useLineageStore } from '../../../../hooks/useLineageStore'; -import { EntityIconSize } from '../../../../utils/EntityIconUtils'; import { getEntityChildrenAndLabel } from '../../../../utils/EntityLineageNodeUtils'; import { getEntityName } from '../../../../utils/EntityNameUtils'; -import searchClassBase from '../../../../utils/SearchClassBase'; import serviceUtilClassBase from '../../../../utils/ServiceUtilClassBase'; import { useLineageHandlers } from '../../../Lineage/Lineage/LineageHandlersContext'; @@ -46,176 +37,62 @@ const LineageSearchSelect = () => { ); const { zoomValue, isPlatformLineage, platformView, setSelectedColumn } = useLineageStore(); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [allOptions, setAllOptions] = useState([]); - const [renderedOptions, setRenderedOptions] = useState( - [] - ); - const [searchValue, setSearchValue] = useState(''); - const [isLoading, setIsLoading] = useState(false); + // Options are built on first focus: a large graph has thousands of columns. + const [isActivated, setIsActivated] = useState(false); + const [selectedItems, setSelectedItems] = useState([]); - const generateNodeOptions = useCallback(() => { - const optionsMap: Map = new Map(); + const options = useMemo(() => { + if (!isActivated) { + return []; + } + const optionsMap = new Map(); nodes.forEach((nodeObj) => { const { node } = nodeObj.data; - if (!node) { + if (!node?.fullyQualifiedName) { return; } - const nodeOption = { - label: ( - - {node.serviceType} - {getEntityName(node)} - - ), - value: node.fullyQualifiedName, - dataLabel: getEntityName(node), - }; - optionsMap.set(nodeOption.value, nodeOption); + const icon = ( + {node.serviceType} + ); + const nodeName = getEntityName(node); + optionsMap.set(node.fullyQualifiedName, { + id: node.fullyQualifiedName, + label: nodeName, + icon, + }); const { children: childrenFlatten } = getEntityChildrenAndLabel(node); - childrenFlatten.forEach((column: Column) => { + childrenFlatten.forEach((column) => { // A metric is its own column-lineage endpoint, so its only child is the // node itself. Both options key on FQN, so without this the child would // overwrite the node option added above and render the node with the // column-style label. - if (column.fullyQualifiedName === node.fullyQualifiedName) { + if ( + !column.fullyQualifiedName || + column.fullyQualifiedName === node.fullyQualifiedName + ) { return; } - const columnOption = { - label: ( -
-
- {node.serviceType} - - {getEntityName(node)} - - -
-
- {searchClassBase.getEntityIconWithBg( - node.entityType ?? '', - EntityIconSize.Size14 - )} - {getEntityName(column)} -
-
- ), - value: column.fullyQualifiedName, - dataLabel: getEntityName(column), - }; - optionsMap.set(columnOption.value, columnOption); + optionsMap.set(column.fullyQualifiedName, { + id: column.fullyQualifiedName, + label: getEntityName(column), + supportingText: nodeName, + icon, + }); }); }); return Array.from(optionsMap.values()); - }, [nodes]); - - useEffect(() => { - if (isDropdownOpen && allOptions.length === 0) { - setIsLoading(true); - const options = generateNodeOptions(); - setAllOptions(options); - setRenderedOptions(options.slice(0, INITIAL_NODE_ITEMS_LENGTH)); - setIsLoading(false); - } - }, [isDropdownOpen, allOptions.length, generateNodeOptions]); - - useEffect(() => { - setAllOptions([]); - setRenderedOptions([]); - setSearchValue(''); - }, [nodes]); - - const filterOptions = useCallback( - (value: string) => { - if (value) { - const filteredOptions = allOptions.filter((option) => - option.dataLabel - ?.toString() - .toLowerCase() - .includes(value.toLowerCase()) - ); - setRenderedOptions(filteredOptions); - } else { - setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH)); - } - }, - [allOptions] - ); - - // Create a debounced version of the filter function - const debouncedFilterOptions = useMemo( - () => debounce(filterOptions, DEBOUNCE_TIMEOUT), - [filterOptions] - ); - - // Cleanup debounce on unmount - useEffect(() => { - return () => { - debouncedFilterOptions.cancel(); - }; - }, [debouncedFilterOptions]); - - const handleSearch = (value: string) => { - setSearchValue(value); - debouncedFilterOptions(value); - }; - - const loadMoreData = () => { - if (searchValue) { - // If searching, just use the filtered options from allOptions - filterOptions(searchValue); - } else { - const nextLength = Math.min( - renderedOptions.length + NODE_ITEMS_PAGE_SIZE, - allOptions.length - ); - setRenderedOptions(allOptions.slice(0, nextLength)); - } - }; - - const handleDropdownVisibleChange = useCallback( - (open: boolean) => { - setIsDropdownOpen(open); - if (!open) { - setSearchValue(''); - setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH)); - debouncedFilterOptions.cancel(); - } - }, - [allOptions, debouncedFilterOptions] - ); - - const handlePopupScroll = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - if (scrollTop + clientHeight >= scrollHeight - 10) { - loadMoreData(); - } - }; + }, [isActivated, nodes]); const onOptionSelect = useCallback( (value?: string) => { @@ -252,32 +129,52 @@ const LineageSearchSelect = () => { [onNodeClick, reactFlowInstance, setSelectedColumn, nodes, zoomValue] ); + const handleItemInserted = useCallback( + (key: React.Key) => { + const item = options.find((option) => option.id === key); + setSelectedItems(item ? [item] : []); + onOptionSelect(String(key)); + }, + [options, onOptionSelect] + ); + + const handleItemCleared = useCallback(() => { + setSelectedItems([]); + onOptionSelect(undefined); + }, [onOptionSelect]); + if (isPlatformLineage || platformView !== LineagePlatformView.None) { return null; } + const placeholder = t('label.search-entity', { + entity: t('label.lineage'), + }); + return ( - } - value={searchValue} - onChange={handleSearchChange} - onClick={(e) => e.stopPropagation()} - /> + {/* The field sits inside a React Flow node; a click must not select the node. */} +
e.stopPropagation()}> + +
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx index b7fe647220078..c7780b3246b19 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx @@ -11,19 +11,20 @@ * limitations under the License. */ -import { Box, Button } from '@openmetadata/ui-core-components'; -import { Select } from 'antd'; +import { + Box, + Select, + type SelectItemType, +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { capitalize, debounce, get } from 'lodash'; import { - ComponentRef, FC, HTMLAttributes, - MouseEvent, + Key, useCallback, useEffect, useMemo, - useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,7 +42,6 @@ import serviceUtilClassBase from '../../../utils/ServiceUtilClassBase'; import { getEntityIcon } from '../../../utils/TableUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import { SourceType } from '../../SearchedData/SearchedData.interface'; -import './node-suggestion.less'; interface EntitySuggestionProps extends HTMLAttributes { onSelectHandler: (value: EntityReference) => void; @@ -55,11 +55,8 @@ const NodeSuggestions: FC = ({ onSelectHandler, }) => { const { t } = useTranslation(); - const selectRef = useRef>(null); - const [data, setData] = useState>([]); const [searchValue, setSearchValue] = useState(''); - const [isOpen, setIsOpen] = useState(true); const getSuggestionLabelHeading = useCallback((fqn: string, type: string) => { if (type === EntityType.TABLE) { @@ -87,7 +84,6 @@ const NodeSuggestions: FC = ({ }); const sources = data.hits.hits.map((hit) => hit._source); setData(sources); - selectRef.current?.focus(); } catch (error) { showErrorToast( error as AxiosError, @@ -104,48 +100,68 @@ const NodeSuggestions: FC = ({ getSearchResults, ]); - const nodeSelectOptions = useMemo(() => { - return data.map((entity) => ({ - value: entity.fullyQualifiedName, - label: ( - - ), - })); - }, [data, getSuggestionLabelHeading]); + )} + + ); + }, + [entityByFqn, getSuggestionLabelHeading] + ); const handleChange = useCallback( (value: string): void => { @@ -172,25 +188,26 @@ const NodeSuggestions: FC = ({ wrap="nowrap"> {entityIcon} - } - value={searchCriteria} - onChange={updateSearchCriteria}> + popoverClassName="tw:w-50" + selectedKey={searchCriteria || ALL_SEARCH_CRITERIA_KEY} + onSelectionChange={(key) => + key !== null && + updateSearchCriteria( + key === ALL_SEARCH_CRITERIA_KEY + ? '' + : (String(key) as ExploreSearchIndex) + ) + }> {searchClassBase.getGlobalSearchOptions().map(({ value, label }) => ( - - {label} - + id={value || ALL_SEARCH_CRITERIA_KEY} + key={value || ALL_SEARCH_CRITERIA_KEY} + label={label} + /> ))} ), - [searchCriteria, i18n.language, renderSearchDropdown] + [searchCriteria, i18n.language, t, updateSearchCriteria] ); const handleSelectOption = useCallback((text: string) => { @@ -179,7 +177,7 @@ export const GlobalSearchBar = () => { } }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLInputElement; if (e.key === 'Enter') { if (isTourOpen && searchValue === TOUR_SEARCH_TERM) { @@ -316,27 +314,27 @@ export const GlobalSearchBar = () => { ref={searchContainerRef}> {renderNlpToggle()} { setIsSearchBlur(true); }} - onChange={(e) => { - const { value } = e.target; + onChange={(value) => { debounceOnSearch(value); handleSearchChange(value); }} - onClick={() => setIsSearchBoxOpen(true)} onFocus={() => { setIsSearchBlur(false); + setIsSearchBoxOpen(true); }} onKeyDown={handleKeyDown} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/GlobalSearchBar/global-search-bar.less b/openmetadata-ui/src/main/resources/ui/src/components/GlobalSearchBar/global-search-bar.less index 8f7eff3997156..ac538268f1b84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/GlobalSearchBar/global-search-bar.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/GlobalSearchBar/global-search-bar.less @@ -19,12 +19,6 @@ background: var(--om-color-gray-25); padding: var(--om-space-6) var(--om-space-20); - .global-search-select { - .ant-select-arrow { - color: @text-color; - } - } - .nav-search-button { border: 0.5px solid @nlp-border-color !important; border-radius: @border-rad-xs; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.test.tsx index e4990f2a0031a..d3cb147258409 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.test.tsx @@ -11,21 +11,13 @@ * limitations under the License. */ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { EntityReference } from '../../../../generated/entity/type'; import { PersonaSelectableList } from './PersonaSelectableList.component'; const mockGetAllPersonas = jest.fn(); const mockSearchPersonas = jest.fn(); -// Captures the Select's onChange so a test can emulate antd reporting the full -// multi-select value (all FQNs), not just the option just clicked. -let latestOnChange: ((value: string[]) => void) | undefined; jest.mock('../../../../rest/PersonaAPI', () => ({ getAllPersonas: (...args: unknown[]) => mockGetAllPersonas(...args), @@ -47,44 +39,6 @@ jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -jest.mock('antd', () => { - const antd = jest.requireActual('antd'); - - return { - ...antd, - Select: ({ - options, - onSearch, - onChange, - }: { - options?: { label: string; value: string }[]; - onSearch?: (value: string) => void; - onChange?: (value: string[]) => void; - }) => { - latestOnChange = onChange; - - return ( -
- {/* eslint-disable-next-line jsx-a11y/control-has-associated-label -- test mock */} - onSearch?.(e.target.value)} - /> - {(options ?? []).map((option: { label: string; value: string }) => ( -
onChange?.([option.value])}> - {option.label} -
- ))} -
- ); - }, - }; -}); - const PERSONAS = [ { id: 'p1', @@ -100,6 +54,18 @@ const PERSONAS = [ }, ]; +const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + +const openAndType = async (text: string) => { + const input = screen.getByRole('combobox'); + await user.clear(input); + await user.type(input, text); + // The search is debounced by 300ms. + await act(async () => { + jest.advanceTimersByTime(300); + }); +}; + describe('PersonaSelectableList', () => { beforeEach(() => { jest.clearAllMocks(); @@ -135,10 +101,8 @@ describe('PersonaSelectableList', () => { await waitFor(() => expect(mockGetAllPersonas).toHaveBeenCalled()); - fireEvent.click(screen.getByTestId('edit-user-persona')); - fireEvent.change(screen.getByTestId('persona-search'), { - target: { value: 'ana' }, - }); + await user.click(screen.getByTestId('edit-user-persona')); + await openAndType('ana'); await waitFor(() => expect(mockSearchPersonas).toHaveBeenCalledWith('ana', 50) @@ -155,16 +119,16 @@ describe('PersonaSelectableList', () => { /> ); - fireEvent.click(screen.getByTestId('edit-user-persona')); - fireEvent.change(screen.getByTestId('persona-search'), { - target: { value: 'ana' }, - }); + await user.click(screen.getByTestId('edit-user-persona')); + await openAndType('ana'); await waitFor(() => - expect(screen.queryByTestId('option-steward')).not.toBeInTheDocument() + expect( + screen.queryByTestId('persona-option-Steward') + ).not.toBeInTheDocument() ); - expect(screen.getByTestId('option-analyst')).toBeInTheDocument(); + expect(screen.getByTestId('persona-option-Analyst')).toBeInTheDocument(); expect(mockSearchPersonas).not.toHaveBeenCalled(); expect(mockGetAllPersonas).not.toHaveBeenCalled(); }); @@ -186,24 +150,16 @@ describe('PersonaSelectableList', () => { /> ); - fireEvent.click(screen.getByTestId('edit-user-persona')); - const searchInput = screen.getByTestId('persona-search'); - - fireEvent.change(searchInput, { target: { value: 'ana' } }); - await waitFor(() => - expect(screen.getByTestId('option-analyst')).toBeInTheDocument() - ); + await user.click(screen.getByTestId('edit-user-persona')); - fireEvent.change(searchInput, { target: { value: 'stew' } }); - await waitFor(() => - expect(screen.getByTestId('option-steward')).toBeInTheDocument() - ); + await openAndType('ana'); + await user.click(await screen.findByTestId('persona-option-Analyst')); - // antd reports the full multi-select value; 'analyst' is no longer in the - // current search page, yet it must survive the save. - act(() => latestOnChange?.(['analyst', 'steward'])); + // 'analyst' is not in the 'stew' results, yet it must survive the save. + await openAndType('stew'); + await user.click(await screen.findByTestId('persona-option-Steward')); - fireEvent.click(screen.getByTestId('user-profile-persona-edit-save')); + await user.click(screen.getByTestId('user-profile-persona-edit-save')); await waitFor(() => expect(onUpdate).toHaveBeenCalledWith([ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.tsx index 859e379d9a93a..a6f0a2ff05896 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import { + Autocomplete, Box, Button, ButtonUtility, @@ -18,10 +19,8 @@ import { PopoverTrigger, Typography, } from '@openmetadata/ui-core-components'; -import { RefSelectProps, Select } from 'antd'; -import classNames from 'classnames'; import { debounce } from 'lodash'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Key, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as EditIcon } from '../../../../assets/svg/edit-new.svg'; import { ReactComponent as PersonaIcon } from '../../../../assets/svg/ic-persona-new.svg'; @@ -34,8 +33,6 @@ import { EntityReference } from '../../../../generated/entity/type'; import { getAllPersonas, searchPersonas } from '../../../../rest/PersonaAPI'; import { getEntityName } from '../../../../utils/EntityNameUtils'; import { getEntityReferenceListFromEntities } from '../../../../utils/EntityReferenceUtils'; -import { normalizeToArray } from '../../../../utils/ObjectUtils'; -import { TagRenderer } from '../../../common/TagRenderer/TagRenderer'; import { PersonaSelectableListProps } from './PersonaSelectableList.interface'; export const PersonaListItemRenderer = (props: EntityReference) => { @@ -59,15 +56,6 @@ export const PersonaListItemRenderer = (props: EntityReference) => { ); }; -const renderMaxTagPlaceholder = ( - count: number, - t: ReturnType['t'] -) => ( - - {t('label.plus-count-more', { count })} - -); - export const PersonaSelectableList = ({ hasPermission, selectedPersonas = [], @@ -79,43 +67,9 @@ export const PersonaSelectableList = ({ const [popupVisible, setPopupVisible] = useState(false); const { t } = useTranslation(); const [isSaving, setIsSaving] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [currentlySelectedPersonas, setCurrentlySelectedPersonas] = useState< EntityReference[] >([]); - const [popoverHeight, setPopoverHeight] = useState( - isDefaultPersona ? 116 : 156 - ); - const dropdownRef = useRef(null); - - useEffect(() => { - setIsDropdownOpen(popupVisible); - }, [popupVisible]); - - useEffect(() => { - const observer = new MutationObserver(() => { - const dropdown = document.querySelector( - '.persona-custom-dropdown-class' - ) as HTMLElement; - - if (dropdown) { - setPopoverHeight( - dropdown.scrollHeight + (isDefaultPersona ? 116 : 156) - ); - } - }); - - const dropdown = document.querySelector('.persona-custom-dropdown-class'); - if (dropdown) { - observer.observe(dropdown, { - attributes: true, - childList: true, - subtree: true, - }); - } - - return () => observer.disconnect(); - }, [isDropdownOpen, isDefaultPersona]); const [selectOptions, setSelectOptions] = useState([]); // Server-side search replaces selectOptions per query, so the selected FQNs are @@ -204,10 +158,29 @@ export const PersonaSelectableList = ({ useEffect(() => { if (popupVisible) { + setCurrentlySelectedPersonas(selectedPersonas); loadOptions(); } }, [popupVisible, loadOptions]); + const autocompleteItems = useMemo( + () => + selectOptions.map((persona) => ({ + id: persona.fullyQualifiedName ?? '', + label: getEntityName(persona), + })), + [selectOptions] + ); + + const selectedItems = useMemo( + () => + currentlySelectedPersonas.map((persona) => ({ + id: persona.fullyQualifiedName ?? '', + label: getEntityName(persona), + })), + [currentlySelectedPersonas] + ); + const handlePersonaUpdate = () => { setIsSaving(true); @@ -224,12 +197,17 @@ export const PersonaSelectableList = ({ } }; - const handleChange = useCallback((selected: string | string[]) => { - const selectedFqns = normalizeToArray(selected); - const selectedPersonasList = selectedFqns - .map((fqn) => personaByFqnRef.current.get(fqn)) - .filter((persona): persona is EntityReference => Boolean(persona)); - setCurrentlySelectedPersonas(selectedPersonasList); + const handleItemInserted = useCallback((key: Key) => { + const persona = personaByFqnRef.current.get(String(key)); + if (persona) { + setCurrentlySelectedPersonas((prev) => [...prev, persona]); + } + }, []); + + const handleItemCleared = useCallback((key: Key) => { + setCurrentlySelectedPersonas((prev) => + prev.filter((persona) => persona.fullyQualifiedName !== key) + ); }, []); if (!hasPermission) { @@ -255,11 +233,7 @@ export const PersonaSelectableList = ({ containerClassName="tw:w-95 tw:p-5" data-testid="persona-popover" placement="bottom start"> -
+
@@ -272,49 +246,37 @@ export const PersonaSelectableList = ({
-
- + aria-label={t('label.boost-mode')} + items={boostModeOptions} + value={signals.boostMode ?? null} + onChange={(key) => + handleRankingSignalBoostModeChange(key as BoostMode) + }> + {(item) => } + {t('label.score-mode')} { {t('label.score-mode')}
{ {t('label.boost-mode')}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx index 5ace051701a38..126f42f12173f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx @@ -14,12 +14,13 @@ import Icon from '@ant-design/icons'; import { Button, Divider, + Select, Toggle, Tooltip, TooltipTrigger, Typography, } from '@openmetadata/ui-core-components'; -import { Badge, Collapse, Select, Slider } from 'antd'; +import { Badge, Collapse, Slider } from 'antd'; import { MouseEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as Delete } from '../../../assets/svg/delete-colored.svg'; @@ -85,10 +86,10 @@ const FieldConfiguration: React.FC = ({ }; const matchTypeOptions = [ - { label: t('label.exact-match'), value: MatchType.Exact }, - { label: t('label.phrase-match'), value: MatchType.Phrase }, - { label: t('label.fuzzy-match'), value: MatchType.Fuzzy }, - { label: t('label.standard-match'), value: MatchType.Standard }, + { 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') }, ]; return ( @@ -210,13 +211,14 @@ const FieldConfiguration: React.FC = ({
{t('label.match-type')}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem.tsx index 224d420bdff94..7bc197d14649b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem.tsx @@ -11,8 +11,8 @@ * limitations under the License. */ import Icon from '@ant-design/icons/lib/components/Icon'; -import { Typography } from '@openmetadata/ui-core-components'; -import { Input } from 'antd'; +import { Input, Typography } from '@openmetadata/ui-core-components'; +import { clamp } from 'lodash'; import { useState } from 'react'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import '../../../pages/SearchSettingsPage/search-settings.less'; @@ -43,7 +43,8 @@ export const GlobalSettingItem = ({ const handleSave = async () => { try { setIsUpdating(true); - await onUpdate(updatedValue); + // The core Input has no min/max, so the bounds are applied on save. + await onUpdate(clamp(updatedValue, min ?? -Infinity, max ?? Infinity)); } catch (error) { setUpdatedValue(value); @@ -70,19 +71,16 @@ export const GlobalSettingItem = ({ onCancel={handleCancel} onSave={handleSave}> - setUpdatedValue( - step ? parseFloat(e.target.value) : parseInt(e.target.value) - ) + value={Number.isNaN(updatedValue) ? '' : String(updatedValue)} + onChange={(value) => + setUpdatedValue(step ? parseFloat(value) : parseInt(value)) } /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/SearchPreview.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/SearchPreview.tsx index 4aad9483397de..80fb0c61997bb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/SearchPreview.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/SearchPreview.tsx @@ -10,19 +10,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import Icon from '@ant-design/icons'; import { Box, Button, + Input, Toggle, Typography, } from '@openmetadata/ui-core-components'; -import { Input } from 'antd'; +import { Search } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; import { debounce } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as IconSearchV1 } from '../../../assets/svg/search.svg'; import { ENTITY_PATH } from '../../../constants/constants'; import { ENTITY_PATH_TO_SEARCH_INDEX } from '../../../constants/SearchSettings.constant'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; @@ -248,16 +247,12 @@ const SearchPreview = ({ - } - type="text" value={searchValue} - onChange={(e) => handleSearch(e.target.value)} + onChange={handleSearch} />
{searchResults}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/search-preview.less b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/search-preview.less index ef9fd41518578..74a2b0e6273b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/search-preview.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/search-preview.less @@ -42,32 +42,6 @@ white-space: nowrap; } - .search-box { - background-color: @grey-1; - border: none; - border-radius: var(--om-radius-md); - - .ant-input { - background-color: @grey-1; - padding: var(--om-space-8); - font-size: var(--om-font-size-sm); - - &:hover, - &:focus, - &:focus-within, - &-focused { - border-color: @grey-15; - box-shadow: none; - outline: none; - } - } - - &:focus-within { - box-shadow: none; - border-color: @grey-15; - } - } - .search-results-container { flex: 1; overflow-y: auto; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanism.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanism.tsx index a16081558c30b..366f54c085c85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanism.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanism.tsx @@ -16,9 +16,9 @@ import { Box, Button, Divider, + PasswordInput, Typography, } from '@openmetadata/ui-core-components'; -import { Input } from 'antd'; import classNames from 'classnames'; import { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -120,8 +120,9 @@ const AuthMechanism: FC = ({ )}`} gap={2} itemClassName="layout-space-item"> - = ({ const [isDisplayNameEdit, setIsDisplayNameEdit] = useState(false); const [selectedRoles, setSelectedRoles] = useState>([]); const [roles, setRoles] = useState>([]); - const [isRolesLoading, setIsRolesLoading] = useState(false); const selectedRolesRef = useRef([]); const { getResourceLimit, config } = useLimitStore(); @@ -90,8 +89,6 @@ const BotDetails: FC = ({ }; const fetchRoles = useCallback(async (query = '') => { - setIsRolesLoading(true); - try { const data = await searchRoles(query); setRoles((prevRoles) => { @@ -103,8 +100,6 @@ const BotDetails: FC = ({ }); } catch (err) { showErrorToast(err as AxiosError); - } finally { - setIsRolesLoading(false); } }, []); @@ -113,10 +108,6 @@ const BotDetails: FC = ({ [fetchRoles] ); - const onDisplayNameChange = (e: React.ChangeEvent) => { - setDisplayName(e.target.value); - }; - const handleDisplayNameChange = () => { if (displayName !== botData.displayName) { updateBotsDetails({ displayName: displayName ?? '' }); @@ -152,12 +143,13 @@ const BotDetails: FC = ({ {isDisplayNameEdit ? ( <>
-
- -); - -const CustomUnitSelect: FC = ({ - value, - customValue, - onChange, - placeholder, - disabled = false, - showSearch = true, - dataTestId = 'unit-of-measurement-select', -}) => { - const { t } = useTranslation(); - const [customUnits, setCustomUnits] = useState([]); - const [userAddedUnits, setUserAddedUnits] = useState([]); - const [newCustomUnit, setNewCustomUnit] = useState(''); - const [loading, setLoading] = useState(false); - - // Memoized computed values - const { allCustomUnits, allOptions } = useMemo(() => { - // Combine custom units with deduplication - const combinedCustomUnits = [ - ...new Set([...customUnits, ...userAddedUnits]), - ]; - - // Standard unit options - const standardOptions = Object.values(UnitOfMeasurement) - .filter((unit) => unit !== UnitOfMeasurement.Other) - .map((unit) => ({ - label: startCase(unit.toLowerCase()), - value: unit, - })); - - // Custom options from combined units - const customOptions = combinedCustomUnits.map((unit) => ({ - label: unit, - value: unit, - })); - - // Combined options - standard units first, then custom units - const combinedOptions = [...standardOptions, ...customOptions]; - - return { - allCustomUnits: combinedCustomUnits, - allOptions: combinedOptions, - }; - }, [customUnits, userAddedUnits]); - - const fetchCustomUnits = useCallback(async () => { - try { - setLoading(true); - const units = await getCustomUnitsOfMeasurement(); - setCustomUnits(units || []); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setLoading(false); - } - }, []); - - // Fetch custom units from backend on component mount - useEffect(() => { - fetchCustomUnits(); - }, []); - - // Initialize with current custom value if it exists - useEffect(() => { - if (customValue && !allCustomUnits.includes(customValue)) { - setUserAddedUnits([customValue]); - } - }, [customValue, allCustomUnits]); - - const handleAddCustomUnit = () => { - const trimmedUnit = newCustomUnit.trim(); - if (trimmedUnit) { - // Check if it already exists in either list - if (!allCustomUnits.includes(trimmedUnit)) { - setUserAddedUnits([...userAddedUnits, trimmedUnit]); - } - // Select the newly added custom unit - onChange?.(UnitOfMeasurement.Other, trimmedUnit); - setNewCustomUnit(''); - } - }; - - const handleChange = (selectedValue: string) => { - // Check if it's a custom unit (from backend or user-added) - const isCustomUnit = allCustomUnits.includes(selectedValue); - - if (isCustomUnit) { - // Custom unit selected - set unitOfMeasurement to OTHER - onChange?.(UnitOfMeasurement.Other, selectedValue); - } else { - // Standard unit selected - clear customUnitOfMeasurement - onChange?.(selectedValue, undefined); - } - }; - - const handleNameChange = (e: React.ChangeEvent) => { - setNewCustomUnit(e.target.value); - }; - - // Determine display value - const displayValue = value === UnitOfMeasurement.Other ? customValue : value; - - return ( - props.onBlur(id, value)} - onChange={(value) => props.onChange(value)} - onFocus={handleFocus} - onKeyDown={(e) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !options) { - e.preventDefault(); - handlePaste(); + +
+ ({ id: item, label: item }))} + onBlur={() => props.onBlur(id, value)} + onFocus={handleFocus} + onItemCleared={(key: Key) => + props.onChange(value.filter((item) => item !== key)) } - if (!options && e.key === 'Enter') { - // If there are no options(mode is tags), we need to split the input value by comma - e.preventDefault(); - const inputValue = (e.target as HTMLInputElement).value; - if (inputValue) { - handleInputSplit(inputValue); - } + onItemInserted={(key: Key) => + options + ? props.onChange([...value, String(key)]) + : handleInputSplit(String(key)) } - }} - /> - -
+ onKeyDown={(e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !options) { + e.preventDefault(); + handlePaste(); + } + }}> + {(item) => } + +
= (props) => { const [searchResults, setSearchResults] = useState>( new Map() ); - const [isLoadingRoles, setIsLoadingRoles] = useState(false); const [errors, setErrors] = useState({}); const idCounterRef = useRef(0); @@ -138,7 +145,6 @@ const LdapRoleMappingWidget: FC = (props) => { useEffect(() => { const fetchRoles = async () => { - setIsLoadingRoles(true); try { const results = await searchRoles(''); const roleOptions: RoleOption[] = results.map((role) => ({ @@ -148,8 +154,6 @@ const LdapRoleMappingWidget: FC = (props) => { setAvailableRoles(roleOptions); } catch (error) { showErrorToast(error as AxiosError); - } finally { - setIsLoadingRoles(false); } }; @@ -277,6 +281,25 @@ const LdapRoleMappingWidget: FC = (props) => { }; }, [debouncedSearchRoles]); + const getRoleItems = (mappingId: string) => + (searchResults.get(mappingId) ?? availableRoles).map((role) => ({ + id: role.value, + label: role.label, + })); + + const getSelectedRoleItems = (mapping: RoleMappingEntry) => { + const knownRoles = [ + ...availableRoles, + ...(searchResults.get(mapping.id) ?? []), + ]; + + return mapping.roles.map((roleName) => ({ + id: roleName, + label: + knownRoles.find((role) => role.value === roleName)?.label ?? roleName, + })); + }; + return (
= (props) => { - handleLdapGroupChange(mapping.id, e.target.value) + onChange={(ldapGroup) => + handleLdapGroupChange(mapping.id, ldapGroup) } /> {errors[mapping.id] && ( @@ -334,22 +357,35 @@ const LdapRoleMappingWidget: FC = (props) => { - onBlur(rest.id, rest.value)} - onChange={(value) => onChange(value)} - onFocus={() => onFocus(rest.id, rest.value)}> - {(rest.options.enumOptions ?? []).map((option) => { - const displayLabel = getDisplayLabel( - option.label, - rest.capitalizeOptionLabel - ); + autoFocus={props.autofocus} + data-testid={`select-widget-${id}`} + id={id} + isDisabled={props.disabled || props.readonly} + items={items} + placeholder={props.placeholder} + selectedItems={items.filter((item) => selectedKeys.includes(item.id))} + onBlur={() => onBlur(id, value)} + onFocus={() => onFocus(id, value)} + onItemCleared={(key: Key) => + onChange(toValues(selectedKeys.filter((selected) => selected !== key))) + } + onItemInserted={(key: Key) => + onChange(toValues([...selectedKeys, String(key)])) + }> + {(item) => ( + + )} + + ); +}; + +const SingleSelectWidget: FC = (props) => { + const { id, value, options, onBlur, onChange, onFocus } = props; + const { items, valueByKey } = useEnumItems(props); + const [inputValue, setInputValue] = useState(); + + const selectedKey = isNil(value) ? null : String(value); + const selectedLabel = items.find((item) => item.id === selectedKey)?.label; + // The input shows the selected label until the user types, so the list is + // only narrowed by text the user entered. + const filterText = inputValue === selectedLabel ? '' : inputValue ?? ''; + const filteredItems = items.filter((item) => + item.label.toLowerCase().includes(filterText.toLowerCase()) + ); + + // Clearing the text clears the selection, as antd's allowClear did. + const handleSelectionChange = (key: Key | null) => + onChange( + key === null + ? options.emptyValue ?? undefined + : valueByKey.get(String(key)) + ); + + return ( + onBlur(id, value)} + onFocus={() => onFocus(id, value)} + onInputChange={setInputValue} + onSelectionChange={handleSelectionChange}> + {(item: SelectItemType) => ( + + )} + + ); +}; + +const SelectWidget: FC = (props) => { + if (props.schema.uiFieldType === 'treeSelect') { + return ; + } - return ( - - {displayLabel} - - ); - })} - + return props.multiple ? ( + + ) : ( + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RolesCard/RolesCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RolesCard/RolesCard.component.tsx index 08d298d982f64..8eb52468ebeb1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RolesCard/RolesCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RolesCard/RolesCard.component.tsx @@ -12,10 +12,14 @@ */ import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; -import { Box, Button, Card } from '@openmetadata/ui-core-components'; -import { Select } from 'antd'; -import { isArray, isNil, toLower } from 'lodash'; -import { useState } from 'react'; +import { + Autocomplete, + Box, + Button, + Card, +} from '@openmetadata/ui-core-components'; +import { isArray, toLower } from 'lodash'; +import { Key, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TERM_ADMIN } from '../../../constants/constants'; import { useAuth } from '../../../hooks/authHooks'; @@ -31,7 +35,6 @@ const RolesCard = ({ selectedRoles, setSelectedRoles, searchRolesOptions, - isRolesLoading, }: RolesComponentProps) => { const [isRolesEdit, setIsRolesEdit] = useState(false); @@ -60,28 +63,32 @@ const RolesCard = ({ setIsRolesEdit(false); }; - const handleOnRolesChange = (selectedOptions: string[]) => { - if (isNil(selectedOptions)) { - setSelectedRoles([]); - } else { - setSelectedRoles(selectedOptions); - } - }; - const userRolesOption = isArray(roles) ? roles.map((role) => ({ + id: role.id, label: getEntityName(role), - value: role.id, })) : []; if (!userData.isAdmin) { userRolesOption.push({ + id: toLower(TERM_ADMIN), label: TERM_ADMIN, - value: toLower(TERM_ADMIN), }); } + const selectedRoleItems = selectedRoles.map((roleId) => { + const role = roles.find((r) => r.id === roleId); + + return { id: roleId, label: role ? getEntityName(role) : TERM_ADMIN }; + }); + + const handleRoleInserted = (key: Key) => + setSelectedRoles([...selectedRoles, String(key)]); + + const handleRoleCleared = (key: Key) => + setSelectedRoles(selectedRoles.filter((roleId) => roleId !== key)); + if (isAdminUser) { return ( @@ -111,20 +118,20 @@ const RolesCard = ({ direction="col" gap={2} itemClassName="layout-space-item"> - + items={TIME_WINDOWS} + value={String(timeWindow)} + onChange={(key) => setTimeWindow(Number(key))}> + {(item) => } + } loading={isDataLoading} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.test.tsx index e5f311ba03852..fdb0877c682de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.test.tsx @@ -107,9 +107,15 @@ jest.mock('@openmetadata/ui-core-components', () => { } ); + const SelectMock = Object.assign(jest.fn(), { + ComboBox: jest.fn(() =>
Select
), + Item: ({ children }: GridProps) =>
{children}
, + }); + return { Card: CardMock, Grid: GridMock, + Select: SelectMock, Tooltip: jest .fn() .mockImplementation(({ children }: { children: React.ReactNode }) => ( @@ -273,7 +279,8 @@ const mockPageLayoutV1 = require('../../components/PageLayoutV1/PageLayoutV1') const mockEntitySuggestionOption = require('../../components/Entity/EntityLineage/EntitySuggestionOption/EntitySuggestionOption.component') .default as jest.Mock; -const mockSelect = require('antd').Select as jest.Mock; +const mockSelect = require('@openmetadata/ui-core-components').Select + .ComboBox as jest.Mock; jest.mock('../../components/Entity/EntityLineage/LineageConfigModal', () => ({ __esModule: true, @@ -310,16 +317,6 @@ jest.mock( }) ); -jest.mock('antd', () => { - const actual = jest.requireActual('antd'); - - return { - ...actual, - Select: jest.fn(() =>
Select
), - Card: jest.fn(({ children }) =>
{children}
), - }; -}); - jest.mock('../../assets/svg/ic-download.svg', () => ({ ReactComponent: () =>
DownloadIcon
, })); @@ -628,23 +625,25 @@ describe('PlatformLineage Component Logic', () => { // one drives: focus, typing, and the option list it is handed. mockSelect.mockImplementation( ({ - options, + items, + children, onFocus, - onSearch, + onInputChange, }: { - options?: { value: string; label: React.ReactNode }[]; + items?: { id: string; label: string }[]; + children: (item: { id: string; label: string }) => React.ReactNode; onFocus?: () => void; - onSearch?: (value: string) => void; + onInputChange?: (value: string) => void; }) => (
onSearch?.(event.target.value)} + onChange={(event) => onInputChange?.(event.target.value)} onFocus={() => onFocus?.()} /> - {options?.map((option) => ( -
{option.label}
+ {items?.map((item) => ( +
{children(item)}
))}
) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.tsx index 6aa4a01791df9..4a8bae21c9fb9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PlatformLineage/PlatformLineage.tsx @@ -14,6 +14,7 @@ import { ButtonUtility, Card, Grid, + Select, Tooltip, TooltipTrigger, } from '@openmetadata/ui-core-components'; @@ -22,12 +23,10 @@ import { Home02, Minimize02, } from '@openmetadata/ui-core-components/icons'; -import { Select } from 'antd'; -import { DefaultOptionType } from 'antd/lib/select'; import { AxiosError } from 'axios'; import { debounce, startCase } from 'lodash'; import QueryString from 'qs'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Key, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ReactComponent as DownloadIcon } from '../../assets/svg/ic-download.svg'; @@ -85,11 +84,11 @@ const PlatformLineage = () => { const { fqn: decodedFqn } = useFqn(); const [selectedEntity, setSelectedEntity] = useState(); const [isEntityLoading, setIsEntityLoading] = useState(false); - const [options, setOptions] = useState([]); - const [isSearchLoading, setIsSearchLoading] = useState(false); + const [searchResults, setSearchResults] = useState([]); const [defaultValue, setDefaultValue] = useState( decodedFqn || undefined ); + const [searchText, setSearchText] = useState(defaultValue ?? ''); // Config lives in the Zustand store — Lineage's fetch effect depends on // it, so writing here triggers a refetch. Local useState here would leave // the store untouched and the depth change would never reach the network. @@ -168,46 +167,27 @@ const PlatformLineage = () => { const runSearch = useMemo( () => debounce(async (value: string, request: number) => { - try { - setIsSearchLoading(true); - const searchIndices = [ - SearchIndex.DATA_ASSET, - SearchIndex.DOMAIN, - SearchIndex.SERVICE, - ]; + const searchIndices = [ + SearchIndex.DATA_ASSET, + SearchIndex.DOMAIN, + SearchIndex.SERVICE, + ]; - const response = await searchQuery({ - query: escapeESReservedCharacters(value), - searchIndex: searchIndices, - pageSize: PAGE_SIZE_BASE, - queryFilter: getLineageEntityExclusionFilter(), - includeDeleted: false, - }); + const response = await searchQuery({ + query: escapeESReservedCharacters(value), + searchIndex: searchIndices, + pageSize: PAGE_SIZE_BASE, + queryFilter: getLineageEntityExclusionFilter(), + includeDeleted: false, + }); - if (request !== searchOrder.current) { - return; - } - - setOptions( - response.hits.hits.map((hit) => ({ - value: hit._source.fullyQualifiedName ?? '', - label: ( - - ), - data: hit, - })) + if (request === searchOrder.current) { + setSearchResults( + response.hits.hits.map((hit) => hit._source as SourceType) ); - } finally { - if (request === searchOrder.current) { - setIsSearchLoading(false); - } } }, 300), - [handleEntitySelect] + [] ); const debouncedSearch = useCallback( @@ -218,6 +198,35 @@ const PlatformLineage = () => { [runSearch] ); + const searchItems = useMemo( + () => + searchResults.map((entity) => ({ + id: entity.fullyQualifiedName ?? '', + label: getEntityName(entity), + })), + [searchResults] + ); + + const handleSearchInputChange = useCallback( + (value: string) => { + setSearchText(value); + debouncedSearch(value); + }, + [debouncedSearch] + ); + + const handleSearchSelect = useCallback( + (key: Key | null) => { + const entity = searchResults.find( + (result) => result.fullyQualifiedName === key + ); + if (entity) { + handleEntitySelect(entity as EntityReference); + } + }, + [searchResults, handleEntitySelect] + ); + const init = useCallback(async () => { if (!decodedFqn || !entityType) { setDefaultValue(undefined); @@ -232,6 +241,7 @@ const PlatformLineage = () => { )(decodedFqn); setSelectedEntity(entityResponse); setDefaultValue(decodedFqn || undefined); + setSearchText(decodedFqn ?? ''); } catch { // Old code awaited this via Promise.allSettled alongside the permission fetch, so a // rejection (or a synchronous throw from an unsupported entityType) never reached a @@ -273,22 +283,40 @@ const PlatformLineage = () => { const header = useMemo(() => { return (
- } - onChange={(e) => handleSearch(e.target.value)} + onChange={handleSearch} /> {isEmpty(searchedData) ? ( ({ + id: value, label: startCase(value), - value: value, })); export const scoreModeOptions = Object.values(ScoreMode).map((value) => ({ + id: value, label: startCase(value), - value: value, })); export const modifierOptions = Object.values(Modifier).map((value) => ({ From 51dbddcd7b7077ab094146557dc2be8d7e8bb514 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 02:07:04 +0530 Subject: [PATCH 02/32] fix(ui): let Enter reach core comboboxes in the SSO form The SSO form swallows Enter on inputs to stop accidental submits and only exempted antd tag selects, so the migrated tag fields could not add values by pressing Enter. --- .../main/resources/ui/src/utils/SSOUtils.test.ts | 14 ++++++++++++++ .../src/main/resources/ui/src/utils/SSOUtils.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts index 3b7fb3fccd0f6..c2fc5cd6a29d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.test.ts @@ -2910,6 +2910,20 @@ describe('SSOUtils', () => { expect(mockEvent.stopPropagation).not.toHaveBeenCalled(); }); + it('should NOT prevent default when Enter is pressed in a combobox', () => { + const input = document.createElement('input'); + input.setAttribute('role', 'combobox'); + Object.defineProperty(mockEvent, 'target', { + value: input, + writable: true, + }); + + handler(mockEvent as KeyboardEvent); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockEvent.stopPropagation).not.toHaveBeenCalled(); + }); + it('should NOT prevent default for non-Enter keys', () => { const input = document.createElement('input'); Object.defineProperty(mockEvent, 'target', { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts index cca3595e6a74c..02dd531a524dd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SSOUtils.ts @@ -1101,7 +1101,10 @@ export const createFormKeyDownHandler = () => { target.tagName === 'INPUT' || target.classList.contains('ant-input') ) { - const isInSelectTags = target.closest('.ant-select-selector'); + // Enter in a combobox picks or creates a value, so it must reach it. + const isInSelectTags = target.closest( + '.ant-select-selector, [role="combobox"]' + ); if (!isInSelectTags) { e.preventDefault(); e.stopPropagation(); From 77cc4f3c8dbb67847673cc7dd1e70a054a552f9b Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 03:19:51 +0530 Subject: [PATCH 03/32] test(ui): fix e2e fallout from the core Select/Input migration - Persona/SSO specs close the open list before touching chips or Save: react-aria hides the chips from the accessibility tree while the list is open, and removing a chip refocuses the field and reopens it. - Search settings selects retry the open-and-pick via selectSettingOption, since expanding a panel smooth-scrolls it and react-aria closes a popover whose trigger scrolls. - The legacy RJSF SelectWidget keeps the raw enum label in option test ids (select-option-JP) when labels are capitalized for display. --- .../e2e/Features/SSOConfiguration.spec.ts | 4 ++- .../playwright/e2e/Flow/PersonaFlow.spec.ts | 6 +++++ .../e2e/Pages/CustomProperties.spec.ts | 7 +++--- .../e2e/Pages/SearchIndexApplication.spec.ts | 3 +-- .../e2e/Pages/SearchSettings.spec.ts | 13 ++++------ .../ui/playwright/e2e/Pages/Users.spec.ts | 25 ++++++++++++++++--- .../ui/playwright/utils/searchSettingUtils.ts | 19 +++++++++++++- .../JsonSchemaWidgets/SelectWidget.tsx | 15 ++++++++--- 8 files changed, 69 insertions(+), 23 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts index 592569ed55394..b5ae4021a00d5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SSOConfiguration.spec.ts @@ -810,7 +810,9 @@ test.describe('SSO Configuration Tests', () => { .click(); await expect(selectedRoles).toHaveCount(1); - // Remove the selected role via its remove button + // Remove the selected role via its remove button; the open list hides + // the chips from the accessibility tree, so close it first. + await field.getByRole('combobox').press('Escape'); await selectedRoles.getByRole('button').click(); await expect(selectedRoles).toHaveCount(0); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts index 2786753931325..68957390c13cd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts @@ -693,6 +693,12 @@ test.describe.serial('Team persona setting flow', () => { .getByRole('button') .click(); + // Removing the chip refocuses the field, which reopens the list over Save. + await adminPage + .getByTestId('default-persona-select-list') + .getByRole('combobox') + .press('Escape'); + const defaultPersonaChangeResponse = adminPage.waitForResponse('/api/v1/teams/*'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts index 34de5ebbe41a7..9b83b86902ed4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/CustomProperties.spec.ts @@ -110,6 +110,7 @@ import { navigateToExploreAndSelectEntity } from '../../utils/explore'; import { createTable } from '../../utils/KnowledgeCenter'; import { openMatchingFieldsPanel, + selectSettingOption, setSliderValue, } from '../../utils/searchSettingUtils'; import { @@ -3376,8 +3377,7 @@ pickEntityMatrix( ); const matchTypeSelect = page.getByTestId('match-type-select'); - await matchTypeSelect.click(); - await page.getByRole('option', { name: 'Standard Match' }).click(); + await selectSettingOption(page, matchTypeSelect, 'Standard Match'); const searchSettingsSaveResponse = page.waitForResponse( '/api/v1/system/settings' @@ -3516,8 +3516,7 @@ pickEntityMatrix( ); const matchTypeSelect = page.getByTestId('match-type-select'); - await matchTypeSelect.click(); - await page.getByRole('option', { name: 'Phrase Match' }).click(); + await selectSettingOption(page, matchTypeSelect, 'Phrase Match'); const searchSettingsSaveResponse = page.waitForResponse( '/api/v1/system/settings' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 8d6498825ad46..4e3baaba41c79 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -347,10 +347,9 @@ test.describe('Search Index Application', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { // Need an outside click to close the dropdown await clickOutside(page); - await page.locator('[for="root/searchIndexMappingLanguage"]').click(); - await page .getByTestId('select-widget-root/searchIndexMappingLanguage') + .getByRole('combobox') .click(); await expect(page.getByTestId('select-option-JP')).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index 45406b5538e1c..b2a3ba29273dd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -28,6 +28,7 @@ import { mockEntitySearchSettings, openMatchingFieldsPanel, restoreDefaultSearchSettings, + selectSettingOption, setSliderValue, } from '../../utils/searchSettingUtils'; import { settingClick } from '../../utils/sidebar'; @@ -264,18 +265,15 @@ test.describe('Search Settings', () => { // Match Type const matchTypeSelect = page.getByTestId('match-type-select'); - await matchTypeSelect.click(); - await page.getByRole('option', { name: 'Fuzzy Match' }).click(); + await selectSettingOption(page, matchTypeSelect, 'Fuzzy Match'); // Score Mode const scoreModeSelect = page.getByTestId('score-mode-select'); - await scoreModeSelect.click(); - await page.getByRole('option', { name: 'Max' }).click(); + await selectSettingOption(page, scoreModeSelect, 'Max'); // Boost Mode const boostModeSelect = page.getByTestId('boost-mode-select'); - await boostModeSelect.click(); - await page.getByRole('option', { name: 'Replace' }).click(); + await selectSettingOption(page, boostModeSelect, 'Replace'); // Save await page.getByTestId('save-btn').click(); @@ -807,8 +805,7 @@ test.describe('Search Settings', () => { ); const matchTypeSelect = page.getByTestId('match-type-select'); - await matchTypeSelect.click(); - await page.getByRole('option', { name: 'Exact Match' }).click(); + await selectSettingOption(page, matchTypeSelect, 'Exact Match'); const saveSettings = page.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts index d07baef919e04..79bbec06a7df5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts @@ -1025,6 +1025,12 @@ test.describe('User Profile Dropdown Persona Interactions', () => { .getByRole('button') .click(); + // Removing the chip refocuses the field, which reopens the list over Save. + await adminPage + .getByTestId('default-persona-select-list') + .getByRole('combobox') + .press('Escape'); + const removePersonaResponse = adminPage.waitForResponse('/api/v1/users/*'); await adminPage .locator('[data-testid="user-profile-default-persona-edit-save"]') @@ -1160,9 +1166,15 @@ test.describe('User Profile Persona Interactions', () => { await adminPage.getByTestId('persona-select-list').waitFor(); // Clear all selected personas - const selectedPersonaChips = adminPage - .getByTestId('persona-select-list') - .getByTestId('autocomplete-selected-item'); + const personaSelect = adminPage.getByTestId('persona-select-list'); + const selectedPersonaChips = personaSelect.getByTestId( + 'autocomplete-selected-item' + ); + // The open list hides the chips from the accessibility tree, and each + // removal refocuses the field and reopens it. + const closePersonaList = () => + personaSelect.getByRole('combobox').press('Escape'); + await closePersonaList(); // Only the first few chips render at once, so re-read after each removal. let [chipName] = await selectedPersonaChips.allTextContents(); while (chipName) { @@ -1170,6 +1182,7 @@ test.describe('User Profile Persona Interactions', () => { .filter({ hasText: chipName }) .getByRole('button') .click(); + await closePersonaList(); [chipName] = await selectedPersonaChips.allTextContents(); } @@ -1295,6 +1308,12 @@ test.describe('User Profile Persona Interactions', () => { .getByRole('button') .click(); + // Removing the chip refocuses the field, which reopens the list over Save. + await adminPage + .getByTestId('default-persona-select-list') + .getByRole('combobox') + .press('Escape'); + const defaultPersonaChangeResponse = adminPage.waitForResponse('/api/v1/users/*'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/searchSettingUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/searchSettingUtils.ts index 1ef16e91417ca..cf751dc4c4173 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/searchSettingUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/searchSettingUtils.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect, Page } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; import { getApiContext } from './common'; export const mockScoreMode = 'first'; @@ -190,3 +190,20 @@ export const restoreDefaultSearchSettings = async (page: Page) => { expect(tableConfig).toEqual(mockEntitySearchConfig); }; + +// Expanding a panel smooth-scrolls it, and react-aria closes a select popover +// whose trigger scrolls, so retry until the value sticks. +export const selectSettingOption = async ( + page: Page, + select: Locator, + optionName: string +) => { + const trigger = select.getByRole('button'); + await expect(async () => { + await trigger.click(); + await page.getByRole('option', { name: optionName }).click({ + timeout: 2_000, + }); + await expect(trigger).toHaveText(optionName, { timeout: 2_000 }); + }).toPass({ timeout: 20_000 }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/SelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/SelectWidget.tsx index ba1cea8b84271..c6cccc21e827d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/SelectWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/SelectWidget.tsx @@ -44,12 +44,19 @@ const useEnumItems = ({ options, capitalizeOptionLabel }: WidgetProps) => valueByKey: new Map( enumOptions.map((option) => [String(option.value), option.value]) ), + // Test ids keep the raw enum label, before any capitalization. + testIdByKey: new Map( + enumOptions.map((option) => [ + String(option.value), + `select-option-${option.label}`, + ]) + ), }; }, [options.enumOptions, capitalizeOptionLabel]); const MultipleSelectWidget: FC = (props) => { const { id, value, onBlur, onChange, onFocus } = props; - const { items, valueByKey } = useEnumItems(props); + const { items, valueByKey, testIdByKey } = useEnumItems(props); const selectedKeys = ((value as unknown[]) ?? []).map(String); const toValues = (keys: string[]) => keys.map((key) => valueByKey.get(key)); @@ -74,7 +81,7 @@ const MultipleSelectWidget: FC = (props) => { }> {(item) => ( @@ -85,7 +92,7 @@ const MultipleSelectWidget: FC = (props) => { const SingleSelectWidget: FC = (props) => { const { id, value, options, onBlur, onChange, onFocus } = props; - const { items, valueByKey } = useEnumItems(props); + const { items, valueByKey, testIdByKey } = useEnumItems(props); const [inputValue, setInputValue] = useState(); const selectedKey = isNil(value) ? null : String(value); @@ -125,7 +132,7 @@ const SingleSelectWidget: FC = (props) => { onSelectionChange={handleSelectionChange}> {(item: SelectItemType) => ( Date: Tue, 13 Oct 2026 03:22:52 +0530 Subject: [PATCH 04/32] fix(ui): keep the lineage search text when the combobox loses focus Without allowsCustomValue, react-aria resets a combobox with no selected key to an empty input on blur, which cleared the platform lineage entity FQN and fired an empty search. --- .../Entity/EntityLineage/NodeSuggestions.component.tsx | 1 + .../resources/ui/src/pages/PlatformLineage/PlatformLineage.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx index c7780b3246b19..3463d31be3ccf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/NodeSuggestions.component.tsx @@ -189,6 +189,7 @@ const NodeSuggestions: FC = ({ {entityIcon} { return (
Date: Tue, 13 Oct 2026 07:01:15 +0530 Subject: [PATCH 05/32] fix(ui-core): truncate long chips instead of overflowing narrow fields Autocomplete, MultiSelect and TreeSelect chips grew to their label's width (badges are size-max), so a long value spilled past a narrow field. The chip row can now shrink and each chip is capped at the field width, ending the label in an ellipsis. --- .../ui/src/components/application/tree-select/tree-select.tsx | 2 +- .../ui/src/components/base/autocomplete/autocomplete.tsx | 4 +++- .../resources/ui/src/components/base/select/multi-select.tsx | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx index 92773c5a6918f..70b55c2e27a0b 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx @@ -1142,7 +1142,7 @@ export const TreeSelect = ({ multiple && selectedData.map((node) => (

{node.label} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx index 3eb695c748ebb..f3b6a0fca837a 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx @@ -251,13 +251,15 @@ const InnerAutocomplete = ({ : allSelected.length - visibleSelected.length; return ( -

+
{!isSelectionEmpty && visibleSelected.map((item) => context.renderTag ? ( context.renderTag(item, () => context.onRemove(new Set([item.id]))) ) : ( +
{!isSelectionEmpty && comboBoxContext?.selectedItems?.items?.map((value) => ( From 354a5ac7990d9efeccf48fd1621320e956c1a2da Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 08:41:17 +0530 Subject: [PATCH 06/32] chore(ui): refresh lint baselines after rebasing onto main Prunes the suppressions the migration cleared, lowers the pinned antd and positional-locator counts to match, and labels the mocked search input that main's stricter control-has-associated-label rule now checks. --- .../resources/ui/eslint-suppressions.json | 117 ++---------------- .../EntityLineage/CustomNodeV1.test.tsx | 1 + 2 files changed, 12 insertions(+), 106 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 4dd8949192f51..941bcb81c7cf5 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -770,11 +770,6 @@ "count": 3 } }, - "playwright/utils/navbar.ts": { - "om-playwright/no-positional-locator": { - "count": 2 - } - }, "playwright/utils/reviewerWorkflow.utils.ts": { "om-playwright/no-positional-locator": { "count": 1 @@ -1018,21 +1013,11 @@ "count": 3 } }, - "src/components/Database/SampleDataTable/SampleDataTable.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/Database/SampleDataWithMessages/MessageCard.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 } }, - "src/components/Database/SchemaTable/SchemaTable.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/Database/TableQueries/QueryCard.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1063,11 +1048,6 @@ "count": 1 } }, - "src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/Entity/EntityLineage/EdgeInteractionOverlay.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1083,21 +1063,6 @@ "count": 2 } }, - "src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/Entity/EntityLineage/NodeChildren/NodeChildren.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/Entity/EntityLineage/NodeSuggestions.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/Explore/EntitySummaryPanel/APIEndpointSummary/APIEndpointSummary.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1115,7 +1080,7 @@ }, "src/components/GlobalSearchBar/GlobalSearchBar.tsx": { "openmetadata-antd/no-antd-import": { - "count": 3 + "count": 1 } }, "src/components/KnowledgeCenter/KnowledgePageVersion/KnowledgePageVersion.tsx": { @@ -1183,14 +1148,9 @@ "count": 3 } }, - "src/components/MyData/Persona/PersonaSelectableList/PersonaSelectableList.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/components/NavBar/NavBar.tsx": { "openmetadata-antd/no-antd-import": { - "count": 4 + "count": 3 } }, "src/components/NotificationBox/NotificationBox.component.tsx": { @@ -1240,12 +1200,12 @@ }, "src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx": { "openmetadata-antd/no-antd-import": { - "count": 4 + "count": 3 } }, "src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx": { "openmetadata-antd/no-antd-import": { - "count": 5 + "count": 4 } }, "src/components/SearchSettings/FieldValueBoostList/FieldValueBoostList.tsx": { @@ -1260,12 +1220,7 @@ }, "src/components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem.tsx": { "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/components/SearchSettings/SearchPreview/SearchPreview.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 + "count": 1 } }, "src/components/SearchSettings/TermBoost/TermBoost.tsx": { @@ -1305,7 +1260,7 @@ }, "src/components/Settings/Bot/BotDetails/AuthMechanism.tsx": { "openmetadata-antd/no-antd-import": { - "count": 2 + "count": 1 } }, "src/components/Settings/Bot/BotDetails/AuthMechanismForm.test.tsx": { @@ -1320,7 +1275,7 @@ }, "src/components/Settings/Bot/BotDetails/BotDetails.component.tsx": { "openmetadata-antd/no-antd-import": { - "count": 3 + "count": 2 } }, "src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx": { @@ -1365,7 +1320,7 @@ }, "src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsHeadingLabel.component.tsx": { "openmetadata-antd/no-antd-import": { - "count": 4 + "count": 3 } }, "src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx": { @@ -1418,11 +1373,6 @@ "count": 1 } }, - "src/components/Settings/Users/UsersProfile/UserProfileRoles/UserProfileRoles.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/SettingsSso/ProviderSelector/ProviderSelector.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1433,16 +1383,6 @@ "count": 1 } }, - "src/components/SettingsSso/SSOConfigurationForm/SsoConfigurationFormArrayFieldTemplate.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/SettingsSso/SSOConfigurationForm/SsoRolesSelectField.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1483,11 +1423,6 @@ "count": 2 } }, - "src/components/common/CustomUnitSelect/CustomUnitSelect.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/common/DatePicker/DatePicker.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1543,27 +1478,12 @@ "count": 2 } }, - "src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/common/Form/JSONSchema/JsonSchemaWidgets/LdapRoleMappingWidget/LdapRoleMappingWidget.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/common/Form/JSONSchema/JsonSchemaWidgets/ManifestJsonWidget/ManifestJsonWidget.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/components/common/Form/JSONSchema/JsonSchemaWidgets/PasswordWidget.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 } }, - "src/components/common/Form/JSONSchema/JsonSchemaWidgets/SelectWidget.tsx": { + "src/components/common/Form/JSONSchema/JsonSchemaWidgets/ManifestJsonWidget/ManifestJsonWidget.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 } @@ -1600,7 +1520,7 @@ }, "src/components/common/RolesCard/RolesCard.component.tsx": { "openmetadata-antd/no-antd-import": { - "count": 3 + "count": 2 } }, "src/components/common/RolesElement/RolesElement.component.tsx": { @@ -1783,26 +1703,11 @@ "count": 3 } }, - "src/pages/OnlineUsersPage/OnlineUsersPage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 } }, - "src/pages/PlatformLineage/PlatformLineage.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/pages/PlatformLineage/PlatformLineage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1840,7 +1745,7 @@ }, "src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx": { "openmetadata-antd/no-antd-import": { - "count": 3 + "count": 1 } }, "src/pages/RolesPage/AddRolePage/AddRolePage.tsx": { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx index 711723fc280bb..b010fd2ee1090 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomNodeV1.test.tsx @@ -166,6 +166,7 @@ jest.mock('@openmetadata/ui-core-components', () => ({ onChange?: (value: string) => void; }) => ( onChange?.(e.target.value)} From af8dbb29614ea68584e877bd361a970f68dd2352 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 13 Oct 2026 03:12:43 +0000 Subject: [PATCH 07/32] chore(playwright): auto-refresh impact-map.generated.json --- .github/playwright/impact-map.generated.json | 104 +++++-------------- 1 file changed, 26 insertions(+), 78 deletions(-) diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index ae584fa889a24..6f2342f7475cb 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -7254,22 +7254,10 @@ "openmetadata-ui/src/main/resources/ui/src/components/GlobalSearchBar/GlobalSearchBar.tsx" ], "specs": [ - "playwright/e2e/Features/DomainFilterQueryFilter.spec.ts", - "playwright/e2e/Features/ExploreFilterComposition.spec.ts", "playwright/e2e/Features/ExploreQuickFilters.spec.ts", - "playwright/e2e/Features/ExploreUrlState.spec.ts", - "playwright/e2e/Features/GlobalSearchSuggestions.spec.ts", - "playwright/e2e/Features/SearchIndexNestedColumns.spec.ts", - "playwright/e2e/Flow/ExploreAggregationCountsMatching.spec.ts", - "playwright/e2e/Flow/ExploreDiscovery.spec.ts", "playwright/e2e/Flow/GlobalSearch.spec.ts", "playwright/e2e/Flow/MetricSearch.spec.ts", "playwright/e2e/Flow/Navbar.spec.ts", - "playwright/e2e/Flow/Tour.spec.ts", - "playwright/e2e/Pages/CustomProperties.spec.ts", - "playwright/e2e/Pages/ExploreBrowse.spec.ts", - "playwright/e2e/Pages/ExploreTree.spec.ts", - "playwright/e2e/Search/SearchNightly.spec.ts", "playwright/e2e/Search/SearchRelevance.spec.ts" ] }, @@ -8136,56 +8124,13 @@ "playwright/e2e/Pages/SearchSettings.spec.ts" ] }, - { - "sources": [ - "openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem.tsx" - ], - "specs": [ - "playwright/e2e/Pages/Domains.spec.ts", - "playwright/e2e/Pages/SearchSettings.spec.ts" - ] - }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/SearchPreview/SearchPreview.tsx" ], "specs": [ - "playwright/e2e/Features/AIMode/ConnectionServiceDetails.spec.ts", - "playwright/e2e/Features/ColumnSorting.spec.ts", - "playwright/e2e/Features/Container.spec.ts", - "playwright/e2e/Features/DataAssetRulesEnabled.spec.ts", - "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", - "playwright/e2e/Features/DataQuality/TestCaseDetailsPage.spec.ts", - "playwright/e2e/Features/DataQuality/TestCaseSoftDeleteRestore.spec.ts", - "playwright/e2e/Features/Glossary/GlossaryAssets.spec.ts", - "playwright/e2e/Features/ImpactAnalysis.spec.ts", - "playwright/e2e/Features/LandingPageWidgets/DomainDataProductsWidgets.spec.ts", - "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", - "playwright/e2e/Flow/SchemaTable.spec.ts", - "playwright/e2e/Pages/AIProfile/ProfileMembers.spec.ts", - "playwright/e2e/Pages/AIProfile/ProfileTeam.spec.ts", - "playwright/e2e/Pages/Bots.spec.ts", - "playwright/e2e/Pages/BotsProfile.spec.ts", - "playwright/e2e/Pages/DataContracts.spec.ts", - "playwright/e2e/Pages/DataProductAndSubdomains.spec.ts", - "playwright/e2e/Pages/DomainAdvanced.spec.ts", - "playwright/e2e/Pages/DomainUIInteractions.spec.ts", - "playwright/e2e/Pages/Domains.spec.ts", - "playwright/e2e/Pages/InputOutputPorts.spec.ts", "playwright/e2e/Pages/SearchSettings.spec.ts", - "playwright/e2e/Pages/ServiceEntity.spec.ts", - "playwright/e2e/Pages/ServiceListing.spec.ts", - "playwright/e2e/Pages/Teams.spec.ts", - "playwright/e2e/Pages/TestSuite.spec.ts", - "playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts", - "playwright/e2e/Pages/UserDetails.spec.ts", - "playwright/e2e/Pages/Users.spec.ts", "playwright/e2e/Search/SearchRelevance.spec.ts" ] }, @@ -8398,8 +8343,11 @@ "specs": [ "playwright/e2e/Features/AccessControlSettings.spec.ts", "playwright/e2e/Features/IncidentManager.spec.ts", + "playwright/e2e/Features/SSOConfiguration.spec.ts", "playwright/e2e/Features/Tasks/TaskAssigneeManagement.spec.ts", - "playwright/e2e/Pages/CustomProperties.spec.ts" + "playwright/e2e/Flow/PersonaFlow.spec.ts", + "playwright/e2e/Pages/CustomProperties.spec.ts", + "playwright/e2e/Pages/Users.spec.ts" ] }, { @@ -8658,12 +8606,17 @@ "openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx" ], "specs": [ + "playwright/e2e/Features/ContextCenterPermission.spec.ts", + "playwright/e2e/Features/Glossary/GlossaryDisplayNameEdit.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts", + "playwright/e2e/Flow/PersonaFlow.spec.ts", "playwright/e2e/Pages/AIProfile/ProfileMembers.spec.ts", "playwright/e2e/Pages/AIProfile/ProfileUser.spec.ts", "playwright/e2e/Pages/BotsProfile.spec.ts", - "playwright/e2e/Pages/DataContracts.spec.ts" + "playwright/e2e/Pages/DataContracts.spec.ts", + "playwright/e2e/Pages/EditClassification.spec.ts", + "playwright/e2e/Pages/Tags.spec.ts" ] }, { @@ -12756,7 +12709,14 @@ "openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx" ], "specs": [ - "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts" + "playwright/e2e/Features/ContextCenterPermission.spec.ts", + "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts", + "playwright/e2e/Features/Glossary/GlossaryDisplayNameEdit.spec.ts", + "playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts", + "playwright/e2e/Flow/PersonaFlow.spec.ts", + "playwright/e2e/Pages/BotsProfile.spec.ts", + "playwright/e2e/Pages/EditClassification.spec.ts", + "playwright/e2e/Pages/Tags.spec.ts" ] }, { @@ -12764,7 +12724,14 @@ "openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/EditKPIPage.tsx" ], "specs": [ - "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts" + "playwright/e2e/Features/ContextCenterPermission.spec.ts", + "playwright/e2e/Features/DataQuality/ProfilerSettingsAfterReindex.spec.ts", + "playwright/e2e/Features/Glossary/GlossaryDisplayNameEdit.spec.ts", + "playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts", + "playwright/e2e/Flow/PersonaFlow.spec.ts", + "playwright/e2e/Pages/BotsProfile.spec.ts", + "playwright/e2e/Pages/EditClassification.spec.ts", + "playwright/e2e/Pages/Tags.spec.ts" ] }, { @@ -12928,25 +12895,6 @@ "openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx" ], "specs": [ - "playwright/e2e/Features/ContextCenterDocument.spec.ts", - "playwright/e2e/Features/ContextCenterMemories.spec.ts", - "playwright/e2e/Features/DataQuality/CertificationFilter.spec.ts", - "playwright/e2e/Features/DataQuality/DataObservabilityGovernanceTab.spec.ts", - "playwright/e2e/Features/DataQuality/DataQuality.spec.ts", - "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts", - "playwright/e2e/Features/DomainFilterQueryFilter.spec.ts", - "playwright/e2e/Features/ExploreFilterComposition.spec.ts", - "playwright/e2e/Features/ExploreQuickFilters.spec.ts", - "playwright/e2e/Features/ExploreSortOrderFilter.spec.ts", - "playwright/e2e/Features/ExploreUrlState.spec.ts", - "playwright/e2e/Features/IncidentManager.spec.ts", - "playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts", - "playwright/e2e/Features/SearchExport.spec.ts", - "playwright/e2e/Flow/ExploreDiscovery.spec.ts", - "playwright/e2e/Pages/AuditLogs.spec.ts", - "playwright/e2e/Pages/DataContracts.spec.ts", - "playwright/e2e/Pages/DataProductCertificationFilter.spec.ts", - "playwright/e2e/Pages/Lineage/LineageFilters.spec.ts", "playwright/e2e/Pages/Roles.spec.ts", "playwright/e2e/Pages/Tags.spec.ts" ] From 8fce2acd46387f83bc221102cbcbbfcdd7f7936b Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 09:17:29 +0530 Subject: [PATCH 08/32] test(ui): refresh the glossary visual baseline for the core modal, button and search changes The Add Glossary button, description card and navbar search moved to core components (#35226 and this PR), so the old glossary.png no longer matches and the visual-regression job fails in the merge queue. The new baseline is the CI render of this branch. --- .../glossary.png | Bin 100105 -> 104755 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VisualRegression/__snapshots__/staticPages.spec.ts-snapshots/glossary.png b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VisualRegression/__snapshots__/staticPages.spec.ts-snapshots/glossary.png index 5dbc87b207bfee506eab46c4c62897eece3998e3..f5e7a769d9f574cfd661601dcdae920b1c9189b2 100644 GIT binary patch literal 104755 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 Date: Tue, 13 Oct 2026 11:24:07 +0530 Subject: [PATCH 09/32] chore(ui): refresh lint baselines after rebasing onto main --- .../main/resources/ui/eslint-rules/openmetadata-antd.test.mjs | 2 +- .../resources/ui/playwright/eslint-rules/tests/corpus.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs b/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs index 985d45f283cf3..19a30b8e9703b 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/eslint-rules/openmetadata-antd.test.mjs @@ -163,7 +163,7 @@ test('the app config never suppresses a migrated component', async () => { // Exact, so re-running --suppress-rule cannot quietly absorb new antd usage. // Lower this when a PR removes antd imports and prunes the baseline. - const EXPECTED_SUPPRESSED_ANTD_SYMBOLS = 509; + const EXPECTED_SUPPRESSED_ANTD_SYMBOLS = 469; const suppressedSymbols = Object.values(suppressions).reduce( (total, rules) => total + (rules['openmetadata-antd/no-antd-import']?.count ?? 0), diff --git a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs index 81369eec6275a..c3af5b42f086e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs @@ -58,7 +58,7 @@ test('the suppressions baseline matches its recorded state exactly', () => { // fixes from both branches. Scoping the Context Center draft editor to // `.om-block-editor` instead of `.ProseMirror...first()` removes four, and // the Data Contract core-components migration removes three more. - 'om-playwright/no-positional-locator': 584, + 'om-playwright/no-positional-locator': 582, 'om-playwright/require-assertion-per-test': 1, 'playwright/no-skipped-test': 1, 'playwright/no-wait-for-selector': 14, From 0a73427c1c816166b8508133e79e472d117f39c1 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 16:01:09 +0530 Subject: [PATCH 10/32] test(ui): click Customize UI cards by test id The navbar search-index select is now the core Select, which renders a hidden native whose options (Table, Topic, Data Product, ...) match the Customize UI card titles, so page-wide getByText(type, { exact }) lookups resolve to two elements. Give the CustomizeUI grid a test id and scope the card lookups to it. LineageControls still filled the antd .ant-select-selection-search-input; fill the core combobox inside search-entity-select instead. --- .../e2e/Features/CustomizeDetailPage.spec.ts | 22 +++++++++++++++---- .../e2e/Pages/DataContracts.spec.ts | 1 + .../e2e/Pages/Lineage/LineageControls.spec.ts | 6 ++--- .../Persona/CustomizeUI/CustomizeUI.tsx | 1 + 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomizeDetailPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomizeDetailPage.spec.ts index 9649e31856ffe..c972aac60e800 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomizeDetailPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CustomizeDetailPage.spec.ts @@ -202,7 +202,11 @@ test.describe( await adminPage.getByText('Data Assets').click(); for (const type of Object.values(ECustomizedDataAssets)) { - await expect(adminPage.getByText(type, { exact: true })).toBeVisible(); + await expect( + adminPage + .getByTestId('customize-ui-options') + .getByText(type, { exact: true }) + ).toBeVisible(); } }); @@ -212,7 +216,11 @@ test.describe( await adminPage.getByText('Governance').click(); for (const type of Object.values(ECustomizedGovernance)) { - await expect(adminPage.getByText(type, { exact: true })).toBeVisible(); + await expect( + adminPage + .getByTestId('customize-ui-options') + .getByText(type, { exact: true }) + ).toBeVisible(); } }); @@ -420,7 +428,10 @@ test.describe('Persona customization', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await adminPage.getByRole('tab', { name: 'Customize UI' }).click(); await adminPage.getByText('Data Assets').click(); - await adminPage.getByText(type, { exact: true }).click(); + await adminPage + .getByTestId('customize-ui-options') + .getByText(type, { exact: true }) + .click(); await waitForAllLoadersToDisappear(adminPage); @@ -576,7 +587,10 @@ test.describe('Persona customization', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { ); await adminPage.getByRole('tab', { name: 'Customize UI' }).click(); await adminPage.getByText('Governance').click(); - await adminPage.getByText(type, { exact: true }).click(); + await adminPage + .getByTestId('customize-ui-options') + .getByText(type, { exact: true }) + .click(); await waitForAllLoadersToDisappear(adminPage); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts index be98036019b75..2a3c3cb889144 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts @@ -2535,6 +2535,7 @@ dataContractEntities.forEach((EntityClass) => { .getByText('Data Assets') .click(); await page + .getByTestId('customize-ui-options') .getByText(entityName[entityType] ?? entityType, { exact: true, }) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts index e2d23737269d9..44eae14324ebb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts @@ -211,10 +211,8 @@ test.describe('Lineage Layers', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await searchSelect.click(); - await page - .locator( - '[data-testid="search-entity-select"] .ant-select-selection-search-input' - ) + await searchSelect + .getByRole('combobox') .fill('invalid_fqn_does_not_exist_12345'); const noResultsText = page.getByText(/no match/i); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Persona/CustomizeUI/CustomizeUI.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Persona/CustomizeUI/CustomizeUI.tsx index 72c04cadd8d70..91f4461411322 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Persona/CustomizeUI/CustomizeUI.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Persona/CustomizeUI/CustomizeUI.tsx @@ -69,6 +69,7 @@ export const CustomizeUI = () => { return ( {items.map((value) => ( From c67392786453a26c1e095bebc735cdbf57cc05d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 13 Oct 2026 10:47:19 +0000 Subject: [PATCH 12/32] chore(playwright): auto-refresh impact-map.generated.json --- .github/playwright/impact-map.generated.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 6f2342f7475cb..8ec9320b680bc 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -8375,6 +8375,15 @@ "playwright/e2e/Features/ProfilePlatformSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/Settings/Persona/CustomizeUI/CustomizeUI.tsx" + ], + "specs": [ + "playwright/e2e/Features/CustomizeDetailPage.spec.ts", + "playwright/e2e/Pages/DataContracts.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/AddIngestion.component.tsx" From e0d0166622abf152688b8d95258e8a5b15538792 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 16:18:37 +0530 Subject: [PATCH 13/32] test(ui): pick the Data Quality "All" test type from the open dropdown getByTitle('All').nth(1) relied on the antd navbar search-index select contributing the first title="All"; with the core Select there is only one, so nth(1) never resolved and the TestCase filters test timed out. Scope the click to the open antd dropdown instead, and prune the now unused positional-locator suppression. --- .../src/main/resources/ui/eslint-suppressions.json | 5 ----- .../playwright/e2e/Features/DataQuality/DataQuality.spec.ts | 6 +++++- .../ui/playwright/eslint-rules/tests/corpus.test.mjs | 6 ++++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 941bcb81c7cf5..a049ce6cf1bc8 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -127,11 +127,6 @@ "count": 2 } }, - "playwright/e2e/Features/DataQuality/DataQuality.spec.ts": { - "om-playwright/no-positional-locator": { - "count": 1 - } - }, "playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts": { "om-playwright/no-positional-locator": { "count": 1 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts index 13222cf633ce9..8a83d27da7a2c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts @@ -1305,7 +1305,11 @@ test.describe( `/api/v1/dataQuality/testCases/search/list?*testCaseType=all*` ); await page.getByTestId('test-case-type-select-filter').click(); - await page.getByTitle('All').nth(1).click(); + await page + .locator('.ant-select-dropdown') + .filter({ hasNot: page.locator('.ant-select-dropdown-hidden') }) + .getByTitle('All', { exact: true }) + .click(); await testCaseTypeByAll; // Test case filter by status diff --git a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs index c3af5b42f086e..e5fb895449076 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs @@ -57,8 +57,10 @@ test('the suppressions baseline matches its recorded state exactly', () => { // remove three more. The merged baseline retains independent locator // fixes from both branches. Scoping the Context Center draft editor to // `.om-block-editor` instead of `.ProseMirror...first()` removes four, and - // the Data Contract core-components migration removes three more. - 'om-playwright/no-positional-locator': 582, + // the Data Contract core-components migration removes three more. The + // Data Quality test-type filter picks "All" from the open dropdown + // instead of `getByTitle('All').nth(1)`. + 'om-playwright/no-positional-locator': 581, 'om-playwright/require-assertion-per-test': 1, 'playwright/no-skipped-test': 1, 'playwright/no-wait-for-selector': 14, From 0db31d1de9c78f67f4e61f4b4bfe9284220e44dc Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 07:29:38 +0530 Subject: [PATCH 14/32] feat(ui): move simple modal forms from antd Form to core HookForm ProfileEditModal, LineageConfigModal, TestEmail, ChangePasswordForm, ModalWithQueryEditor and ModalWithFunctionEditor now use react-hook-form with core FormFields/FormField and core Input/PasswordInput. Core: FieldPropsMap gains inputDataTestId so Input-based fields keep test ids on the inner input, and PasswordInput forwards inputDataTestId. --- .../form-field/form-field.types.ts | 2 + .../components/base/input/password-input.tsx | 2 + .../EntityLineage/LineageConfigModal.tsx | 143 ++++++++++------- .../ModalWithFunctionEditor.tsx | 68 ++++---- .../ModalWithQueryEditor.tsx | 75 +++++---- .../ProfileEditModal/ProfileEditModal.tsx | 52 +++--- .../Email/TestEmail/TestEmail.component.tsx | 72 ++++++--- .../Settings/Users/ChangePasswordForm.tsx | 148 +++++++++--------- 8 files changed, 329 insertions(+), 233 deletions(-) diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/form-field/form-field.types.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/form-field/form-field.types.ts index 13f9210ce92a2..e9b0713e8c261 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/form-field/form-field.types.ts +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/form-field/form-field.types.ts @@ -91,6 +91,8 @@ export interface FieldPropsMap { children?: ReactNode; colors?: string[]; 'data-testid'?: string; + /** TEXT / PASSWORD / NUMBER only: test id on the inner ``, not the wrapper. */ + inputDataTestId?: string; defaultCamera?: 'environment' | 'user'; defaultIcon?: { component: FC }; disabled?: boolean; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/password-input.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/password-input.tsx index de66c49e9b216..56aee548d9c5b 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/password-input.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/input/password-input.tsx @@ -49,6 +49,7 @@ export const PasswordInput = forwardRef< inputClassName, wrapperClassName, tooltipClassName, + inputDataTestId, // Everything else (value, onChange, isRequired, isDisabled, isInvalid, id, etc.) // goes into ...props so they reach TextField/AriaTextField ...props @@ -133,6 +134,7 @@ export const PasswordInput = forwardRef< groupRef={groupRef} iconClassName={iconClassName} inputClassName={cx('tw:pr-9', inputClassName)} + inputDataTestId={inputDataTestId} isDisabled={isDisabled} isInvalid={isInvalid} isRequired={isRequired} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx index 0352a88a90e02..f656f4590a84d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx @@ -10,12 +10,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SimpleModal } from '@openmetadata/ui-core-components'; -import { Form, InputNumber } from 'antd'; +import { + FieldProp, + FieldTypes, + FormFields, + HelperTextType, + HookForm, + SimpleModal +} from '@openmetadata/ui-core-components'; import React from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; -import { LineageConfigModalProps } from './EntityLineage.interface'; +import { + LineageConfig, + LineageConfigModalProps +} from './EntityLineage.interface'; + +type DepthField = 'upstreamDepth' | 'downstreamDepth' | 'nodesPerLayer'; + +// Number inputs hold strings; the config stores numbers. +type LineageConfigFormValues = Record; const LineageConfigModal: React.FC = ({ visible, @@ -24,7 +38,66 @@ const LineageConfigModal: React.FC = ({ onSave, }) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { + upstreamDepth: String(config.upstreamDepth ?? ''), + downstreamDepth: String(config.downstreamDepth ?? ''), + nodesPerLayer: String(config.nodesPerLayer ?? ''), + }, + }); + + const getNumberField = ( + name: DepthField, + label: string, + tooltip: string, + min: number, + testId: string + ): FieldProp => ({ + name, + label, + type: FieldTypes.NUMBER, + required: true, + helperText: tooltip, + helperTextType: HelperTextType.TOOLTIP, + props: { inputDataTestId: testId }, + rules: { + required: t('message.field-text-is-required', { fieldText: label }), + validate: (value) => + Number(value) >= min || + t('message.entity-size-less-than', { entity: label, min }), + }, + }); + + const fields: FieldProp[] = [ + getNumberField( + 'upstreamDepth', + t('label.upstream-depth'), + t('message.upstream-depth-tooltip'), + 0, + 'field-upstream' + ), + getNumberField( + 'downstreamDepth', + t('label.downstream-depth'), + t('message.downstream-depth-tooltip'), + 0, + 'field-downstream' + ), + getNumberField( + 'nodesPerLayer', + t('label.nodes-per-layer'), + t('message.nodes-per-layer-tooltip'), + 5, + 'field-nodes-per-layer' + ), + ]; + + const handleSave = (values: LineageConfigFormValues) => + onSave({ + upstreamDepth: Number(values.upstreamDepth), + downstreamDepth: Number(values.downstreamDepth), + nodesPerLayer: Number(values.nodesPerLayer), + } as LineageConfig); return ( = ({ isOpen={visible} title={t('label.lineage-config')} onCancel={onCancel} - onOk={form.submit}> -

+ - - - - - - - - - - - - + onSubmit={form.handleSubmit(handleSave)}> + +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithFunctionEditor/ModalWithFunctionEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithFunctionEditor/ModalWithFunctionEditor.tsx index 57c6e1f343e82..ab81474150dc9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithFunctionEditor/ModalWithFunctionEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithFunctionEditor/ModalWithFunctionEditor.tsx @@ -11,15 +11,18 @@ * limitations under the License. */ import { - Button, - Dialog, - Modal, - ModalOverlay, + Box, + Button, + Dialog, + FieldTypes, + FormFields, + HookForm, + Modal, + ModalOverlay } from '@openmetadata/ui-core-components'; -import { Form, Input } from 'antd'; -import { FormProps, useForm } from 'antd/lib/form/Form'; import { AxiosError } from 'axios'; import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../../common/Loader/Loader'; @@ -33,11 +36,13 @@ export const ModalWithFunctionEditor = ({ visible, }: ModalWithFunctionEditorProps) => { const { t } = useTranslation(); - const [form] = useForm(); + const form = useForm<{ functionValue: string }>({ + defaultValues: { functionValue: '' }, + }); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const onFinish: FormProps['onFinish'] = async (values) => { + const onFinish = async (values: { functionValue: string }) => { setIsSaving(true); try { await onSave?.(values.functionValue ?? ''); @@ -50,7 +55,7 @@ export const ModalWithFunctionEditor = ({ useEffect(() => { if (visible) { - form.setFieldsValue({ functionValue: value }); + form.reset({ functionValue: value ?? '' }); setIsLoading(false); } }, [form, visible]); @@ -74,28 +79,29 @@ export const ModalWithFunctionEditor = ({ {isLoading ? ( ) : ( -
- - + + - -
+ + )} @@ -111,7 +117,7 @@ export const ModalWithFunctionEditor = ({ color="primary" data-testid="save" key="saveButton" - onClick={() => form.submit()}> + onClick={form.handleSubmit(onFinish)}> {isSaving ? ( ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithQueryEditor/ModalWithQueryEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithQueryEditor/ModalWithQueryEditor.tsx index e6680bbfe8b24..298e8da3d6d54 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithQueryEditor/ModalWithQueryEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithQueryEditor/ModalWithQueryEditor.tsx @@ -11,15 +11,19 @@ * limitations under the License. */ import { - Button, - Dialog, - Modal, - ModalOverlay, + Box, + Button, + Dialog, + FormField, + FormItemLabel, + HintText, + HookForm, + Modal, + ModalOverlay } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; -import { FormProps, useForm } from 'antd/lib/form/Form'; import { AxiosError } from 'axios'; import { lazy, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { CSMode } from '../../../enums/codemirror.enum'; import { showErrorToast } from '../../../utils/ToastUtils'; @@ -39,11 +43,11 @@ export const ModalWithQueryEditor = ({ visible, }: ModalWithQueryEditorProps) => { const { t } = useTranslation(); - const [form] = useForm(); + const form = useForm<{ query: string }>({ defaultValues: { query: '' } }); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const onFinish: FormProps['onFinish'] = async (value) => { + const onFinish = async (value: { query: string }) => { setIsSaving(true); try { await onSave?.(value.query ?? ''); @@ -56,7 +60,7 @@ export const ModalWithQueryEditor = ({ useEffect(() => { if (visible) { - form.setFieldsValue({ query: value }); + form.reset({ query: value ?? '' }); setIsLoading(false); } }, [form, visible]); @@ -80,27 +84,38 @@ export const ModalWithQueryEditor = ({ {isLoading ? ( ) : ( -
- + - - -
+ rules={{ + required: t('label.field-required', { + field: t('label.sql-uppercase-query'), + }), + }}> + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + )} @@ -116,7 +131,7 @@ export const ModalWithQueryEditor = ({ color="primary" data-testid="save" key="saveButton" - onClick={() => form.submit()}> + onClick={form.handleSubmit(onFinish)}> {isSaving ? ( ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ProfileEditModal/ProfileEditModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ProfileEditModal/ProfileEditModal.tsx index 685e87af6d026..a69374d1089d4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ProfileEditModal/ProfileEditModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ProfileEditModal/ProfileEditModal.tsx @@ -11,10 +11,16 @@ * limitations under the License. */ -import { SimpleModal } from '@openmetadata/ui-core-components'; -import { Form, FormProps, Input } from 'antd'; +import { + FieldProp, + FieldTypes, + FormFields, + HookForm, + SimpleModal +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; -import { FunctionComponent, useEffect, useState } from 'react'; +import { FunctionComponent, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { User } from '../../../generated/entity/teams/user'; import { showErrorToast } from '../../../utils/ToastUtils'; @@ -25,18 +31,24 @@ interface ProfileEditModalProps { updateUserDetails: (data: Partial, key: keyof User) => Promise; } +interface ProfileEditFormValues { + displayName: string; +} + export const ProfileEditModal: FunctionComponent = ({ userData, onCancel, updateUserDetails, }: ProfileEditModalProps) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { displayName: userData.displayName ?? '' }, + }); const [isLoading, setIsLoading] = useState(false); - const handleSaveData: FormProps['onFinish'] = async ({ + const handleSaveData = async ({ displayName, - }): Promise => { + }: ProfileEditFormValues): Promise => { setIsLoading(true); try { await updateUserDetails({ displayName }, 'displayName'); @@ -48,9 +60,15 @@ export const ProfileEditModal: FunctionComponent = ({ } }; - useEffect(() => { - form.setFieldsValue({ displayName: userData.displayName }); - }, [userData.displayName]); + const fields: FieldProp[] = [ + { + name: 'displayName', + label: t('label.display-name'), + type: FieldTypes.TEXT, + placeholder: t('label.display-name'), + props: { inputDataTestId: 'displayName-input' }, + }, + ]; return ( = ({ })} width={500} onCancel={onCancel} - onOk={form.submit}> -
+ - - - - + onSubmit={form.handleSubmit(handleSaveData)}> + +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/TestEmail/TestEmail.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/TestEmail/TestEmail.component.tsx index f06c9785167ae..fa8c687c62b4b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/TestEmail/TestEmail.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/TestEmail/TestEmail.component.tsx @@ -10,12 +10,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SimpleModal } from '@openmetadata/ui-core-components'; -import { Form, FormProps, Input } from 'antd'; +import { + FormField, + HookForm, + Input, + SimpleModal +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../../constants/constants'; +import { EMAIL_REG_EX } from '../../../../constants/regex.constants'; import { testEmailConnection } from '../../../../rest/settingConfigAPI'; import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils'; @@ -23,12 +28,16 @@ interface TesEmailProps { onCancel: () => void; } +interface TestEmailFormValues { + email: string; +} + const TestEmail = ({ onCancel }: TesEmailProps) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ defaultValues: { email: '' } }); const [isLoading, setIsLoading] = useState(false); - const handleSubmit: FormProps['onFinish'] = async (values) => { + const handleSubmit = async (values: TestEmailFormValues) => { try { setIsLoading(true); const res = await testEmailConnection(values); @@ -50,28 +59,45 @@ const TestEmail = ({ onCancel }: TesEmailProps) => { okText={t('label.test')} title={t('label.test-email-connection')} onCancel={onCancel} - onOk={form.submit}> -
+ - + - - - + rules={{ + required: t('message.field-text-is-required', { + fieldText: t('label.email'), + }), + pattern: { + value: EMAIL_REG_EX, + message: t('message.entity-is-not-valid', { + entity: t('label.email'), + }), + }, + }}> + {({ field, fieldState }) => ( + + )} + +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/ChangePasswordForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/ChangePasswordForm.tsx index 04d295dbd807b..8bd387da75894 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/ChangePasswordForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/ChangePasswordForm.tsx @@ -11,11 +11,16 @@ * limitations under the License. */ -import { SimpleModal } from '@openmetadata/ui-core-components'; -import { Form, Input } from 'antd'; +import { + Box, + FormField, + HookForm, + PasswordInput, + SimpleModal +} from '@openmetadata/ui-core-components'; import React from 'react'; +import { RegisterOptions, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; import { passwordRegex } from '../../../constants/regex.constants'; import { ChangePasswordRequest } from '../../../generated/auth/changePasswordRequest'; @@ -27,6 +32,8 @@ interface ChangePasswordFormProps { isLoading: boolean; } +type PasswordField = 'oldPassword' | 'newPassword' | 'confirmPassword'; + const ChangePasswordForm: React.FC = ({ visible, onCancel, @@ -35,8 +42,37 @@ const ChangePasswordForm: React.FC = ({ isLoading, }) => { const { t } = useTranslation(); - const [form] = Form.useForm(); - const newPassword = Form.useWatch('newPassword', form); + // Unregister unmounted fields so a hidden old-password field is not sent. + const form = useForm({ shouldUnregister: true }); + const handleSave = form.handleSubmit((data) => onSave(data)); + + const renderPasswordField = ( + name: PasswordField, + label: string, + placeholder: string, + testId: string, + rules: RegisterOptions + ) => ( + + {({ field, fieldState }) => ( + + )} + + ); + + const requiredRule = (label: string) => + t('message.field-text-is-required', { fieldText: label }); return ( = ({ })} width={500} onCancel={() => { - form.resetFields(); + form.reset(); onCancel(); }} - onOk={form.submit}> -
+ - {isLoggedInUser && ( - - - - )} - + + {isLoggedInUser && + renderPasswordField( + 'oldPassword', + t('label.old-password'), + t('label.enter-type-password', { type: t('label.old') }), + 'input-oldPassword', + { required: requiredRule(t('label.old-password')) } + )} + {renderPasswordField( + 'newPassword', + t('label.new-password'), + t('label.enter-type-password', { type: t('label.new') }), + 'input-newPassword', { - pattern: passwordRegex, - message: t('message.password-error-message'), - }, - ]}> - - - + new RegExp(passwordRegex.source).test(value ?? '') || + t('message.password-error-message'), + } + )} + {renderPasswordField( + 'confirmPassword', + t('label.confirm-new-password'), + t('label.confirm-new-password'), + 'input-confirm-newPassword', { - validator: (_, value) => { - if (value !== newPassword) { - return Promise.reject(t('label.password-not-match')); - } - - return Promise.resolve(); - }, - }, - ]}> - - - + validate: (value, values) => + value === values.newPassword || t('label.password-not-match'), + } + )} + +
); }; From 65286e5475fc21e567159c4db946d39d9f4b331f Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 07:38:28 +0530 Subject: [PATCH 15/32] feat(ui): move config, policy, rule and role forms to core HookForm - Login/URL configuration pages use core FormFields/Input; numbers are converted on submit, and doc-panel focus tracking moves to a wrapper. - Add policy, add rule and edit rule reuse the core AccessControlRuleForm; the classic antd RuleForm is deleted. - Add role uses a core Input and an Autocomplete for policies. - Form.constants gains react-hook-form name/display-name rules. - Policies and Roles specs drive the core controls. --- .../ui/playwright/e2e/Pages/Policies.spec.ts | 87 ++--- .../ui/playwright/e2e/Pages/Roles.spec.ts | 63 ++- .../ui/src/constants/Form.constants.ts | 34 ++ .../EditLoginConfigurationPage.tsx | 148 ++++--- .../EditUrlConfigurationPage.tsx | 129 +++--- .../AddPolicyPage/AddPolicyPage.tsx | 134 ++++--- .../PoliciesDetailPage/AddRulePage.test.tsx | 8 +- .../PoliciesDetailPage/AddRulePage.tsx | 50 +-- .../PoliciesDetailPage/EditRulePage.test.tsx | 8 +- .../PoliciesDetailPage/EditRulePage.tsx | 66 +--- .../PoliciesPage/RuleForm/RuleForm.test.tsx | 92 ----- .../pages/PoliciesPage/RuleForm/RuleForm.tsx | 368 ------------------ .../RolesPage/AddRolePage/AddRolePage.tsx | 169 ++++---- 13 files changed, 475 insertions(+), 881 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts index a7cfa4fa89df3..81b3f6bc4e7c3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts @@ -13,32 +13,32 @@ import test, { expect, Page } from '@playwright/test'; import { PLAYWRIGHT_BASIC_TEST_TAG_OBJ } from '../../constant/config'; import { - DEFAULT_POLICIES, - DESCRIPTION, - ERROR_MESSAGE_VALIDATION, - NEW_RULE_DESCRIPTION, - NEW_RULE_NAME, - POLICY_NAME, - RULE_DESCRIPTION, - RULE_DETAILS, - RULE_NAME, - UPDATED_DESCRIPTION, - UPDATED_POLICY_NAME, - UPDATED_RULE_NAME, + DEFAULT_POLICIES, + DESCRIPTION, + ERROR_MESSAGE_VALIDATION, + NEW_RULE_DESCRIPTION, + NEW_RULE_NAME, + POLICY_NAME, + RULE_DESCRIPTION, + RULE_DETAILS, + RULE_NAME, + UPDATED_DESCRIPTION, + UPDATED_POLICY_NAME, + UPDATED_RULE_NAME } from '../../constant/permission'; import { GlobalSettingOptions } from '../../constant/settings'; import { - PolicyClass, - PolicyRulesType, + PolicyClass, + PolicyRulesType } from '../../support/access-control/PoliciesClass'; import { - closeFirstPopupAlert, - descriptionBox, - fillDescriptionBox, - getApiContext, - getDescriptionBox, - redirectToHomePage, - toastNotification, + closeFirstPopupAlert, + descriptionBox, + fillDescriptionBox, + getApiContext, + getDescriptionBox, + redirectToHomePage, + toastNotification } from '../../utils/common'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { validateFormNameFieldInput } from '../../utils/form'; @@ -54,8 +54,7 @@ const addRule = async ( ruleDescription: string, descriptionIndex: number ) => { - // Validate form name field input - await page.fill('[data-testid="rule-name"]', rulename); + await page.getByTestId('rule-name').getByRole('textbox').fill(rulename); // Enter rule description await page @@ -63,36 +62,25 @@ const addRule = async ( .nth(descriptionIndex) .fill(ruleDescription); - // Select resource dropdown - await page.locator('[data-testid="resources"]').click(); + // Select "All" resources, then the first operation for them + const resources = page.getByTestId('resources'); + await resources.getByRole('combobox').click(); + await page.getByRole('option', { name: 'All', exact: true }).click(); + await resources.getByRole('combobox').press('Escape'); - // Select All - await page.locator('.ant-select-tree-checkbox-inner').first().click(); + const operations = page.getByTestId('operations'); + await operations.getByRole('combobox').click(); + await page.getByRole('option', { name: 'All', exact: true }).click(); + await operations.getByRole('combobox').press('Escape'); - // Click on operations dropdown - await page.locator('[data-testid="operations"]').click(); - - // Select operation - await page.locator('.ant-select-tree-checkbox-inner').nth(1).click(); - - // Click on condition combobox - await page.locator('[data-testid="condition"]').click(); - - // Select condition. The listener goes up first because typing the condition validates it too, - // so the response can arrive before the option is clicked. + // Typing the condition validates it, so the listener goes up first. const conditionResponse = page.waitForResponse( '/api/v1/policies/validation/condition/*' ); - - // Type the condition to filter the list. The options are built from every policy function's - // examples and the dropdown is virtualized, so an option far enough down the list is not in the - // DOM to be clicked - which is what happens whenever a new function is added. await page - .locator( - '[data-testid="condition"] > .ant-select-selector .ant-select-selection-search-input' - ) + .getByTestId('condition') + .getByRole('combobox') .fill(RULE_DETAILS.condition); - await page.locator(`[title="${RULE_DETAILS.condition}"]`).click(); await conditionResponse; // Verify condition success @@ -137,7 +125,7 @@ test.describe( ).toBeVisible(); // Enter policy name - await page.locator('[data-testid="policy-name"]').fill(POLICY_NAME); + await page.getByTestId('policy-name').fill(POLICY_NAME); await validateFormNameFieldInput({ page, value: POLICY_NAME, @@ -268,7 +256,10 @@ test.describe( await page.locator('[data-testid="edit-rule"]').click(); // Enter new name - await page.locator('[data-testid="rule-name"]').fill(UPDATED_RULE_NAME); + await page + .getByTestId('rule-name') + .getByRole('textbox') + .fill(UPDATED_RULE_NAME); // Click on submit button await page.locator('[data-testid="submit-btn"]').click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts index 77cd7ebe23a09..f536a322c0cdd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts @@ -16,16 +16,16 @@ import { GlobalSettingOptions } from '../../constant/settings'; import { RolesClass } from '../../support/access-control/RolesClass'; import { expect, test } from '../../support/fixtures/base'; import { - getApiContext, - getDescriptionBox, - redirectToHomePage, - toastNotification, - uuid, + getApiContext, + getDescriptionBox, + redirectToHomePage, + toastNotification, + uuid } from '../../utils/common'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { - getElementWithPagination, - removePolicyFromRole, + getElementWithPagination, + removePolicyFromRole } from '../../utils/roles'; import { settingClick } from '../../utils/sidebar'; import { waitForResponseWithStatus } from '../../utils/waitHelpers'; @@ -91,30 +91,21 @@ test.describe('Roles page tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await descriptionField.fill(description); // Select the policies - search and select from dropdown - const policiesDropdown = page.locator('[data-testid="policies"]'); - await expect(policiesDropdown).toBeVisible(); - await policiesDropdown.click(); - await policiesDropdown.locator('input').fill('Data'); - const dataConsumerOption = page - .locator('.ant-select-dropdown:visible') - .locator('[title="Data Consumer Policy"]'); - await expect(dataConsumerOption).toBeVisible(); - await dataConsumerOption.click(); - - await policiesDropdown.locator('input').fill('Data'); - const dataStewardOption = page - .locator('.ant-select-dropdown:visible') - .locator('[title="Data Steward Policy"]'); - await expect(dataStewardOption).toBeVisible(); - await dataStewardOption.click(); - - // close dropdown by clicking side panel - await page.getByText('Add Role').click(); - - // Wait for dropdown to close - await expect( - page.locator('.ant-select-dropdown:visible') - ).not.toBeVisible(); + const policiesInput = page + .getByTestId('policies') + .getByRole('combobox'); + await policiesInput.fill('Data'); + await page + .getByRole('option', { name: 'Data Consumer Policy', exact: true }) + .click(); + await policiesInput.fill('Data'); + await page + .getByRole('option', { name: 'Data Steward Policy', exact: true }) + .click(); + + // Close the list so it does not cover the footer + await policiesInput.press('Escape'); + await expect(page.getByRole('listbox')).not.toBeVisible(); // Save the role - wait for API response and UI update const submitButton = page.locator('[data-testid="submit-btn"]'); @@ -262,12 +253,10 @@ test.describe('Roles page tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); - // Verify the error message that is displayed - wait for alert to appear - const errorAlert = page.locator('[role="alert"]'); - await expect(errorAlert).toBeVisible(); - await expect(errorAlert).toContainText( - errorMessageValidation.ifPolicyNotSelected - ); + // Verify the validation message under the policies field + await expect( + page.getByText(errorMessageValidation.ifPolicyNotSelected) + ).toBeVisible(); }); await test.step('Edit created role', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Form.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Form.constants.ts index c015bb6eaab43..ac7377a29e811 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Form.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Form.constants.ts @@ -49,3 +49,37 @@ export const DISPLAY_NAME_FIELD_RULES: Rule[] = [ }), }, ]; + +const NAME_SIZE_MESSAGE = String( + i18n.t('message.entity-size-in-between', { + entity: i18n.t('label.name'), + min: 1, + max: 128, + }) +); + +/** react-hook-form counterpart of NAME_FIELD_RULES. */ +export const NAME_FIELD_REGISTER_RULES = { + required: String( + i18n.t('label.field-required', { field: i18n.t('label.name') }) + ), + maxLength: { value: 128, message: NAME_SIZE_MESSAGE }, + pattern: { + value: ENTITY_NAME_REGEX, + message: String(i18n.t('message.entity-name-validation')), + }, +}; + +/** react-hook-form counterpart of DISPLAY_NAME_FIELD_RULES. */ +export const DISPLAY_NAME_FIELD_REGISTER_RULES = { + maxLength: { + value: 128, + message: String( + i18n.t('message.entity-size-in-between', { + entity: i18n.t('label.display-name'), + min: 1, + max: 128, + }) + ), + }, +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditLoginConfiguration/EditLoginConfigurationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditLoginConfiguration/EditLoginConfigurationPage.tsx index 6de47087ac71b..b5032ef343ed1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditLoginConfiguration/EditLoginConfigurationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditLoginConfiguration/EditLoginConfigurationPage.tsx @@ -10,42 +10,61 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Box, Button } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; +import { + Box, + Button, + FieldProp, + FieldTypes, + FormFields, + HookForm +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; +import { isEmpty } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../../components/common/Loader/Loader'; import ResizablePanels from '../../../components/common/ResizablePanels/ResizablePanels'; import ServiceDocPanel from '../../../components/common/ServiceDocPanel/ServiceDocPanel'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; import { - GlobalSettingOptions, - GlobalSettingsMenuCategory, + GlobalSettingOptions, + GlobalSettingsMenuCategory } from '../../../constants/GlobalSettings.constants'; import { - CUSTOM_LOGIN_CONFIG_SERVICE_CATEGORY, - OPEN_METADATA, + CUSTOM_LOGIN_CONFIG_SERVICE_CATEGORY, + OPEN_METADATA } from '../../../constants/service-guide.constant'; import { ServiceCategory } from '../../../enums/service.enum'; import { LoginConfiguration } from '../../../generated/configuration/loginConfiguration'; import { Settings, SettingType } from '../../../generated/settings/settings'; import { withPageLayout } from '../../../hoc/withPageLayout'; -import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { - getLoginConfig, - updateSettingsConfig, + getLoginConfig, + updateSettingsConfig } from '../../../rest/settingConfigAPI'; -import { generateFormFields } from '../../../utils/formUtils'; import { getSettingPath } from '../../../utils/RouterUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; +type LoginConfigField = keyof Pick< + LoginConfiguration, + 'maxLoginFailAttempts' | 'accessBlockTime' | 'jwtTokenExpiryTime' +>; + +// Number inputs hold strings; the configuration stores numbers. +type LoginConfigFormValues = Record; + +const LOGIN_CONFIG_FIELDS: LoginConfigField[] = [ + 'maxLoginFailAttempts', + 'accessBlockTime', + 'jwtTokenExpiryTime', +]; + const EditLoginConfiguration = () => { const navigate = useNavigate(); const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm(); const [activeField, setActiveField] = useState(''); const [loading, setLoading] = useState(false); const [updating, setUpdating] = useState(false); @@ -56,7 +75,11 @@ const EditLoginConfiguration = () => { const data = await getLoginConfig(); - form.setFieldsValue({ ...(data ?? {}) }); + form.reset( + Object.fromEntries( + LOGIN_CONFIG_FIELDS.map((key) => [key, String(data?.[key] ?? '')]) + ) as LoginConfigFormValues + ); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -87,52 +110,49 @@ const EditLoginConfiguration = () => { [] ); + const minZeroRule = (label: string) => ({ + validate: (value: string) => + isEmpty(value) || + Number(value) >= 0 || + t('message.entity-size-less-than', { entity: label, min: 0 }), + }); + const formFields: FieldProp[] = [ { name: 'maxLoginFailAttempts', label: t('label.max-login-fail-attempt-plural'), type: FieldTypes.NUMBER, - required: false, id: 'root/maxLoginFailAttempts', - props: { - 'data-testid': 'maxLoginFailAttempts', - size: 'default', - style: { width: '100%' }, - autoFocus: true, - }, - rules: [{ min: 0, type: 'number' }], + props: { inputDataTestId: 'maxLoginFailAttempts' }, + rules: minZeroRule(t('label.max-login-fail-attempt-plural')), }, { name: 'accessBlockTime', label: t('label.access-block-time'), type: FieldTypes.NUMBER, - required: false, id: 'root/accessBlockTime', - props: { - 'data-testid': 'accessBlockTime', - size: 'default', - style: { width: '100%' }, - }, - rules: [{ min: 0, type: 'number' }], + props: { inputDataTestId: 'accessBlockTime' }, + rules: minZeroRule(t('label.access-block-time')), }, { name: 'jwtTokenExpiryTime', label: t('label.jwt-token-expiry-time'), type: FieldTypes.NUMBER, - required: false, id: 'root/jwtTokenExpiryTime', - props: { - 'data-testid': 'jwtTokenExpiryTime', - size: 'default', - style: { width: '100%' }, - }, - rules: [{ min: 0, type: 'number' }], + props: { inputDataTestId: 'jwtTokenExpiryTime' }, + rules: minZeroRule(t('label.jwt-token-expiry-time')), }, ]; const handleGoBack = () => navigate(-1); - const handleSubmit = async (configValues: LoginConfiguration) => { + const handleSubmit = async (formValues: LoginConfigFormValues) => { + const configValues = Object.fromEntries( + LOGIN_CONFIG_FIELDS.map((key) => [ + key, + isEmpty(formValues[key]) ? undefined : Number(formValues[key]), + ]) + ) as LoginConfiguration; try { setUpdating(true); const configData = { @@ -160,39 +180,41 @@ const EditLoginConfiguration = () => { const firstPanelChildren = ( <> -
{ e.preventDefault(); e.stopPropagation(); setActiveField(e.target.id); }}> - {generateFormFields(formFields)} - - - - - - + + + + + + + + + - - + +
); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditUrlConfiguration/EditUrlConfigurationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditUrlConfiguration/EditUrlConfigurationPage.tsx index d9393b0ee82ae..8088698ff1c49 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditUrlConfiguration/EditUrlConfigurationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Configuration/EditUrlConfiguration/EditUrlConfigurationPage.tsx @@ -10,10 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Box, Button } from '@openmetadata/ui-core-components'; -import { Form, Input } from 'antd'; +import { + Box, + Button, + FormField, + HookForm, + Input +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../../components/common/Loader/Loader'; @@ -21,14 +27,13 @@ import ResizablePanels from '../../../components/common/ResizablePanels/Resizabl import ServiceDocPanel from '../../../components/common/ServiceDocPanel/ServiceDocPanel'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import SettingsSourceBanner from '../../../components/platform/settings/SettingsSourceBanner/SettingsSourceBanner'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; import { - GlobalSettingOptions, - GlobalSettingsMenuCategory, + GlobalSettingOptions, + GlobalSettingsMenuCategory } from '../../../constants/GlobalSettings.constants'; import { - OPENMETADATA_URL_CONFIG_SERVICE_CATEGORY, - OPEN_METADATA, + OPENMETADATA_URL_CONFIG_SERVICE_CATEGORY, + OPEN_METADATA } from '../../../constants/service-guide.constant'; import { ServiceCategory } from '../../../enums/service.enum'; import { OpenMetadataBaseURLConfiguration } from '../../../generated/configuration/openMetadataBaseUrlConfiguration'; @@ -36,23 +41,24 @@ import { Settings, SettingType } from '../../../generated/settings/settings'; import { withPageLayout } from '../../../hoc/withPageLayout'; import { useSettingsSource } from '../../../hooks/platform/useSettingsSource'; import { - getSettingsConfigFromConfigType, - updateSettingsConfig, + getSettingsConfigFromConfigType, + updateSettingsConfig } from '../../../rest/settingConfigAPI'; import { getHyperlinkUrlValidationErrorKey } from '../../../utils/CustomProperty.utils'; import { - findSettingSource, - isPathManaged, + findSettingSource, + isPathManaged } from '../../../utils/platform/settingsSource.utils'; import { getSettingPath } from '../../../utils/RouterUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; -const { Item } = Form; const URL_SETTING_TYPES = [SettingType.OpenMetadataBaseURLConfiguration]; const EditUrlConfigurationPage = () => { const navigate = useNavigate(); const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { openMetadataUrl: '' }, + }); const [activeField, setActiveField] = useState(''); const [loading, setLoading] = useState(false); const [updating, setUpdating] = useState(false); @@ -71,7 +77,7 @@ const EditUrlConfigurationPage = () => { SettingType.OpenMetadataBaseURLConfiguration ); - form.setFieldsValue({ ...(data.config_value ?? {}) }); + form.reset({ ...(data.config_value ?? {}) }); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -146,63 +152,72 @@ const EditUrlConfigurationPage = () => { onAdopted={fetchCustomLogoConfig} onRefetch={refetchSources} /> -
{ e.preventDefault(); e.stopPropagation(); setActiveField(e.target.id); }}> - { + + { const errorKey = getHyperlinkUrlValidationErrorKey( typeof value === 'string' ? value : undefined ); - return errorKey - ? Promise.reject(new Error(t(errorKey))) - : Promise.resolve(); + return errorKey ? t(errorKey) : true; }, - }, - ]}> - - - - - - - {!isManagedByDeployment && ( + }}> + {({ field, fieldState }) => ( + + )} + + - )} - - + {!isManagedByDeployment && ( + + + + )} +
+ +
); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx index 039c12e2f2d0f..cbec908a95b96 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx @@ -12,37 +12,37 @@ */ import { - Box, - Button, - Divider, - Typography, + Box, + Button, + Divider, + FormField, + FormItemLabel, + HookForm, + Input, + Typography } from '@openmetadata/ui-core-components'; -import { Form, Input } from 'antd'; import { AxiosError } from 'axios'; import { trim } from 'lodash'; import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ResizablePanels from '../../../components/common/ResizablePanels/ResizablePanels'; +import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; +import { INITIAL_RULE } from '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControl.constants'; +import AccessControlRuleForm from '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControlRuleForm'; import { ADD_POLICY_PAGE_BREADCRUMB } from '../../../constants/Breadcrumb.constants'; import { ERROR_MESSAGE } from '../../../constants/constants'; -import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; +import { NAME_FIELD_REGISTER_RULES } from '../../../constants/Form.constants'; import { GlobalSettingOptions } from '../../../constants/GlobalSettings.constants'; -import { - CreatePolicy, - Effect, - Rule, -} from '../../../generated/api/policies/createPolicy'; +import { CreatePolicy, Rule } from '../../../generated/api/policies/createPolicy'; import { withPageLayout } from '../../../hoc/withPageLayout'; -import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { addPolicy } from '../../../rest/rolesAPIV1'; import { getIsErrorMatch } from '../../../utils/APIUtils'; -import { getField } from '../../../utils/formUtils'; import { translateWithNestedKeys } from '../../../utils/i18next/LocalUtil'; import { getPath, getPolicyWithFqnPath } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import RuleForm from '../RuleForm/RuleForm'; const policiesPath = getPath(GlobalSettingOptions.POLICIES); @@ -50,16 +50,13 @@ const AddPolicyPage = () => { const navigate = useNavigate(); const { t } = useTranslation(); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [ruleData, setRuleData] = useState({ - name: '', - description: '', - resources: [], - operations: [], - condition: '', - effect: Effect.Allow, + // Validate while typing, as the antd form did. + const form = useForm<{ name: string }>({ + mode: 'onChange', + defaultValues: { name: '' }, }); + const ruleForm = useForm({ defaultValues: INITIAL_RULE }); + const [description, setDescription] = useState(''); const [isSaveLoading, setIsSaveLoading] = useState(false); const handleCancel = () => { @@ -67,6 +64,19 @@ const AddPolicyPage = () => { }; const handleSubmit = async () => { + let ruleData: Rule | undefined; + let name: string | undefined; + // Validate both forms so every invalid field shows its error at once. + await ruleForm.handleSubmit((data) => { + ruleData = data; + })(); + await form.handleSubmit((data) => { + name = data.name; + })(); + if (!ruleData || name === undefined) { + return; + } + const { condition, ...rest } = { ...ruleData, name: trim(ruleData.name) }; const data: CreatePolicy = { name: trim(name), @@ -96,26 +106,6 @@ const AddPolicyPage = () => { } }; - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'description', - required: false, - label: `${t('label.description')}:`, - id: 'root/description', - type: FieldTypes.DESCRIPTION, - props: { - 'data-testid': 'description', - initialValue: '', - style: { - margin: 0, - }, - placeHolder: t('message.write-your-description'), - onTextChange: (value: string) => setDescription(value), - }, - }), - [] - ); - const translatedAddPolicyBreadcrumb = useMemo( () => ADD_POLICY_PAGE_BREADCRUMB.map((option) => ({ @@ -144,28 +134,37 @@ const AddPolicyPage = () => { entity: t('label.policy'), })} -
- - setName(e.target.value)} - /> - + + + + {({ field, fieldState }) => ( + + )} + + - {getField(descriptionField)} + + + + { } /> - + { - +
), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.test.tsx index ab3475f3c4d14..57f3e540ad48e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.test.tsx @@ -57,8 +57,12 @@ jest.mock('../../../utils/ToastUtils', () => ({ showErrorToast: jest.fn(), })); -jest.mock('../RuleForm/RuleForm', () => - jest.fn().mockReturnValue(
RuleForm
) +jest.mock( + '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControlRuleForm', + () => + jest + .fn() + .mockReturnValue(
RuleForm
) ); describe('Test Add rule page Component', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx index 4d11e61e1bda3..27b2b4387f842 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx @@ -12,36 +12,37 @@ */ import { - Box, - Button, - Card, - Typography, + Box, + Button, + Card, + Typography } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { trim } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../../components/common/Loader/Loader'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; +import { INITIAL_RULE } from '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControl.constants'; +import AccessControlRuleForm from '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControlRuleForm'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; import { HTTP_STATUS_CODE } from '../../../constants/Auth.constants'; import { GlobalSettingOptions } from '../../../constants/GlobalSettings.constants'; import { TabSpecificField } from '../../../enums/entity.enum'; -import { Effect, Rule } from '../../../generated/api/policies/createPolicy'; +import { Rule } from '../../../generated/api/policies/createPolicy'; import { Policy } from '../../../generated/entity/policies/policy'; import { useFqn } from '../../../hooks/useFqn'; import { getPolicyByName, patchPolicy } from '../../../rest/rolesAPIV1'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { - getPath, - getPolicyWithFqnPath, - getSettingPath, + getPath, + getPolicyWithFqnPath, + getSettingPath } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import RuleForm from '../RuleForm/RuleForm'; const policiesPath = getPath(GlobalSettingOptions.POLICIES); @@ -51,14 +52,7 @@ const AddRulePage = () => { const { fqn } = useFqn(); const [isLoading, setLoading] = useState(false); const [policy, setPolicy] = useState({} as Policy); - const [ruleData, setRuleData] = useState({ - name: '', - description: '', - resources: [], - operations: [], - condition: '', - effect: Effect.Allow, - }); + const ruleForm = useForm({ defaultValues: INITIAL_RULE }); const breadcrumb = useMemo( () => [ @@ -104,7 +98,7 @@ const AddRulePage = () => { navigate(getPolicyWithFqnPath(fqn)); }; - const handleSubmit = async () => { + const handleSubmit = ruleForm.handleSubmit(async (ruleData: Rule) => { const { condition, ...rest } = { ...ruleData, name: trim(ruleData.name) }; const patch = compare(policy, { ...policy, @@ -135,7 +129,7 @@ const AddRulePage = () => { ); } } - }; + }); useEffect(() => { fetchPolicy(); @@ -160,15 +154,8 @@ const AddRulePage = () => { data-testid="add-rule-title"> {t('label.add-new-entity', { entity: t('label.rule') })} -
- + + { - + diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.test.tsx index 8f84f38a0da90..ec36a42fc3784 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.test.tsx @@ -57,8 +57,12 @@ jest.mock('../../../utils/ToastUtils', () => ({ showErrorToast: jest.fn(), })); -jest.mock('../RuleForm/RuleForm', () => - jest.fn().mockReturnValue(
RuleForm
) +jest.mock( + '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControlRuleForm', + () => + jest + .fn() + .mockReturnValue(
RuleForm
) ); describe('Test Edit rule page Component', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx index 7e49ddcfefd5d..dada1f804b451 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx @@ -12,57 +12,48 @@ */ import { - Box, - Button, - Card, - Typography, + Box, + Button, + Card, + Typography } from '@openmetadata/ui-core-components'; -import { Form } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { trim } from 'lodash'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../../components/common/Loader/Loader'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; +import { INITIAL_RULE } from '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControl.constants'; +import AccessControlRuleForm from '../../../components/discovery/personal-space/Profile/tabs/access-control/AccessControlRuleForm'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; import { HTTP_STATUS_CODE } from '../../../constants/Auth.constants'; import { GlobalSettingOptions } from '../../../constants/GlobalSettings.constants'; import { TabSpecificField } from '../../../enums/entity.enum'; -import { Effect, Rule } from '../../../generated/api/policies/createPolicy'; +import { Rule } from '../../../generated/api/policies/createPolicy'; import { Policy } from '../../../generated/entity/policies/policy'; import { useFqn } from '../../../hooks/useFqn'; import { getPolicyByName, patchPolicy } from '../../../rest/rolesAPIV1'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { - getPath, - getPolicyWithFqnPath, - getSettingPath, + getPath, + getPolicyWithFqnPath, + getSettingPath } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import RuleForm from '../RuleForm/RuleForm'; const policiesPath = getPath(GlobalSettingOptions.POLICIES); -const InitialData: Rule = { - name: '', - description: '', - resources: [], - operations: [], - condition: '', - effect: Effect.Allow, -}; - const EditRulePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { fqn, ruleName } = useFqn(); const [isLoading, setIsLoading] = useState(false); const [policy, setPolicy] = useState({} as Policy); - const [ruleData, setRuleData] = useState(InitialData); + const ruleForm = useForm({ defaultValues: INITIAL_RULE }); - const selectedRuleRef = useRef(InitialData); const breadcrumb = useMemo( () => [ @@ -97,8 +88,7 @@ const EditRulePage = () => { if (data) { setPolicy(data); const selectedRule = data.rules.find((rule) => rule.name === ruleName); - selectedRuleRef.current = selectedRule; - setRuleData(selectedRule ?? InitialData); + ruleForm.reset({ ...INITIAL_RULE, ...selectedRule }); } else { setPolicy({} as Policy); } @@ -113,7 +103,7 @@ const EditRulePage = () => { navigate(getPolicyWithFqnPath(fqn)); }; - const handleSubmit = async () => { + const handleSubmit = ruleForm.handleSubmit(async (ruleData: Rule) => { const existingRules = policy.rules; const updatedRules = existingRules.map((rule) => { if (rule.name === ruleName) { @@ -157,7 +147,7 @@ const EditRulePage = () => { ); } } - }; + }); useEffect(() => { fetchPolicy(); @@ -180,23 +170,8 @@ const EditRulePage = () => { {t('label.edit-entity', { entity: t('label.rule') })}{' '} {`"${ruleName}"`} -
- + + { - + diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx deleted file mode 100644 index a209f8834fb03..0000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2022 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { fireEvent, render, screen } from '@testing-library/react'; -import { Form } from 'antd'; -import { Rule } from '../../../generated/api/policies/createPolicy'; -import RuleForm, { RuleFormProps } from './RuleForm'; - -jest.mock('../../../rest/rolesAPIV1', () => ({ - getPolicyFunctions: jest.fn().mockImplementation(() => Promise.resolve()), - getPolicyResources: jest.fn().mockImplementation(() => Promise.resolve()), - validateRuleCondition: jest.fn().mockImplementation(() => Promise.resolve()), -})); - -jest.mock('../../../components/common/RichTextEditor/RichTextEditor', () => - jest.fn().mockReturnValue(
Editor
) -); - -jest.mock('../../../utils/StringUtils', () => ({ - getErrorText: jest.fn(), -})); - -jest.mock('../../../utils/ToastUtils', () => ({ - showErrorToast: jest.fn(), -})); - -const mockRule = { - name: 'DataConsumerPolicy-EditRule', - effect: 'allow', - condition: 'isOwner()', - resources: ['all'], - operations: ['ViewAll', 'EditDescription', 'EditTags'], - description: 'Allow some of the edit operations on a resource for everyone.', -} as Rule; - -const setRuleData = jest.fn(); - -const mockProps = { - ruleData: mockRule, - setRuleData, -}; - -const MockFormComponent = ({ ...mockProps }: RuleFormProps) => { - return ( -
- - - ); -}; - -describe('Test Rule Form Component', () => { - it('Should render the rule form fields', async () => { - render(); - - const ruleName = await screen.findByTestId('rule-name'); - - const resources = await screen.findByTestId('resources'); - const operations = await screen.findByTestId('operations'); - - const effect = await screen.findByTestId('effect'); - - const condition = await screen.findByTestId('condition'); - - expect(ruleName).toBeInTheDocument(); - expect(resources).toBeInTheDocument(); - expect(operations).toBeInTheDocument(); - expect(effect).toBeInTheDocument(); - expect(condition).toBeInTheDocument(); - }); - - it('SetRuleData method should work', async () => { - render(); - - const ruleName = await screen.findByTestId('rule-name'); - - expect(ruleName).toBeInTheDocument(); - - fireEvent.change(ruleName, { target: { value: 'RuleName' } }); - - expect(setRuleData).toHaveBeenCalled(); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx deleted file mode 100644 index 6acab74de1c23..0000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright 2022 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, Form, Input, Select, TreeSelect } from 'antd'; -import { BaseOptionType } from 'antd/lib/select'; -import { AxiosError } from 'axios'; -import { capitalize, startCase, uniq, uniqBy } from 'lodash'; -import { FC, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EntityAttachmentProvider } from '../../../components/common/EntityDescription/EntityAttachmentProvider/EntityAttachmentProvider'; -import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; -import { EntityType } from '../../../enums/entity.enum'; -import { - Effect, - Operation, - Rule, -} from '../../../generated/api/policies/createPolicy'; -import { ResourceDescriptor } from '../../../generated/entity/policies/accessControl/resourceDescriptor'; -import { Function } from '../../../generated/type/function'; -import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; -import { - getPolicyFunctions, - getPolicyResources, - validateRuleCondition, -} from '../../../rest/rolesAPIV1'; -import { getField } from '../../../utils/formUtils'; -import { ALL_TYPE_RESOURCE_LIST } from '../../../utils/PermissionsUtils'; -import { getErrorText } from '../../../utils/StringUtils'; -import { showErrorToast } from '../../../utils/ToastUtils'; - -const { Option } = Select; - -export interface RuleFormProps { - ruleData: Rule; - setRuleData: (value: React.SetStateAction) => void; - description?: string; -} - -const RuleForm: FC = ({ - ruleData, - setRuleData, - description, -}) => { - const { t } = useTranslation(); - const [policyResources, setPolicyResources] = useState( - [] - ); - - const [policyFunctions, setPolicyFunctions] = useState([]); - - const [conditionOptions, setConditionOptions] = useState( - [] - ); - - const [validationError, setValidationError] = useState(''); - const [isValidatingCondition, setIsValidating] = useState(false); - const [isValidCondition, setIsValidCondition] = useState(false); - - /** - * Derive the resources from policy resources - */ - const resourcesOptions = useMemo(() => { - const resources = policyResources.filter( - (resource) => !ALL_TYPE_RESOURCE_LIST.includes(resource.name || '') - ); - const option = [ - { - title: 'All', - value: 'All', - key: 'all', - children: resources.map((resource) => ({ - title: startCase(resource.name), - value: resource.name, - key: resource.name, - })), - }, - ]; - - return option; - }, [policyResources]); - - /** - * Derive the operations from selected resources - */ - const operationOptions = useMemo(() => { - const selectedResources = policyResources.filter((resource) => { - // if resource is all then check for both case lower and upper - if (ALL_TYPE_RESOURCE_LIST.includes(resource.name || '')) { - return ALL_TYPE_RESOURCE_LIST.some((val) => - ruleData.resources?.includes(val) - ); - } - - return ruleData.resources?.includes(resource.name || ''); - }); - const operations = selectedResources - .reduce( - (prev: Operation[], curr: ResourceDescriptor) => - uniq([...prev, ...(curr.operations || [])]), - [] - ) - .filter((operation) => operation !== Operation.All); - - const option = [ - { - title: 'All', - value: Operation.All, - key: 'All', - children: operations.map((operation) => ({ - title: operation, - value: operation, - key: operation, - })), - }, - ]; - - return option; - }, [ruleData.resources, policyResources]); - - const getConditionOptions = (conditionFunctions: Function[]) => { - return conditionFunctions.reduce((prev: BaseOptionType[], curr) => { - const currentValues = (curr.examples || []).map((example: string) => ({ - label: example, - value: example, - })); - - return uniqBy([...prev, ...currentValues], 'value'); - }, []); - }; - - const handleConditionSearch = (value: string) => { - if (value) { - setConditionOptions((prev) => - prev.filter((condition) => condition.value.includes(value)) - ); - } else { - setConditionOptions(getConditionOptions(policyFunctions)); - } - }; - - const fetchPolicyResources = async () => { - try { - const data = await getPolicyResources(); - setPolicyResources(data.data || []); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; - - const fetchPolicyFunctions = async () => { - try { - const data = await getPolicyFunctions(); - setPolicyFunctions(data.data || []); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; - - const handleConditionValidation = async (condition: string) => { - const defaultErrorText = t('message.field-text-is-invalid', { - fieldText: t('label.condition'), - }); - - if (condition) { - setIsValidating(true); - try { - const response = await validateRuleCondition(condition); - - /** - * If request is successful then we will only get response as success without any data - * So, here we have to check the response status code - */ - const check = [200, 204].includes(response.status); - if (check) { - setValidationError(''); - setIsValidCondition(true); - } else { - setValidationError(defaultErrorText); - } - } catch (error) { - setValidationError(getErrorText(error as AxiosError, defaultErrorText)); - setIsValidCondition(false); - } finally { - setIsValidating(false); - } - } - }; - - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'ruleDescription', - required: false, - label: t('label.description'), - id: 'root/description', - type: FieldTypes.DESCRIPTION, - props: { - 'data-testid': 'description', - initialValue: description, - style: { - margin: 0, - }, - placeHolder: t('message.write-your-description'), - onTextChange: (value: string) => - setRuleData((prev: Rule) => ({ ...prev, description: value })), - }, - }), - [description, setRuleData] - ); - - useEffect(() => { - fetchPolicyResources(); - fetchPolicyFunctions(); - }, []); - - useEffect(() => { - setConditionOptions(getConditionOptions(policyFunctions)); - }, [policyFunctions]); - - return ( - <> - - - setRuleData((prev: Rule) => ({ ...prev, name: e.target.value })) - } - /> - - - {getField(descriptionField)} - - - { - setRuleData((prev: Rule) => ({ - ...prev, - resources: values, - })); - }} - /> - - - { - setRuleData((prev: Rule) => ({ - ...prev, - operations: values, - })); - }} - /> - - - - - - <> - { - setRuleData((prev: Rule) => ({ ...prev, condition: value })); - !value && setValidationError(''); - handleConditionValidation(value); - }} - onSearch={handleConditionSearch} - /> - {validationError && ( -
- {`❌ ${t('label.invalid-condition')} : ${validationError}`} -
- )} - {isValidatingCondition && ( -
- {t('label.validating-condition')} -
- )} - {isValidCondition && !isValidatingCondition && !validationError && ( -
- {`✅ ${t('label.valid-condition')}`} -
- )} - -
- - ); -}; - -export default RuleForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx index 02aa7795d18d6..afdc51d7f5558 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx @@ -11,40 +11,57 @@ * limitations under the License. */ -import { Box, Button, Typography } from '@openmetadata/ui-core-components'; -import { Form, Input, Select } from 'antd'; +import { + Autocomplete, + Box, + Button, + FormField, + FormItemLabel, + HintText, + HookForm, + Input, + SelectItemType, + Typography +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { trim } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ResizablePanels from '../../../components/common/ResizablePanels/ResizablePanels'; +import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { ADD_ROLE_PAGE_BREADCRUMB } from '../../../constants/Breadcrumb.constants'; import { ERROR_MESSAGE } from '../../../constants/constants'; -import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; +import { NAME_FIELD_REGISTER_RULES } from '../../../constants/Form.constants'; import { GlobalSettingOptions } from '../../../constants/GlobalSettings.constants'; import { TabSpecificField } from '../../../enums/entity.enum'; import { Policy } from '../../../generated/entity/policies/policy'; import { withPageLayout } from '../../../hoc/withPageLayout'; -import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { addRole, getPolicies } from '../../../rest/rolesAPIV1'; import { getIsErrorMatch } from '../../../utils/APIUtils'; -import { getField } from '../../../utils/formUtils'; import { translateWithNestedKeys } from '../../../utils/i18next/LocalUtil'; import { getPath, getRoleWithFqnPath } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -const { Option } = Select; +interface AddRoleFormValues { + name: string; + policies: string[]; +} + const rolesPath = getPath(GlobalSettingOptions.ROLES); const AddRolePage = () => { const navigate = useNavigate(); const { t } = useTranslation(); const [policies, setPolicies] = useState([]); - const [name, setName] = useState(''); + // Validate while typing, as the antd form did. + const form = useForm({ + mode: 'onChange', + defaultValues: { name: '', policies: [] }, + }); const [description, setDescription] = useState(''); - const [selectedPolicies, setSelectedPolicies] = useState([]); const [isSaveLoading, setIsSaveLoading] = useState(false); const fetchPolicies = async () => { @@ -66,12 +83,12 @@ const AddRolePage = () => { navigate(rolesPath); }; - const handleSubmit = async () => { + const handleSubmit = async ({ name, policies }: AddRoleFormValues) => { setIsSaveLoading(true); const data = { name: trim(name), description, - policies: selectedPolicies.map((policy) => policy), + policies, }; try { @@ -94,24 +111,13 @@ const AddRolePage = () => { } }; - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'description', - required: false, - label: `${t('label.description')}:`, - id: 'root/description', - type: FieldTypes.DESCRIPTION, - props: { - 'data-testid': 'description', - initialValue: '', - style: { - margin: 0, - }, - placeHolder: t('message.write-your-description'), - onTextChange: (value: string) => setDescription(value), - }, - }), - [] + const policyItems = useMemo( + () => + policies.map((policy) => ({ + id: policy.fullyQualifiedName ?? policy.name, + label: policy.displayName || policy.name, + })), + [policies] ); useEffect(() => { @@ -146,48 +152,79 @@ const AddRolePage = () => { entity: t('label.role'), })} -
- + - setName(e.target.value)} + rules={NAME_FIELD_REGISTER_RULES}> + {({ field, fieldState }) => ( + + )} + + + + - - {getField(descriptionField)} - - + - - - + rules={{ + validate: (value) => + value.length > 0 || t('message.at-least-one-policy'), + }}> + {({ field, fieldState }) => ( + + + + field.value.includes(item.id) + )} + onItemCleared={(key) => + field.onChange( + field.value.filter((id) => id !== String(key)) + ) + } + onItemInserted={(key) => + field.onChange([...field.value, String(key)]) + }> + {(item) => ( + + )} + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + { {t('label.create')} -
+
), From 8f45f03f9244e3cb62ce5a502e838d7c97c8b401 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 07:52:35 +0530 Subject: [PATCH 16/32] feat(ui): move email, team, bot auth and create-user forms to core HookForm EmailConfigForm reuses the AI email-settings mappers; AddTeamForm, AuthMechanismForm and CreateUser move to react-hook-form with core Input/Select/Autocomplete/Toggle. getJWTTokenExpiryOptions now returns core select items. E2E helpers switch to role locators, and the antd/positional suppression baselines shrink accordingly. --- .../resources/ui/eslint-suppressions.json | 91 +- .../e2e/Flow/AddRoleAndAssignToUser.spec.ts | 14 +- .../main/resources/ui/playwright/utils/bot.ts | 13 +- .../resources/ui/playwright/utils/team.ts | 17 +- .../resources/ui/playwright/utils/user.ts | 38 +- .../Bot/BotDetails/AuthMechanismForm.test.tsx | 10 +- .../Bot/BotDetails/AuthMechanismForm.tsx | 96 +- .../EmailConfigForm.component.tsx | 243 ++-- .../EmailConfigForm/EmailConfigForm.test.tsx | 13 +- .../Users/CreateUser/CreateUser.component.tsx | 1015 +++++++---------- .../Users/CreateUser/CreateUser.test.tsx | 48 + .../EditEmailConfigPage.component.tsx | 2 +- .../src/pages/TeamsPage/AddTeamForm.test.tsx | 29 +- .../ui/src/pages/TeamsPage/AddTeamForm.tsx | 374 +++--- .../resources/ui/src/utils/BotsUtils.test.tsx | 91 +- .../main/resources/ui/src/utils/BotsUtils.tsx | 10 +- 16 files changed, 937 insertions(+), 1167 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index a049ce6cf1bc8..895c5ddcb1c23 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -544,7 +544,7 @@ }, "playwright/e2e/Pages/Policies.spec.ts": { "om-playwright/no-positional-locator": { - "count": 10 + "count": 8 } }, "playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts": { @@ -1053,11 +1053,6 @@ "count": 1 } }, - "src/components/Entity/EntityLineage/LineageConfigModal.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/components/Explore/EntitySummaryPanel/APIEndpointSummary/APIEndpointSummary.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1098,21 +1093,6 @@ "count": 1 } }, - "src/components/Modals/ModalWithFunctionEditor/ModalWithFunctionEditor.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 4 - } - }, - "src/components/Modals/ModalWithQueryEditor/ModalWithQueryEditor.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/Modals/ProfileEditModal/ProfileEditModal.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/Modals/TourEndModal/TourEndModal.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1263,11 +1243,6 @@ "count": 1 } }, - "src/components/Settings/Bot/BotDetails/AuthMechanismForm.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/Settings/Bot/BotDetails/BotDetails.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1278,16 +1253,6 @@ "count": 2 } }, - "src/components/Settings/Email/EmailConfigForm/EmailConfigForm.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, - "src/components/Settings/Email/TestEmail/TestEmail.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/components/Settings/Services/Ingestion/AddIngestionButton.interface.ts": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1343,14 +1308,9 @@ "count": 5 } }, - "src/components/Settings/Users/ChangePasswordForm.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/components/Settings/Users/CreateUser/CreateUser.component.tsx": { "openmetadata-antd/no-antd-import": { - "count": 7 + "count": 1 } }, "src/components/Settings/Users/UserProfileIcon/UserProfileIcon.component.tsx": { @@ -1628,16 +1588,6 @@ "count": 1 } }, - "src/pages/Configuration/EditLoginConfiguration/EditLoginConfigurationPage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/pages/Configuration/EditUrlConfiguration/EditUrlConfigurationPage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/pages/Configuration/LoginConfigurationDetails/LoginConfigurationPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1703,36 +1653,11 @@ "count": 1 } }, - "src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, - "src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 } }, - "src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, - "src/pages/PoliciesPage/RuleForm/RuleForm.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 6 - } - }, "src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 6 @@ -1743,11 +1668,6 @@ "count": 1 } }, - "src/pages/RolesPage/AddRolePage/AddRolePage.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 3 - } - }, "src/pages/RolesPage/RolesDetailPage/RolesDetailPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1795,7 +1715,7 @@ }, "src/pages/TeamsPage/AddTeamForm.tsx": { "openmetadata-antd/no-antd-import": { - "count": 5 + "count": 1 } }, "src/utils/Alerts/AlertsUtil.tsx": { @@ -1808,11 +1728,6 @@ "count": 1 } }, - "src/utils/BotsUtils.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/utils/CSV/CSVUtilsClassBase.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts index 06aabe2148d65..47d8646ad3666 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts @@ -94,16 +94,16 @@ test.describe.serial('Add role and assign it to the user', () => { await page.click('[data-testid="password-generator"]'); await generatePasswordResponse; - await expect(page.locator('#generatedPassword')).toHaveValue(/\S+/); + await expect(page.getByTestId('generated-password')).toHaveValue(/\S+/); - await page.click('[data-testid="roles-dropdown"]'); - await page.locator('.ant-select-dropdown').waitFor({ - state: 'visible', - }); + const rolesCombobox = page + .getByTestId('roles-dropdown') + .getByRole('combobox'); + await rolesCombobox.click(); const rolesSearchResponse = page.waitForResponse('/api/v1/roles/search?*'); - await page.fill('#roles', roleName); + await rolesCombobox.fill(roleName); await rolesSearchResponse; - await page.click(`[title="${roleName}"]`); + await page.getByRole('option', { name: roleName, exact: true }).click(); await page.keyboard.press('Escape'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts index 750d7b10610c6..a7331f0a1a2d1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts @@ -93,7 +93,7 @@ export const createBot = async (page: Page) => { // Select expiry time await page.click('[data-testid="token-expiry"]'); - await page.locator('[title="1 hour"] div').click(); + await page.getByRole('option', { name: '1 hour', exact: true }).click(); await fillDescriptionBox(page, BOT_DETAILS.description); @@ -199,7 +199,10 @@ export const tokenExpirationForDays = async (page: Page) => { // Select the expiration period await page - .locator(`text=${expiryTime} day${expiryTime > 1 ? 's' : ''}`) + .getByRole('option', { + name: `${expiryTime} day${expiryTime > 1 ? 's' : ''}`, + exact: true, + }) .click(); // Save the updated date @@ -249,7 +252,7 @@ export const verifyGenerateTokenAPIContract = async (page: Page) => { // Click on dropdown and select '7 days' expiry await page.click('[data-testid="token-expiry"]'); - await page.locator('text=7 days').click(); + await page.getByRole('option', { name: '7 days', exact: true }).click(); // Intercept the POST /api/v1/users/generateToken call const generateTokenResponsePromise = page.waitForResponse( @@ -299,7 +302,7 @@ export const tokenExpirationUnlimitedDays = async (page: Page) => { // Click on expiry token dropdown await page.click('[data-testid="token-expiry"]'); // Select unlimited days - await page.getByText('Unlimited').click(); + await page.getByRole('option', { name: 'Unlimited', exact: true }).click(); // Wait for the new generateToken API endpoint const generateTokenResponse = page.waitForResponse( (response) => @@ -361,7 +364,7 @@ export const resetTokenFromBotPage = async (page: Page, botName: string) => { await expect(page.getByTestId('token-expiry')).toBeVisible(); await page.getByTestId('token-expiry').click(); - await page.getByText('Unlimited').click(); + await page.getByRole('option', { name: 'Unlimited', exact: true }).click(); await expect(page.getByTestId('save-edit')).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts index cede1d00d58c9..817b4d0d4fedb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts @@ -359,9 +359,10 @@ const checkTeamTypeOptions = async (page: Page, type: string) => { const teamTypeOptions = getTeamType(type)?.teamTypeOptions; if (teamTypeOptions) { for (const teamType of teamTypeOptions) { - const teamTypeElement = page.locator( - `.ant-select-dropdown [title="${teamType}"]` - ); + const teamTypeElement = page.getByRole('option', { + name: teamType, + exact: true, + }); await expect(teamTypeElement).toBeVisible(); } @@ -375,11 +376,13 @@ export const selectTeamHierarchy = async (page: Page, index: number) => { checkTeamTypeOptions(page, text); const childTeamType = getTeamType(text).childTeamType; await page - .locator(`.ant-select-dropdown [title="${childTeamType}"]`) + .getByRole('option', { name: childTeamType, exact: true }) .click(); } else { checkTeamTypeOptions(page, 'BusinessUnit'); - await page.locator(`.ant-select-dropdown [title='BusinessUnit']`).click(); + await page + .getByRole('option', { name: 'BusinessUnit', exact: true }) + .click(); } }; @@ -424,7 +427,9 @@ export const addTeamHierarchy = async ( if (isHierarchy) { await selectTeamHierarchy(page, index ?? 0); } else { - await page.click(`.ant-select-dropdown [title="${teamDetails.teamType}"]`); + await page + .getByRole('option', { name: teamDetails.teamType, exact: true }) + .click(); } await page.locator(descriptionBox).fill(teamDetails.description); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts index 5f1876c68ff4d..1a14328aa2cc9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts @@ -731,9 +731,9 @@ export const addUser = async ( await fillDescriptionBox(page, 'Adding new user'); - await page.click(':nth-child(2) > .ant-radio > .ant-radio-input'); - await page.fill('input[name="password"]', password); - await page.fill('input[name="confirmPassword"]', password); + await page.getByTestId('password-create').click(); + await page.getByTestId('password').fill(password); + await page.getByTestId('confirmPassword').fill(password); const rolesCombobox = page .getByTestId('roles-dropdown') @@ -743,29 +743,19 @@ export const addUser = async ( const rolesSearchResponse = page.waitForResponse('/api/v1/roles/search?*'); await rolesCombobox.fill(role); await rolesSearchResponse; - const roleOption = page - .locator('.ant-select-item-option-content') - .filter({ hasText: new RegExp(`^${role}$`) }); + const roleOption = page.getByRole('option', { name: role, exact: true }); await expect(roleOption).toBeVisible({ timeout: LONG_ACTION_TIMEOUT }); await roleOption.click(); - await clickOutside(page); + await page.keyboard.press('Escape'); if (personas?.length) { - await page - .locator('[data-testid="personas-dropdown"] .ant-select-selector') - .click(); - await page + const personasCombobox = page .getByTestId('personas-dropdown') - .getByRole('combobox') - .fill(personas[0]); - await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(1); - const personaOption = page - .locator('.ant-select-dropdown:visible') - .locator('.ant-select-item-option') - .filter({ hasText: personas[0] }); - await personaOption.waitFor({ state: 'visible' }); - await personaOption.click(); - await clickOutside(page); + .getByRole('combobox'); + await personasCombobox.click(); + await personasCombobox.fill(personas[0]); + await page.getByRole('option', { name: personas[0], exact: true }).click(); + await page.keyboard.press('Escape'); } const saveResponse = page.waitForResponse('/api/v1/users'); @@ -795,9 +785,9 @@ export const checkForUserExistError = async ( await fillDescriptionBox(page, 'Adding new user'); - await page.click(':nth-child(2) > .ant-radio > .ant-radio-input'); - await page.fill('input[name="password"]', password); - await page.fill('input[name="confirmPassword"]', password); + await page.getByTestId('password-create').click(); + await page.getByTestId('password').fill(password); + await page.getByTestId('confirmPassword').fill(password); const saveResponse = page.waitForResponse('/api/v1/users'); await page.click('[data-testid="save-user"]'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.test.tsx index c9ccb4425d3f6..3a04a19d8820b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.test.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import { Select } from 'antd'; import { PersonalAccessToken, @@ -117,11 +117,9 @@ describe('AuthMechanismForm', () => { it('should disable auth mechanism select field', () => { render(); - const authMechanismSelect = screen - .getByTestId('auth-mechanism') - .querySelector('input'); - - expect(authMechanismSelect).toBeDisabled(); + expect( + within(screen.getByTestId('auth-mechanism')).getByRole('button') + ).toBeDisabled(); }); it('should show cancel button when authenticationMechanism is not empty', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.tsx index 850e9a9a8cdc0..e16977b90a375 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/AuthMechanismForm.tsx @@ -11,13 +11,19 @@ * limitations under the License. */ -import { Box, Button, Typography } from '@openmetadata/ui-core-components'; -import { Form, FormProps, Select } from 'antd'; +import { + Box, + Button, + FormField, + HookForm, + Select, + Typography, +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; import { FC, useCallback, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../../constants/constants'; import { ClientErrors } from '../../../../enums/Axios.enum'; import { PersonalAccessToken, @@ -37,8 +43,6 @@ import { import { getJWTTokenExpiryOptions } from '../../../../utils/BotsUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; -const { Option } = Select; - const DEFAULT_SCIM_CONFIGURATION: ScimConfiguration = { enabled: true, identityProvider: 'default', @@ -65,6 +69,11 @@ const enableScimProvisioning = async () => { } }; +interface AuthFormValues { + authType?: string; + tokenExpiry: string; +} + interface Props { isUpdating: boolean; authenticationMechanism: AuthenticationMechanism | PersonalAccessToken; @@ -83,9 +92,9 @@ const AuthMechanismForm: FC = ({ isSCIMBot, }) => { const { t } = useTranslation(); - const handleSave: FormProps['onFinish'] = (values) => { + const handleSave = (values: AuthFormValues) => { const updatedAuthMechanism: AuthenticationMechanism = { - authType: values?.authType ?? AuthType.Jwt, + authType: (values?.authType as AuthType) ?? AuthType.Jwt, config: { JWTTokenExpiry: values.tokenExpiry as JWTTokenExpiry, }, @@ -123,6 +132,10 @@ const AuthMechanismForm: FC = ({ }; }, [isBot, authenticationMechanism]); + const form = useForm({ + values: { authType, tokenExpiry }, + }); + const handleGenerateSCIMToken = useCallback(async () => { if (isSCIMBot) { try { @@ -162,36 +175,47 @@ const AuthMechanismForm: FC = ({
) : ( -
- - - - - + + - - - + rules={{ + required: t('message.field-text-is-required', { + fieldText: t('label.token-expiration'), + }), + }}> + {({ field, fieldState }) => ( + + )} + = ({ {t('label.generate')} -
+ ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/EmailConfigForm/EmailConfigForm.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/EmailConfigForm/EmailConfigForm.component.tsx index 6816c639756d3..c1e3810e27c47 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/EmailConfigForm/EmailConfigForm.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Email/EmailConfigForm/EmailConfigForm.component.tsx @@ -11,16 +11,27 @@ * limitations under the License. */ -import { Box, Button, Grid, Toggle } from '@openmetadata/ui-core-components'; -import { Form, Input, Select } from 'antd'; +import { + Box, + Button, + FieldProp, + FieldTypes, + FormFields, + HookForm, +} from '@openmetadata/ui-core-components'; import { FocusEvent } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../../constants/constants'; import { EMAIL_CONFIG_FORM_FIELDS, TRANSPORTATION_STRATEGY_OPTIONS, } from '../../../../constants/EmailConfig.constants'; import { SMTPSettings } from '../../../../generated/email/smtpSettings'; +import { + EmailFormValues, + toEmailFormValues, + toSmtpSettings, +} from '../../../discovery/personal-space/Profile/tabs/platform-settings/EmailSettingsForm'; interface EmailConfigFormProps { isLoading: boolean; @@ -29,11 +40,9 @@ interface EmailConfigFormProps { managedFields?: (keyof SMTPSettings)[]; onSubmit: (configValues: SMTPSettings) => void; onCancel: () => void; - onFocus: (event: FocusEvent) => void; + onFocus: (event: FocusEvent) => void; } -const { Item } = Form; - function EmailConfigForm({ emailConfigValues, isLoading, @@ -43,127 +52,123 @@ function EmailConfigForm({ onSubmit, }: EmailConfigFormProps) { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: toEmailFormValues(emailConfigValues), + }); + const isManaged = (field: keyof SMTPSettings) => managedFields.includes(field); const isEveryFieldManaged = EMAIL_CONFIG_FORM_FIELDS.every(isManaged); + const textField = ( + name: keyof EmailFormValues & keyof SMTPSettings, + label: string, + testId: string, + type: FieldTypes = FieldTypes.TEXT, + required = false + ): FieldProp => ({ + name, + label, + type, + required, + id: `root/${name}`, + props: { inputDataTestId: testId, isDisabled: isManaged(name) }, + rules: required + ? { + required: t('message.field-text-is-required', { fieldText: label }), + } + : undefined, + }); + + const fields: FieldProp[] = [ + textField('username', t('label.username'), 'username-input'), + textField( + 'password', + t('label.password'), + 'password-input', + FieldTypes.PASSWORD + ), + textField( + 'senderMail', + t('label.sender-email'), + 'sender-email-input', + FieldTypes.TEXT, + true + ), + textField( + 'serverEndpoint', + t('label.server-endpoint'), + 'server-endpoint-input', + FieldTypes.TEXT, + true + ), + textField( + 'serverPort', + t('label.server-port'), + 'server-port-input', + FieldTypes.NUMBER, + true + ), + textField( + 'emailingEntity', + t('label.emailing-entity'), + 'emailing-entity-input' + ), + { + name: 'enableSmtpServer', + label: t('label.enable-smtp-server'), + type: FieldTypes.SWITCH, + id: 'root/enableSmtpServer', + props: { + 'data-testid': 'smtp-server-input', + isDisabled: isManaged('enableSmtpServer'), + }, + }, + textField('supportUrl', t('label.support-url'), 'support-url-input'), + { + name: 'transportationStrategy', + label: t('label.transportation-strategy'), + type: FieldTypes.SELECT, + id: 'root/transportationStrategy', + props: { + 'data-testid': 'transportation-strategy-input', + isDisabled: isManaged('transportationStrategy'), + items: TRANSPORTATION_STRATEGY_OPTIONS.map(({ value, label }) => ({ + id: value, + label, + })), + }, + }, + ]; + return ( -
- - - - - - - - - - - - - - - - - - - - - - {t('label.enable-smtp-server')} - - - - form.setFieldsValue({ enableSmtpServer: value }) - } - /> - - - - - - - - - {getJWTTokenExpiryOptions()} - - + rules={{ + required: t('label.field-required', { + field: t('label.token-expiration'), + }), + }}> + {({ field, fieldState }) => ( + + )} + {isAdminUser && ( - - - - )} - - ); -}; - -interface PasswordConfigSectionProps { - generatedPassword?: string; - isAuthProviderBasic: boolean; - isPasswordGenerating: boolean; - password?: string; - passwordGenerator?: string; - onGeneratePassword: () => void; -} - -const PasswordConfigSection = ({ - generatedPassword, - isAuthProviderBasic, - isPasswordGenerating, - password, - passwordGenerator, - onGeneratePassword, -}: PasswordConfigSectionProps) => { - const { t } = useTranslation(); - - if (!isAuthProviderBasic) { - return null; - } - - return ( - <> - - - - {t('label.automatically-generate')} - - - {t('label.password-type', { - type: t('label.create'), - })} - - - - - {passwordGenerator === CreatePasswordGenerator.CreatePassword ? ( -
- - - - - { - if (value !== password) { - return Promise.reject(t('label.password-not-match')); - } - - return Promise.resolve(); - }, - }, - ]}> - + {({ field }) => ( + - -
- ) : ( -
- - -
{ - if (e.key === 'Enter' || e.key === ' ') { - onGeneratePassword(); - } - }}> - {isPasswordGenerating ? ( - - ) : ( - - )} -
- -
- -
-
- } - autoComplete="off" - name="generatedPassword" - value={generatedPassword} - /> - -
- )} - - ); -}; - -interface TeamRolePersonaFieldsProps { - debouncedFetchRoleOptions: (searchText?: string) => void; - fetchPersonaOptions: AsyncSelectListProps['api']; - isRolesLoading: boolean; - roleOptions: Array<{ label: string; value: string }>; - onTeamsSelectionChange: (teams: EntityReference[]) => void; -} - -const TeamRolePersonaFields = ({ - debouncedFetchRoleOptions, - fetchPersonaOptions, - isRolesLoading, - roleOptions, - onTeamsSelectionChange, -}: TeamRolePersonaFieldsProps) => { - const { t } = useTranslation(); - - return ( - <> - - - - - + + +
+ } + type="password" + value={generatedPassword} + /> + )} + + )} + + ); return ( - - - - - - - + onSubmit={form.handleSubmit(handleSave)}> + {forceBot && } - - {getField(descriptionField)} - - {!forceBot && ( - + + + + {!forceBot && ( + <> + {isAuthProviderBasic && renderPasswordSection()} + {!isAdminPage && ( + <> + + + + + {renderMultiSelect( + 'roles', + t('label.role-plural'), + 'roles-dropdown', + roleItems, + debouncedFetchRoleOptions + )} + {renderMultiSelect( + 'personas', + t('label.persona-plural'), + 'personas-dropdown', + personaItems + )} + + )} + { + setIsAdmin(selected); + setIsBot(false); + }} + /> + )} - {!isBot && ( - + + {({ field }) => ( + + + + + field.onChange( + domains === undefined + ? undefined + : ([] as EntityReference[]).concat(domains) + ) + }> + - + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.test.tsx index c28e0198d1fae..2f625ee6cc5c8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.test.tsx @@ -12,8 +12,10 @@ */ import { + act, findByTestId, findByText, + fireEvent, queryByTestId, queryByText, render, @@ -210,4 +212,50 @@ describe('Test CreateUser component', () => { expect(passwordGenerator).not.toBeInTheDocument(); }); + + it('should submit the user payload built from the form', async () => { + const onSave = jest.fn(); + const { container } = render( + , + { wrapper: MemoryRouter } + ); + + fireEvent.change(await findByTestId(container, 'email'), { + target: { value: 'jane.doe@example.com' }, + }); + fireEvent.change(await findByTestId(container, 'displayName'), { + target: { value: ' Jane Doe ' }, + }); + await act(async () => { + fireEvent.click(await findByTestId(container, 'save-user')); + }); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'jane.doe', + email: 'jane.doe@example.com', + displayName: 'Jane Doe', + isAdmin: true, + roles: [], + personas: [], + }) + ); + }); + + it('should not submit an invalid email', async () => { + const onSave = jest.fn(); + const { container } = render( + , + { wrapper: MemoryRouter } + ); + + fireEvent.change(await findByTestId(container, 'email'), { + target: { value: 'not-an-email' }, + }); + await act(async () => { + fireEvent.click(await findByTestId(container, 'save-user')); + }); + + expect(onSave).not.toHaveBeenCalled(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/EditEmailConfigPage/EditEmailConfigPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/EditEmailConfigPage/EditEmailConfigPage.component.tsx index 667d9666eeb13..31fbfd3242679 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/EditEmailConfigPage/EditEmailConfigPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/EditEmailConfigPage/EditEmailConfigPage.component.tsx @@ -163,7 +163,7 @@ function EditEmailConfigPage() { [] ); - const handleFieldFocus = useCallback((event: FocusEvent) => { + const handleFieldFocus = useCallback((event: FocusEvent) => { setActiveField(event.target.id); }, []); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.test.tsx index 1451cd85ef55c..76629fa7502ac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.test.tsx @@ -29,20 +29,21 @@ jest.mock('../../components/common/DomainLabel/DomainLabel.component', () => ({ DomainLabel: jest.fn().mockImplementation(() =>
DomainLabel
), })); -jest.mock('../../utils/formUtils', () => ({ - getField: jest +jest.mock( + '../../components/common/DomainSelectableList/DomainSelectableList.component', + () => jest.fn().mockImplementation(({ children }) =>
{children}
) +); + +jest.mock('../../components/common/RichTextEditor/RichTextEditor', () => + jest .fn() - .mockImplementation(({ name }) =>
{name}
), -})); + .mockImplementation(() =>
Editor
) +); jest.mock('../../utils/TeamUtils', () => ({ - getTeamOptionsFromType: jest.fn().mockImplementation(() => [ - { - label: 'Organization', - value: TeamType.Organization, - type: TeamType.Organization, - }, - ]), + getTeamOptionsFromType: jest + .fn() + .mockImplementation(() => [TeamType.Group, TeamType.Department]), })); jest.mock('../../utils/ToastUtils', () => ({ @@ -74,7 +75,7 @@ describe('AddTeamForm component', () => { expect(getByTestId('email')).toBeInTheDocument(); expect(getByTestId('description')).toBeInTheDocument(); expect(getByTestId('team-selector')).toBeInTheDocument(); - expect(getByTestId('isJoinable')).toBeInTheDocument(); + expect(getByTestId('isJoinable-switch-button')).toBeInTheDocument(); }); it('should call onCancel function when cancel button is clicked', () => { @@ -135,6 +136,8 @@ describe('AddTeamForm component', () => { email: 'testteam@gmail.com', description: '', teamType: 'Group', + isJoinable: false, + domains: [{ id: '1', type: 'domain' }], }); }); @@ -174,6 +177,8 @@ describe('AddTeamForm component', () => { email: undefined, description: '', teamType: 'Group', + isJoinable: false, + domains: [{ id: '1', type: 'domain' }], }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx index 5076367131c73..db6f5cd8456a3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx @@ -12,17 +12,32 @@ */ import { PlusOutlined } from '@ant-design/icons'; -import { Button, SimpleModal } from '@openmetadata/ui-core-components'; -import { Form, Input, Select } from 'antd'; -import { useForm } from 'antd/lib/form/Form'; +import { + Box, + Button, + FieldTypes, + FormField, + FormFields, + FormItemLabel, + HookForm, + Input, + Select, + SimpleModal, +} from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { toLower, trim } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; +import { RegisterOptions, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { DomainLabel } from '../../components/common/DomainLabel/DomainLabel.component'; +import DomainSelectableList from '../../components/common/DomainSelectableList/DomainSelectableList.component'; import { DrawerPopupContainerProvider } from '../../components/common/DrawerPopupContainerProvider/DrawerPopupContainerProvider'; -import { VALIDATION_MESSAGES } from '../../constants/constants'; -import { NAME_FIELD_RULES } from '../../constants/Form.constants'; +import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; +import { + DISPLAY_NAME_FIELD_REGISTER_RULES, + NAME_FIELD_REGISTER_RULES, +} from '../../constants/Form.constants'; +import { EMAIL_REG_EX } from '../../constants/regex.constants'; import { EntityType } from '../../enums/entity.enum'; import { EntityReference, @@ -30,17 +45,22 @@ import { TeamType, } from '../../generated/entity/teams/team'; import { useDomainStore } from '../../hooks/useDomainStore'; -import { - FieldProp, - FieldTypes, - FormItemLayout, -} from '../../interface/FormUtils.interface'; import { getTeams } from '../../rest/teamsAPI'; -import { getField } from '../../utils/formUtils'; import { getTeamOptionsFromType } from '../../utils/TeamUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import { AddTeamFormType } from './AddTeamForm.interface'; +interface AddTeamFormValues { + name: string; + displayName: string; + email: string; + teamType: TeamType; + isJoinable: boolean; + domains?: EntityReference[]; +} + +type TextFieldName = 'name' | 'displayName' | 'email'; + const AddTeamForm: React.FC = ({ visible, onCancel, @@ -49,101 +69,73 @@ const AddTeamForm: React.FC = ({ parentTeamType, }) => { const { t } = useTranslation(); - const [form] = useForm(); + const { activeDomainEntityRef } = useDomainStore(); + // Validate while typing, as the antd form did. + const form = useForm({ + mode: 'onChange', + defaultValues: { + name: '', + displayName: '', + email: '', + teamType: TeamType.Group, + isJoinable: false, + domains: activeDomainEntityRef ? [activeDomainEntityRef] : undefined, + }, + }); const [description, setDescription] = useState(''); const [allTeam, setAllTeam] = useState([]); - const { activeDomainEntityRef } = useDomainStore(); - const selectedDomain = - Form.useWatch('domains', form) ?? []; + const selectedDomain = form.watch('domains') ?? []; - const teamTypeOptions = useMemo(() => { - return getTeamOptionsFromType(parentTeamType).map((type) => ({ - label: type, - value: type, - })); - }, [parentTeamType]); + const teamTypeItems = useMemo( + () => + getTeamOptionsFromType(parentTeamType).map((type) => ({ + id: type, + label: type, + })), + [parentTeamType] + ); - const handleSubmit = (data: Team) => { - data = { + const handleSubmit = (data: AddTeamFormValues) => { + onSave({ ...data, name: trim(data.name), displayName: trim(data.displayName), + email: data.email || undefined, description, - }; - onSave(data); + } as Team); }; const fetchAllTeams = async () => { try { const { data } = await getTeams(); - setAllTeam(data); } catch (error) { showErrorToast(error as AxiosError, t('server.unexpected-response')); } }; - const domainsField: FieldProp = { - name: 'domains', - id: 'root/domains', - required: false, - label: t('label.domain-plural'), - type: FieldTypes.DOMAIN_SELECT, - props: { - selectedDomain: activeDomainEntityRef - ? [activeDomainEntityRef] - : undefined, - multiple: true, - children: ( -
- + + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.interface.ts index a831ae74da31c..80bb1ac072a69 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.interface.ts @@ -11,10 +11,7 @@ * limitations under the License. */ -import { Persona } from '../../../../generated/entity/teams/persona'; - export interface AddPersonaFormProps { onCancel: () => void; onSave: () => void; - persona?: Persona; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.test.tsx new file mode 100644 index 0000000000000..87f0870f6f0bb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/AddEditPersona/AddEditPersona.test.tsx @@ -0,0 +1,73 @@ +/* + * 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 { createPersona } from '../../../../rest/PersonaAPI'; +import { AddEditPersonaForm } from './AddEditPersona.component'; + +jest.mock('../../../../rest/PersonaAPI', () => ({ + createPersona: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../../../../rest/userAPI', () => ({ + getUserById: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../../../../hooks/useApplicationStore', () => ({ + useApplicationStore: jest.fn(() => ({ + currentUser: { id: 'user-1' }, + setCurrentUser: jest.fn(), + })), +})); + +jest.mock('../../../common/RichTextEditor/RichTextEditor', () => + jest.fn(() =>
) +); + +jest.mock('../../../common/UserSelectableList/UserSelectableList.component', () => ({ + UserSelectableList: jest.fn(({ children }) =>
{children}
), +})); + +describe('AddEditPersonaForm', () => { + it('should create the persona from the trimmed form values', async () => { + const onSave = jest.fn(); + render(); + + fireEvent.change(screen.getByTestId('name'), { + target: { value: ' data-steward ' }, + }); + fireEvent.change(screen.getByTestId('displayName'), { + target: { value: 'Data Steward' }, + }); + await act(async () => { + fireEvent.click(screen.getByText('label.create')); + }); + + expect(createPersona).toHaveBeenCalledWith({ + name: 'data-steward', + displayName: 'Data Steward', + description: '', + users: [], + }); + expect(onSave).toHaveBeenCalled(); + }); + + it('should not create the persona without a name', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByText('label.create')); + }); + + expect(createPersona).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx index b929981dcb9c2..50945adfdc537 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx @@ -12,12 +12,12 @@ */ import Icon from '@ant-design/icons/lib/components/Icon'; import { - Badge, - Box, - Grid, - Select, - Toggle, - Typography, + Badge, + Box, + Grid, + Select, + Toggle, + Typography } from '@openmetadata/ui-core-components'; import { Collapse, InputNumber } from 'antd'; import { AxiosError } from 'axios'; @@ -26,24 +26,24 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ENTITY_PATH } from '../../../constants/constants'; import { - GlobalSettingOptions, - GlobalSettingsMenuCategory, + GlobalSettingOptions, + GlobalSettingsMenuCategory } from '../../../constants/GlobalSettings.constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { - AllowedFieldField, - AllowedFieldValueBoostField, - AllowedSearchFields, - AssetTypeConfiguration, - BoostMode, - FieldBoost, - FieldValueBoost, - RankingConfiguration, - RankingStage, - ScoreMode, - SearchFieldMatchType as MatchType, - SearchSettings, - TermBoost, + AllowedFieldField, + AllowedFieldValueBoostField, + AllowedSearchFields, + AssetTypeConfiguration, + BoostMode, + FieldBoost, + FieldValueBoost, + RankingConfiguration, + RankingStage, + ScoreMode, + SearchFieldMatchType as MatchType, + SearchSettings, + TermBoost } from '../../../generated/configuration/searchSettings'; import { CustomProperty } from '../../../generated/entity/type'; import { Settings, SettingType } from '../../../generated/settings/settings'; @@ -52,28 +52,28 @@ import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { EntitySearchSettingsState } from '../../../pages/SearchSettingsPage/searchSettings.interface'; import { getCustomPropertiesByEntityType } from '../../../rest/metadataTypeAPI'; import { - getSettingsByType, - restoreSettingsConfig, - updateSettingsConfig, + getSettingsByType, + restoreSettingsConfig, + updateSettingsConfig } from '../../../rest/settingConfigAPI'; import { getLayoutGutter } from '../../../utils/common/layout.utils'; import { getSettingPageEntityBreadCrumb } from '../../../utils/GlobalSettingsUtils'; import { - boostModeOptions, - getEffectiveRankingConfiguration, - getEntitySearchConfig, - getSearchSettingCategories, - scoreModeOptions, + boostModeOptions, + getEffectiveRankingConfiguration, + getEntitySearchConfig, + getSearchSettingCategories, + scoreModeOptions } from '../../../utils/SearchSettingsUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; import { useRequiredParams } from '../../../utils/useRequiredParams'; import CollapseHeader from '../../common/CollapseHeader/CollapseHeader'; import TitleBreadcrumb from '../../common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import FieldValueBoostDialog from '../../discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostDialog'; import PageLayoutV1 from '../../PageLayoutV1/PageLayoutV1'; import FieldConfiguration from '../FieldConfiguration/FieldConfiguration'; import FieldValueBoostList from '../FieldValueBoostList/FieldValueBoostList'; -import FieldValueBoostModal from '../FieldValueBoostModal/FieldValueBoostModal'; import SearchPreview from '../SearchPreview/SearchPreview'; import TermBoostList from '../TermBoostList/TermBoostList'; import './entity-search-settings.less'; @@ -1084,16 +1084,17 @@ const EntitySearchSettings = () => { - { - setShowNewFieldValueBoost(false); - setSelectedFieldValueBoost(undefined); - }} - onSave={handleSaveFieldValueBoost} - /> + {showNewFieldValueBoost && ( + { + setShowNewFieldValueBoost(false); + setSelectedFieldValueBoost(undefined); + }} + onSave={handleSaveFieldValueBoost} + /> + )} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx deleted file mode 100644 index b633bb207a013..0000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright 2025 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { SimpleModal, Typography } from '@openmetadata/ui-core-components'; -import { Form, Input, Select, Slider } from 'antd'; -import { useForm } from 'antd/lib/form/Form'; -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - FieldValueBoost, - Modifier, -} from '../../../generated/configuration/searchSettings'; -import { modifierOptions } from '../../../utils/SearchSettingsUtils'; -import { DrawerPopupContainerProvider } from '../../common/DrawerPopupContainerProvider/DrawerPopupContainerProvider'; - -interface FieldValueBoostModalProps { - open: boolean; - entityOptions: string[]; - selectedBoost?: FieldValueBoost; - onSave: (values: FieldValueBoost) => void; - onCancel: () => void; -} - -const FieldValueBoostModal: React.FC = ({ - open, - entityOptions, - selectedBoost, - onSave, - onCancel, -}) => { - const { t } = useTranslation(); - const [form] = useForm(); - const selectedField = Form.useWatch('field', form); - const [factor, setFactor] = useState(selectedBoost?.factor ?? 0); - - useEffect(() => { - if (selectedBoost) { - form.setFieldsValue({ - field: selectedBoost.field, - factor: selectedBoost.factor, - modifier: selectedBoost.modifier, - missing: selectedBoost.missing, - condition: selectedBoost.condition, - }); - setFactor(selectedBoost.factor ?? 1); - } else { - form.resetFields(); - setFactor(0); - } - }, [selectedBoost, open]); - - const handleFactorChange = (value: number) => { - setFactor(value); - form.setFieldValue('factor', value); - }; - - const handleSubmit = () => { - form.validateFields().then((values) => { - const fieldName = values.field; - - if (!fieldName) { - return; - } - - const condition = { - range: { - ...(values.condition?.range?.gte !== undefined && { - gte: Number(values.condition.range.gte), - }), - ...(values.condition?.range?.lte !== undefined && { - lte: Number(values.condition.range.lte), - }), - ...(values.condition?.range?.gt !== undefined && { - gt: Number(values.condition.range.gt), - }), - ...(values.condition?.range?.lt !== undefined && { - lt: Number(values.condition.range.lt), - }), - }, - }; - - onSave({ - field: fieldName, - factor: factor, - modifier: values.modifier ?? Modifier.None, - missing: values.missing ? Number(values.missing) : 0, - condition, - }); - }); - }; - - return ( - - -
- - - - - - - - - - {t('label.range-condition')} - -
-
- - - - - - -
-
- - - - - - -
-
-
-
-
- ); -}; - -export default FieldValueBoostModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx index 3d951c38f9cc5..cf993e0e77ea1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx @@ -11,9 +11,9 @@ * limitations under the License. */ import { - Button, - Card, - SkeletonParagraph, + Button, + Card, + SkeletonParagraph } from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; @@ -158,7 +158,6 @@ export const PersonaPage = ({ pageTitle }: { pageTitle: string }) => { {errorPlaceHolder} {Boolean(addEditPersona) && ( @@ -238,7 +237,6 @@ export const PersonaPage = ({ pageTitle }: { pageTitle: string }) => { {Boolean(addEditPersona) && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx index 4615bfa5e25a7..7b5cc1d0c5e03 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx @@ -20,7 +20,6 @@ import { } from '@openmetadata/ui-core-components'; import { Collapse, Slider } from 'antd'; import { AxiosError } from 'axios'; -import { isEmpty } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -28,11 +27,11 @@ import { ReactComponent as PlusOutlined } from '../../assets/svg/plus-outlined.s import Loader from '../../components/common/Loader/Loader'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import FieldValueBoostDialog from '../../components/discovery/personal-space/Profile/tabs/platform-settings/search/FieldValueBoostDialog'; import ConfirmationModal from '../../components/Modals/ConfirmationModal/ConfirmationModal'; import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import FieldValueBoostList from '../../components/SearchSettings/FieldValueBoostList/FieldValueBoostList'; -import FieldValueBoostModal from '../../components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal'; import { GlobalSettingItem } from '../../components/SearchSettings/GlobalSettingsItem/GlobalSettingsItem'; import TermBoostList from '../../components/SearchSettings/TermBoostList/TermBoostList'; import SettingItemCard from '../../components/Settings/SettingItemCard/SettingItemCard.component'; @@ -242,15 +241,13 @@ const SearchSettingsPage = () => { [] ); - const fieldValueBoostOptions = useMemo(() => { - if (!isEmpty(searchConfig?.allowedFieldValueBoosts)) { - return searchConfig?.allowedFieldValueBoosts?.[0].fields?.map( + const fieldValueBoostOptions = useMemo( + () => + searchConfig?.allowedFieldValueBoosts?.[0]?.fields?.map( (field) => field.name - ); - } - - return []; - }, [searchConfig]); + ) ?? [], + [searchConfig] + ); const fetchSearchConfig = async () => { try { @@ -755,16 +752,17 @@ const SearchSettingsPage = () => { ))} - { - setShowFieldValueBoostModal(false); - setSelectedFieldValueBoost(undefined); - }} - onSave={handleSaveFieldValueBoost} - /> + {showFieldValueBoostModal && ( + { + setShowFieldValueBoostModal(false); + setSelectedFieldValueBoost(undefined); + }} + onSave={handleSaveFieldValueBoost} + /> + )} {showResetModal && ( Date: Tue, 13 Oct 2026 08:03:24 +0530 Subject: [PATCH 18/32] feat(ui): move team email and subscription forms to core HookForm --- .../resources/ui/eslint-suppressions.json | 7 +- .../ui/playwright/utils/teamSubscription.ts | 21 +-- .../TeamsInfo.component.tsx | 129 ++++++++++-------- .../TeamsSubscription.component.tsx | 125 ++++++++++------- .../TeamsSubscription.test.tsx | 43 +++++- 5 files changed, 195 insertions(+), 130 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 4c5db319e0484..07f1f38ffd5cd 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1280,12 +1280,7 @@ }, "src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx": { "openmetadata-antd/no-antd-import": { - "count": 5 - } - }, - "src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 4 + "count": 3 } }, "src/components/Settings/Team/TeamsSelectable/TeamsSelectable.tsx": { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/teamSubscription.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/teamSubscription.ts index b3a9790a0af40..80bae51976d98 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/teamSubscription.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/teamSubscription.ts @@ -12,6 +12,7 @@ */ import { expect, Page } from '@playwright/test'; import { waitForAllLoadersToDisappear } from './entity'; +import { selectSettingOption } from './searchSettingUtils'; export type WebhookType = 'MS Teams' | 'Slack' | 'G Chat' | 'Webhook' | 'None'; export type WebhookIconTestId = @@ -34,21 +35,11 @@ export const selectWebhookType = async ( page: Page, webhookType: WebhookType ) => { - const webhookInput = page.locator('#webhook'); - const selectContainer = page - .getByTestId('subscription-modal') - .locator('.ant-select-selector') - .filter({ has: webhookInput }); - - await selectContainer.waitFor({ state: 'visible' }); - await selectContainer.click(); - - const dropdown = page.locator('.ant-select-dropdown:visible'); - const webhookOption = dropdown.getByText(webhookType, { exact: true }); - - await expect(webhookOption).toBeVisible(); - await webhookOption.click(); - await expect(dropdown).not.toBeVisible(); + await selectSettingOption( + page, + page.getByTestId('subscription-modal').getByTestId('webhook-select'), + webhookType + ); }; export const fillEndpointAndSave = async (page: Page, endpoint: string) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx index 380e1d49e82e2..eb28c406d3fb1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsInfo.component.tsx @@ -19,14 +19,17 @@ import { Box, Button, Divider, + FormField, + HookForm, + Input, Owner, Tooltip, Typography, } from '@openmetadata/ui-core-components'; -import { Form, Input } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, last } from 'lodash'; import { MouseEvent, useCallback, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { @@ -66,6 +69,9 @@ const TeamsInfo = ({ }: TeamsInfoProps) => { const { t } = useTranslation(); const [isEmailEdit, setIsEmailEdit] = useState(false); + const emailForm = useForm<{ email: string }>({ + defaultValues: { email: '' }, + }); const [showTypeSelector, setShowTypeSelector] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -201,69 +207,68 @@ const TeamsInfo = ({ onClick={(e) => { // Used to stop click propagation event to parent TeamDetailV1 collapsible panel e.stopPropagation(); + emailForm.reset({ email: email ?? '' }); setIsEmailEdit(true); }} /> )}
{isEmailEdit ? ( -
e.stopPropagation()} - onFinish={onEmailSave}> - - - - - - - + // Stops clicks reaching the parent TeamsDetailV1 collapsible panel. +
e.stopPropagation()}> + + + + {({ field, fieldState }) => ( + + )} + + + + + - - + +
) : ( ), - [email, isEmailEdit, hasEditPermission, isLoading] + [ + email, + isEmailEdit, + hasEditPermission, + isLoading, + emailForm, + onEmailSave, + t, + ] ); const teamTypeElement = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.component.tsx index 2474947d42bfb..7056045f07af6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.component.tsx @@ -11,32 +11,44 @@ * limitations under the License. */ -import { Box, SimpleModal, Typography } from '@openmetadata/ui-core-components'; -import { Form, Input, Select } from 'antd'; -import { useForm } from 'antd/lib/form/Form'; +import { + Box, + FormField, + HookForm, + Input, + Select, + SimpleModal, + Typography +} from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { NO_DATA_PLACEHOLDER } from '../../../../../constants/constants'; import { - SUBSCRIPTION_WEBHOOK, - SUBSCRIPTION_WEBHOOK_OPTIONS, + SUBSCRIPTION_WEBHOOK, + SUBSCRIPTION_WEBHOOK_OPTIONS } from '../../../../../constants/Teams.constants'; import { Webhook } from '../../../../../generated/type/profile'; +import { isValidUrl } from '../../../../../utils/SSOUtils'; import { getWebhookIcon } from '../../../../../utils/TeamUtils'; -import { DrawerPopupContainerProvider } from '../../../../common/DrawerPopupContainerProvider/DrawerPopupContainerProvider'; import { EditIconButton } from '../../../../common/IconButtons/EditIconButton'; import { SubscriptionWebhook, TeamsSubscriptionProps } from '../team.interface'; import './teams-subscription.less'; +// react-aria keys cannot be empty, so the "None" option gets a sentinel id. +const NO_WEBHOOK = 'none'; + const TeamsSubscription = ({ subscription, hasEditPermission, updateTeamSubscription, }: TeamsSubscriptionProps) => { - const [form] = useForm(); const { t } = useTranslation(); + const form = useForm({ + defaultValues: { webhook: NO_WEBHOOK, endpoint: '' }, + }); const [editSubscription, setEditSubscription] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -53,8 +65,7 @@ const TeamsSubscription = ({ ); }, []); - // Watchers - const isWebhookEmpty = isEmpty(Form.useWatch('webhook', form)); + const isWebhookEmpty = form.watch('webhook') === NO_WEBHOOK; const cellItem = useCallback( (key: string, value: Webhook) => ( @@ -110,24 +121,23 @@ const TeamsSubscription = ({ useEffect(() => { if (isWebhookEmpty) { - form.setFieldValue('endpoint', ''); + form.setValue('endpoint', ''); + form.clearErrors('endpoint'); } }, [isWebhookEmpty, form]); useEffect(() => { - if (subscription) { - const data = Object.entries(subscription)[0]; - form.setFieldsValue({ - webhook: data[0], - endpoint: data[1].endpoint, - }); - } + const [webhook, value] = Object.entries(subscription ?? {})[0] ?? []; + form.reset({ + webhook: webhook ?? NO_WEBHOOK, + endpoint: value?.endpoint ?? '', + }); }, [subscription, editSubscription, form]); - const subscriptionWebhookTranslated = useMemo( + const webhookItems = useMemo( () => SUBSCRIPTION_WEBHOOK_OPTIONS.map((option) => ({ - ...option, + id: option.value || NO_WEBHOOK, label: t(option.label), })), [t] @@ -190,46 +200,61 @@ const TeamsSubscription = ({ entity: t('label.subscription'), })} onCancel={() => setEditSubscription(false)} - onOk={form.submit}> - -
- + onOk={form.handleSubmit(handleSave)}> + + + {({ field }) => ( + )} + + + isWebhookEmpty || + isValidUrl(value) || + t('message.endpoint-should-be-valid'), + }}> + {({ field, fieldState }) => ( - -
-
+ )} + + )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.test.tsx index 7d0fa764b1379..65d3410bc93be 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamsHeaderSection/TeamsSubscription.test.tsx @@ -56,7 +56,48 @@ describe('TeamsSubscription', () => { fireEvent.click(saveButton); }); - expect(mockUpdateTeamSubscription).toHaveBeenCalled(); + // "None" is the default webhook, which clears the subscription. + expect(mockUpdateTeamSubscription).toHaveBeenCalledWith(undefined); + }); + + it('should save the existing webhook and endpoint', async () => { + render( + + ); + await act(async () => { + fireEvent.click(screen.getByTestId('edit-team-subscription')); + }); + await act(async () => { + fireEvent.click(screen.getByText('label.confirm')); + }); + + expect(mockUpdateTeamSubscription).toHaveBeenCalledWith({ + webhook: 'slack', + endpoint: 'https://hooks.slack.com/x', + }); + }); + + it('should reject an invalid endpoint', async () => { + render( + + ); + await act(async () => { + fireEvent.click(screen.getByTestId('edit-team-subscription')); + }); + await act(async () => { + fireEvent.click(screen.getByText('label.confirm')); + }); + + expect(mockUpdateTeamSubscription).not.toHaveBeenCalled(); + expect( + screen.getByText('message.endpoint-should-be-valid') + ).toBeInTheDocument(); }); it('should render no data when no subscription', async () => { From 3ae88f62598880e0113887cad9a85cc9793af97a Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 08:12:02 +0530 Subject: [PATCH 19/32] feat(ui): share the core custom property form and permission debugger with classic settings The add-custom-property fields move out of the AI CustomPropertiesAddPage into a shared CustomPropertyFormFields component plus payload utils, and the classic AddCustomProperty drawer/page now renders them on react-hook-form. The classic permission debugger page renders the AI AccessControlDebuggerPanel instead of its antd copy. --- .../resources/ui/eslint-suppressions.json | 15 - .../ui/playwright/utils/customProperty.ts | 122 ++-- .../AddCustomProperty.test.tsx | 22 +- .../AddCustomProperty/AddCustomProperty.tsx | 590 +++--------------- .../AdminPermissionDebugger.component.tsx | 523 +--------------- .../AdminPermissionDebugger.style.less | 100 --- .../AdminPermissionDebugger.test.tsx | 70 +-- .../CustomPropertiesAddPage.tsx | 376 +---------- .../CustomPropertiesPanel.constants.ts | 7 + .../CustomPropertiesPanel.utils.test.ts | 55 +- .../CustomPropertiesPanel.utils.ts | 69 +- .../CustomPropertyFormFields.tsx | 344 ++++++++++ .../CustomPropertiesPageV1.tsx | 18 +- 13 files changed, 687 insertions(+), 1624 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.style.less create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertyFormFields.tsx diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 07f1f38ffd5cd..ad3236654e77a 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1243,11 +1243,6 @@ "count": 2 } }, - "src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 2 - } - }, "src/components/Settings/Services/Ingestion/AddIngestionButton.interface.ts": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1293,11 +1288,6 @@ "count": 2 } }, - "src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 5 - } - }, "src/components/Settings/Users/CreateUser/CreateUser.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 1 @@ -1588,11 +1578,6 @@ "count": 1 } }, - "src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/pages/CustomizeAppModeSidebarPage/CustomizeAppModeSidebarPage.tsx": { "openmetadata-antd/no-antd-import": { "count": 3 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts index 01c97662f82bf..53b0f09b09bf2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts @@ -11,39 +11,39 @@ * limitations under the License. */ import { - APIRequestContext, - expect, - Locator, - Page, - Response, + APIRequestContext, + expect, + Locator, + Page, + Response } from '@playwright/test'; import { - CUSTOM_PROPERTY_INVALID_NAMES, - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR, - ENTITY_REFERENCE_PROPERTIES, - NAME_SUFFIX, + CUSTOM_PROPERTY_INVALID_NAMES, + CUSTOM_PROPERTY_NAME_VALIDATION_ERROR, + ENTITY_REFERENCE_PROPERTIES, + NAME_SUFFIX } from '../constant/customProperty'; import { SidebarItem } from '../constant/sidebar'; import { - EntityTypeEndpoint, - ENTITY_PATH, + EntityTypeEndpoint, + ENTITY_PATH } from '../support/entity/Entity.interface'; import { UserClass } from '../support/user/UserClass'; import { selectOption, showAdvancedSearchDialog } from './advancedSearch'; import { CODE_EDITOR_CONTENT, typeInCodeEditor } from './codeEditor'; import { - clickIgnoringToasts, - descriptionBoxReadOnly, - fillDescriptionBox, - getDescriptionBox, - selectOptionWithRetry, - uuid, + clickIgnoringToasts, + descriptionBoxReadOnly, + fillDescriptionBox, + getDescriptionBox, + selectOptionWithRetry, + uuid } from './common'; import { pickDateInCorePicker } from './dateTime'; import { waitForAllLoadersToDisappear } from './entity'; import { - navigateToEntityPanelTab, - navigateToExploreAndSelectTable, + navigateToEntityPanelTab, + navigateToExploreAndSelectTable } from './entityPanel'; import { sidebarClick } from './sidebar'; import { waitForAntOverlayToOpen } from './waitHelpers'; @@ -735,53 +735,58 @@ export const addCustomPropertiesForEntity = async ({ // Click the switch to show service doc panel await page.locator('[data-testid="show-side-panel-switch"]').click(); + const form = page.getByTestId('custom-property-form'); + const nameInput = form.getByTestId('custom-property-name').getByRole('textbox'); + // Validation check — name must start with a letter/number and must not contain: " * : ^ $ \ < > & ~ / - await page.fill( - '[data-testid="name"]', - CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_COLON - ); + await nameInput.fill(CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_COLON); - await expect(page.locator('#name_help')).toContainText( - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR - ); + await expect( + form.getByText(CUSTOM_PROPERTY_NAME_VALIDATION_ERROR) + ).toBeVisible(); // Correct name - await page.fill('[data-testid="name"]', propertyName); + await nameInput.fill(propertyName); // displayName - await page.fill('[data-testid="display-name"]', propertyName); + await form + .getByTestId('custom-property-display-name') + .getByRole('textbox') + .fill(propertyName); // Select custom type await selectOptionWithRetry( - page.locator('[data-testid="propertyType"]'), + form.getByTestId('custom-property-type').getByRole('button'), page.getByRole('option', { name: customType, exact: true }) ); // Enum configuration if (customType === 'Enum' && enumConfig) { + const enumInput = form + .getByTestId('custom-property-enum-config') + .locator('input'); for (const val of enumConfig.values) { - const enumInput = page.locator(String.raw`#root\/enumConfig`); - await enumInput.clear(); - await enumInput.pressSequentially(val, { delay: 50 }); + await enumInput.fill(val); await enumInput.press('Enter'); await expect(enumInput).toHaveValue(''); } + await page.keyboard.press('Escape'); if (enumConfig.multiSelect) { - await page.getByTestId('multiSelect').click(); + await form.getByTestId('custom-property-multi-select').click(); } } // Table configuration if (customType === 'Table' && tableConfig) { + const columnInput = form + .getByTestId('custom-property-columns') + .locator('input'); for (const val of tableConfig.columns) { - const columnInput = page.locator(String.raw`#root\/columns`); - await expect(columnInput).toBeVisible(); - await columnInput.click(); - await columnInput.clear(); - await columnInput.pressSequentially(val, { delay: 100 }); + await columnInput.fill(val); await columnInput.press('Enter'); - await expect(columnInput).toHaveValue(''); // Verify input is consumed + await expect(columnInput).toHaveValue(''); } + await page.keyboard.press('Escape'); } // Entity reference configuration @@ -789,54 +794,31 @@ export const addCustomPropertiesForEntity = async ({ ENTITY_REFERENCE_PROPERTIES.includes(customType) && entityReferenceConfig ) { + const refInput = form + .getByTestId('custom-property-entity-ref-config') + .locator('input'); for (const val of entityReferenceConfig) { - await page.click(String.raw`#root\/entityReferenceConfig`); - await page.keyboard.type(val); - - // CRITICAL: Use :visible selector chain pattern (Rule 4 from deflake guide) - const option = page - .locator('.ant-select-dropdown:visible') - .locator(`[title="${val}"]`); - await expect(option).toBeVisible(); - await option.click(); - - // Close the dropdown by pressing Escape - await page.keyboard.press('Escape'); - - // Wait for dropdown to close - await expect(page.locator('.ant-select-dropdown')).toBeHidden(); - - // Verify the selection was applied - await expect( - page.locator(String.raw`#root\/entityReferenceConfig_list`) - ).not.toBeVisible(); + await refInput.click(); + await refInput.fill(val); + await page.getByRole('option', { name: val, exact: true }).click(); } + await page.keyboard.press('Escape'); } // Format configuration if (['Date', 'Date Time', 'Time'].includes(customType) && formatConfig) { await selectOptionWithRetry( - page.getByTestId('formatConfig'), + form.getByTestId('custom-property-format-config').getByRole('button'), page.getByRole('option', { name: formatConfig, exact: true }) ); } // Description - await expect( - page.locator(String.raw`#root\/entityReferenceConfig_list`) - ).not.toBeVisible(); - await getDescriptionBox(page).waitFor({ state: 'visible' }); await expect(getDescriptionBox(page)).toHaveCount(1); await getDescriptionBox(page).click(); await page.keyboard.type(customPropertyData.description, { delay: 50 }); - // Click on name field to blur description and trigger validation without closing modal - await page.click('[data-testid="name"]'); - - await expect(page.locator('#propertyType_help')).not.toBeVisible(); - await expect(page.locator('#description_help')).not.toBeVisible(); - const createButton = page.locator('[data-testid="create-button"]'); await expect(createButton).toBeVisible(); const createPropertyPromise = page.waitForResponse( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx index 5036a3122a4b7..412d775fabd86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx @@ -11,7 +11,13 @@ * limitations under the License. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createInstance } from 'i18next'; import { HelmetProvider } from 'react-helmet-async'; @@ -20,9 +26,9 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Category, Type } from '../../../../generated/entity/type'; import english from '../../../../locale/languages/en-us.json'; import { - addPropertyToEntity, - getTypeByFQN, - getTypeListByCategory, + addPropertyToEntity, + getTypeByFQN, + getTypeListByCategory } from '../../../../rest/metadataTypeAPI'; import AddCustomProperty from './AddCustomProperty'; @@ -45,6 +51,8 @@ const tableType: Type = { }; const invalidNameMessage = String.raw`Name must start with a letter or number. Invalid characters: " * : ^ $ \ < > & ~ /`; const i18n = createInstance(); +const getNameInput = () => + within(screen.getByTestId('custom-property-name')).getByRole('textbox'); const renderForm = (element = ) => { render( @@ -137,7 +145,7 @@ describe('AddCustomProperty form validation', () => { renderForm(); await fillRequiredFields(user); - fireEvent.change(screen.getByTestId('name'), { target: { value: name } }); + fireEvent.change(getNameInput(), { target: { value: name } }); fireEvent.submit(screen.getByTestId('custom-property-form')); expect(await screen.findByText(invalidNameMessage)).toBeVisible(); @@ -150,7 +158,7 @@ describe('AddCustomProperty form validation', () => { renderForm(); await fillRequiredFields(user); - fireEvent.change(screen.getByTestId('name'), { + fireEvent.change(getNameInput(), { target: { value: 'a'.repeat(257) }, }); fireEvent.submit(screen.getByTestId('custom-property-form')); @@ -187,7 +195,7 @@ describe('AddCustomProperty form validation', () => { renderForm(); await fillRequiredFields(user); - const nameInput = screen.getByTestId('name'); + const nameInput = getNameInput(); fireEvent.change(nameInput, { target: { value: 'invalid:name' } }); fireEvent.submit(screen.getByTestId('custom-property-form')); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx index f2fa7cf29ac71..80c0975c921d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx @@ -10,34 +10,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { Badge, Box, Button, - SelectItemType, + HookForm, SlideoutMenu, Toggle, Typography, } from '@openmetadata/ui-core-components'; -import { Form, FormInstance } from 'antd'; import { AxiosError } from 'axios'; -import { ReactComponent as ColumnIcon } from '../../../../assets/svg/entity/column.svg'; - -import { isArray, isUndefined, map, omit, omitBy, startCase } from 'lodash'; +import { isUndefined } from 'lodash'; import { FocusEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { - CUSTOM_PROPERTIES_ICON_MAP, - ENTITY_REFERENCE_OPTIONS, - PROPERTY_TYPES_WITH_ENTITY_REFERENCE, - PROPERTY_TYPES_WITH_FORMAT, - SUPPORTED_FORMAT_MAP, - TABLE_TYPE_CUSTOM_PROPERTY, -} from '../../../../constants/CustomProperty.constants'; +import { ReactComponent as ColumnIcon } from '../../../../assets/svg/entity/column.svg'; import { GlobalSettingsMenuCategory } from '../../../../constants/GlobalSettings.constants'; -import { CUSTOM_PROPERTY_NAME_REGEX } from '../../../../constants/regex.constants'; import { CUSTOM_PROPERTY_CATEGORY, OPEN_METADATA, @@ -46,18 +35,11 @@ import { EntityType } from '../../../../enums/entity.enum'; import { ServiceCategory } from '../../../../enums/service.enum'; import { Category, Type } from '../../../../generated/entity/type'; import { CustomProperty } from '../../../../generated/type/customProperty'; -import { - FieldProp, - FieldTypes, - FormItemLayout, -} from '../../../../interface/FormUtils.interface'; import { addPropertyToEntity, getTypeByFQN, getTypeListByCategory, } from '../../../../rest/metadataTypeAPI'; -import { getEntityName } from '../../../../utils/EntityNameUtils'; -import { generateFormFields } from '../../../../utils/formUtils'; import { getSettingOptionByEntityType } from '../../../../utils/GlobalSettingsUtils'; import { getSettingPath } from '../../../../utils/RouterUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; @@ -65,9 +47,11 @@ import { useRequiredParams } from '../../../../utils/useRequiredParams'; import ResizablePanels from '../../../common/ResizablePanels/ResizablePanels'; import ServiceDocPanel from '../../../common/ServiceDocPanel/ServiceDocPanel'; import TitleBreadcrumb from '../../../common/TitleBreadcrumb/TitleBreadcrumb.component'; +import { AddCustomPropertyFormValues } from '../../../discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.types'; +import { toCustomPropertyPayload } from '../../../discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils'; +import CustomPropertyFormFields from '../../../discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertyFormFields'; interface AddCustomPropertyProps { - formRef?: FormInstance; onCancel?: () => void; onSubmit?: (data: CustomProperty) => void; loading?: boolean; @@ -76,15 +60,15 @@ interface AddCustomPropertyProps { onClose?: () => void; } -/** - * Column name the earlier table-type custom property editor used as its - * internal row key and stripped on save. It stays reserved so a user column - * never takes that name and loses its data to an older client. - */ -const RESERVED_TABLE_COLUMN_NAMES = ['__row_id__']; +const DEFAULT_VALUES: AddCustomPropertyFormValues = { + name: '', + displayName: '', + propertyType: null, + description: '', + multiSelect: false, +}; const AddCustomProperty = ({ - formRef, onSubmit, loading, entityType: entityTypeProp, @@ -92,8 +76,11 @@ const AddCustomProperty = ({ onClose, onCancel, }: AddCustomPropertyProps) => { - const [localForm] = Form.useForm(); - const form = formRef ?? localForm; + // Validate while typing so the drawer's Create button tracks validity. + const form = useForm({ + mode: 'onChange', + defaultValues: DEFAULT_VALUES, + }); const { entityType: entityTypeParam } = useRequiredParams<{ entityType: EntityType; }>(); @@ -101,14 +88,11 @@ const AddCustomProperty = ({ const navigate = useNavigate(); const { t } = useTranslation(); const [typeDetail, setTypeDetail] = useState(); - - const [propertyTypes, setPropertyTypes] = useState>([]); + const [propertyTypes, setPropertyTypes] = useState([]); const [activeField, setActiveField] = useState(''); const [isCreating, setIsCreating] = useState(false); - const [isFormInvalid, setIsFormInvalid] = useState(true); const [showSidePanel, setShowSidePanel] = useState(false); - - const watchedPropertyType = Form.useWatch('propertyType', form); + const [descriptionKey, setDescriptionKey] = useState(0); const slashedBreadcrumb = useMemo( () => [ @@ -130,56 +114,9 @@ const AddCustomProperty = ({ url: '', }, ], - [entityType] + [entityType, t] ); - const propertyTypeOptions: SelectItemType[] = useMemo(() => { - return map(propertyTypes, (type) => { - const Icon = - CUSTOM_PROPERTIES_ICON_MAP[ - type.name as keyof typeof CUSTOM_PROPERTIES_ICON_MAP - ]; - - // Remove -cp from the name and convert to start case - const title = startCase(getEntityName(type).replaceAll('-cp', '')); - - return { - id: type.id ?? '', - label: title, - icon: Icon, - }; - }); - }, [propertyTypes]); - - const { - hasEnumConfig, - hasFormatConfig, - hasEntityReferenceConfig, - watchedTypeName, - hasTableTypeConfig, - } = useMemo(() => { - const watchedTypeName = - propertyTypes.find((type) => type.id === watchedPropertyType)?.name ?? ''; - - const hasEnumConfig = watchedTypeName === 'enum'; - - const hasTableTypeConfig = watchedTypeName === TABLE_TYPE_CUSTOM_PROPERTY; - - const hasFormatConfig = - PROPERTY_TYPES_WITH_FORMAT.includes(watchedTypeName); - - const hasEntityReferenceConfig = - PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(watchedTypeName); - - return { - hasEnumConfig, - hasFormatConfig, - hasEntityReferenceConfig, - watchedTypeName, - hasTableTypeConfig, - }; - }, [watchedPropertyType, propertyTypes]); - const fetchPropertyType = async () => { try { const response = await getTypeListByCategory(Category.Field); @@ -190,12 +127,8 @@ const AddCustomProperty = ({ }; const fetchTypeDetail = async (typeFQN: string) => { - if (!typeFQN) { - return; - } try { - const response = await getTypeByFQN(typeFQN); - setTypeDetail(response); + setTypeDetail(await getTypeByFQN(typeFQN)); } catch (error) { showErrorToast(error as AxiosError); } @@ -209,352 +142,16 @@ const AddCustomProperty = ({ } }, [navigate, onCancel]); - const handleFieldFocus = useCallback((event: FocusEvent) => { + const handleFieldFocus = useCallback((event: FocusEvent) => { const isDescription = event.target.classList.contains('ProseMirror'); - const fieldName = isDescription ? 'root/description' : event.target.id; - - setActiveField(fieldName); - }, []); - - const handleFieldsChange = useCallback(() => { - // Internal validation logic for Drawer - const hasErrors = form.getFieldsError().some(({ errors }) => errors.length); - - const { name, propertyType, description } = form.getFieldsValue(); - const isNameValid = !!name; - const isPropertyTypeValid = !!propertyType; - const isDescriptionValid = !!description; - - setIsFormInvalid( - hasErrors || !isNameValid || !isPropertyTypeValid || !isDescriptionValid - ); - }, [form]); - - useEffect(() => { - if (entityType) { - fetchTypeDetail(entityType); - } - }, [entityType]); - - useEffect(() => { - fetchPropertyType(); + setActiveField(isDescription ? 'root/description' : event.target.id); }, []); - const getAppliesToField = useCallback(() => { - return ( - - - {t('label.table-column')} - - ); - }, [t]); - - const supportedFormats = useMemo(() => { - return ( - SUPPORTED_FORMAT_MAP[ - watchedTypeName as keyof typeof SUPPORTED_FORMAT_MAP - ] ?? [] - ); - }, [watchedTypeName]); - - const formFields: FieldProp[] = useMemo( - () => [ - { - name: 'name', - required: true, - label: t('label.name'), - id: 'root/name', - type: FieldTypes.UT_TEXT, - props: { - 'data-testid': 'name', - autoComplete: 'off', - }, - placeholder: t('label.name'), - rules: [ - { - max: 256, - message: t('message.entity-size-in-between', { - entity: t('label.name'), - min: 1, - max: 256, - }), - }, - { - pattern: CUSTOM_PROPERTY_NAME_REGEX, - message: t('message.custom-property-name-validation'), - }, - ], - }, - { - name: 'displayName', - id: 'root/displayName', - label: t('label.display-name'), - required: false, - placeholder: t('label.display-name'), - type: FieldTypes.UT_TEXT, - props: { - 'data-testid': 'display-name', - }, - }, - { - name: 'propertyType', - required: true, - label: t('label.type'), - id: 'root/propertyType', - type: FieldTypes.UT_SELECT, - placeholder: `${t('label.select-field', { - field: t('label.type'), - })}`, - props: { - 'data-testid': 'propertyType', - items: propertyTypeOptions, - }, - }, - ...(entityType === EntityType.TABLE_COLUMN - ? [ - { - name: 'appliesTo', - required: false, - label: t('label.applies-to'), - id: 'root/appliesTo', - type: FieldTypes.COMPONENT, - props: { - 'data-testid': 'applies-to', - children: getAppliesToField(), - }, - }, - ] - : []), - ], - [getAppliesToField, propertyTypeOptions, t, entityType] - ); - - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'description', - required: true, - label: t('label.description'), - id: 'root/description', - type: FieldTypes.DESCRIPTION, - props: { - 'data-testid': 'description', - }, - }), - [t] - ); - - const enumConfigField: FieldProp = useMemo( - () => ({ - name: 'enumConfig', - required: false, - label: t('label.enum-value-plural'), - id: 'root/enumConfig', - type: FieldTypes.SELECT, - props: { - 'data-testid': 'enumConfig', - mode: 'tags', - placeholder: t('label.enum-value-plural'), - open: false, - className: 'trim-select', - getPopupContainer: (triggerNode: HTMLElement) => - triggerNode.parentElement ?? document.body, - }, - rules: [ - { - required: true, - message: t('label.field-required', { - field: t('label.enum-value-plural'), - }), - }, - ], - }), - [t] - ); - - const multiSelectField: FieldProp = useMemo( - () => ({ - name: 'multiSelect', - label: t('label.multi-select'), - type: FieldTypes.UT_SWITCH, - required: false, - props: { - 'data-testid': 'multiSelect', - }, - id: 'root/multiSelect', - formItemLayout: FormItemLayout.HORIZONTAL, - }), - [t] - ); - - const formatConfigField: FieldProp = useMemo( - () => ({ - name: 'formatConfig', - required: false, - label: t('label.format'), - id: 'root/formatConfig', - type: FieldTypes.UT_SELECT, - props: { - 'data-testid': 'formatConfig', - items: supportedFormats.map((option) => ({ - id: option, - label: option, - })), - }, - placeholder: t('label.format'), - rules: [ - { - validator: (_, value) => { - if (value && !supportedFormats.includes(value)) { - return Promise.reject( - t('label.field-invalid', { - field: t('label.format'), - }) - ); - } - - return Promise.resolve(); - }, - }, - ], - }), - [supportedFormats, t] - ); - - const entityReferenceConfigField: FieldProp = useMemo( - () => ({ - name: 'entityReferenceConfig', - required: true, - label: t('label.entity-reference-types'), - id: 'root/entityReferenceConfig', - type: FieldTypes.SELECT, - props: { - mode: 'multiple', - options: ENTITY_REFERENCE_OPTIONS, - 'data-testid': 'entityReferenceConfig', - placeholder: `${t('label.select-field', { - field: t('label.type'), - })}`, - getPopupContainer: (triggerNode: HTMLElement) => - triggerNode.parentElement ?? document.body, - }, - }), - [t] - ); - - const tableTypePropertyConfig: FieldProp[] = useMemo( - () => [ - { - name: 'columns', - required: true, - label: t('label.column-plural'), - id: 'root/columns', - type: FieldTypes.SELECT, - props: { - 'data-testid': 'columns', - mode: 'tags', - placeholder: t('label.column-plural'), - getPopupContainer: (triggerNode: HTMLElement) => - triggerNode.parentElement ?? document.body, - }, - rules: [ - { - required: true, - validator: async (_, value) => { - if (isArray(value)) { - if (value.length > 3) { - throw t('message.maximum-count-allowed', { - count: 3, - label: t('label.column-plural'), - }); - } - if ( - value.some((c: string) => - RESERVED_TABLE_COLUMN_NAMES.includes(c) - ) - ) { - throw t('message.reserved-column-name', { - name: RESERVED_TABLE_COLUMN_NAMES.join(', '), - }); - } - } else { - throw t('label.field-required', { - field: t('label.column-plural'), - }); - } - }, - }, - ], - }, - ], - [t] - ); - - const handleFormSubmit = async ( - data: Exclude & { - propertyType: string; - enumConfig: string[]; - formatConfig: string; - entityReferenceConfig: string[]; - multiSelect?: boolean; - columns: string[]; - } - ) => { + const handleFormSubmit = async (data: AddCustomPropertyFormValues) => { if (isUndefined(typeDetail)) { return; } - - let customPropertyConfig; - - if (hasEnumConfig) { - customPropertyConfig = { - config: { - multiSelect: Boolean(data?.multiSelect), - values: data.enumConfig, - }, - }; - } - - if (hasFormatConfig) { - customPropertyConfig = { - config: data.formatConfig, - }; - } - - if (hasEntityReferenceConfig) { - customPropertyConfig = { - config: data.entityReferenceConfig, - }; - } - - if (hasTableTypeConfig) { - customPropertyConfig = { - config: { - columns: data.columns, - }, - }; - } - - const payload = omitBy( - { - ...omit(data, [ - 'multiSelect', - 'formatConfig', - 'entityReferenceConfig', - 'enumConfig', - 'columns', - ]), - propertyType: { - id: data.propertyType, - type: 'type', - }, - customPropertyConfig, - }, - isUndefined - ) as unknown as CustomProperty; + const payload = toCustomPropertyPayload(data, propertyTypes); if (onSubmit) { onSubmit(payload); @@ -564,7 +161,7 @@ const AddCustomProperty = ({ try { setIsCreating(true); - await addPropertyToEntity(typeDetail?.id ?? '', payload); + await addPropertyToEntity(typeDetail.id ?? '', payload); navigate(-1); } catch (error) { showErrorToast(error as AxiosError); @@ -573,71 +170,80 @@ const AddCustomProperty = ({ } }; - // Fields shown conditionally based on the selected property type; computed - // once here so the render body doesn't carry each type-specific branch. - const conditionalTypeFields: FieldProp[] = useMemo(() => { - const fields: FieldProp[] = []; + const submit = form.handleSubmit(handleFormSubmit); - if (hasEnumConfig) { - fields.push(enumConfigField, multiSelectField); - } - if (hasFormatConfig) { - fields.push(formatConfigField); - } - if (hasEntityReferenceConfig) { - fields.push(entityReferenceConfigField); - } - if (hasTableTypeConfig) { - fields.push(...tableTypePropertyConfig); + useEffect(() => { + if (entityType) { + fetchTypeDetail(entityType); } + }, [entityType]); - return fields; - }, [ - hasEnumConfig, - hasFormatConfig, - hasEntityReferenceConfig, - hasTableTypeConfig, - enumConfigField, - multiSelectField, - formatConfigField, - entityReferenceConfigField, - tableTypePropertyConfig, - ]); + useEffect(() => { + fetchPropertyType(); + }, []); + + // The drawer stays mounted between openings, so start each one clean. + useEffect(() => { + if (open) { + form.reset(DEFAULT_VALUES); + setDescriptionKey((key) => key + 1); + } + }, [open, form]); const formContent = ( -
- {generateFormFields(formFields)} - {generateFormFields(conditionalTypeFields)} - {generateFormFields([descriptionField])} - {isUndefined(open) && ( - - - - - - + // Tracks the focused field for the doc panel; focus events bubble. +
+ + + + {t('label.applies-to')} + + + + {t('label.table-column')} + + + ) + } + /> + {isUndefined(open) && ( + + + + + + + - - )} - + )} + +
); if (!isUndefined(open)) { @@ -714,9 +320,9 @@ const AddCustomProperty = ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx index 2d960d6da5a98..7d0dc8ce1528f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx @@ -10,534 +10,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { - Box, - Button, - Card, - Grid, - Typography, -} from '@openmetadata/ui-core-components'; -import { AutoComplete, Form, Input, Select, Spin } from 'antd'; -import { AxiosError } from 'axios'; -import { debounce } from 'lodash'; -import React, { useCallback, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { GlobalSettingsMenuCategory } from '../../../../constants/GlobalSettings.constants'; -import { SearchIndex } from '../../../../enums/search.enum'; -import { Operation } from '../../../../generated/entity/policies/accessControl/resourcePermission'; -import { - evaluatePermission, - getPermissionDebugInfo, - PermissionDebugInfo, - PermissionEvaluationDebugInfo, -} from '../../../../rest/permissionAPI'; -import { searchQuery } from '../../../../rest/searchAPI'; -import { getLayoutGutter } from '../../../../utils/common/layout.utils'; import { getSettingPageEntityBreadCrumb } from '../../../../utils/GlobalSettingsUtils'; -import { showErrorToast } from '../../../../utils/ToastUtils'; import TitleBreadcrumb from '../../../common/TitleBreadcrumb/TitleBreadcrumb.component'; -import { TitleBreadcrumbProps } from '../../../common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import AccessControlDebuggerPanel from '../../../discovery/personal-space/Profile/tabs/access-control/AccessControlDebuggerPanel'; import PageLayoutV1 from '../../../PageLayoutV1/PageLayoutV1'; -import UserPermissions from '../UsersProfile/UserPermissions/UserPermissions.component'; -import { - PERMISSION_OPERATIONS, - PERMISSION_RESOURCES, -} from './AdminPermissionDebugger.constants'; -import './AdminPermissionDebugger.style.less'; - -const { Option } = Select; -interface EvaluationFormValues { - resource: string; - operation: Operation; - resourceId?: string; -} - -const AdminPermissionDebugger: React.FC = () => { +const AdminPermissionDebugger = () => { const { t } = useTranslation(); - const [selectedUsername, setSelectedUsername] = useState(''); - const [permissionInfo, setPermissionInfo] = useState(); - const [evaluationInfo, setEvaluationInfo] = - useState(); - const [loadingPermissions, setLoadingPermissions] = useState(false); - const [loadingEvaluation, setLoadingEvaluation] = useState(false); - const [userOptions, setUserOptions] = useState< - { value: string; label: string }[] - >([]); - const [searchingUsers, setSearchingUsers] = useState(false); - const [form] = Form.useForm(); - - const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( + const breadcrumbs = useMemo( () => getSettingPageEntityBreadCrumb( GlobalSettingsMenuCategory.ACCESS, t('label.permission-debugger') ), - [] - ); - - const searchUsers = useCallback( - debounce(async (searchText: string) => { - if (!searchText) { - setUserOptions([]); - - return; - } - - setSearchingUsers(true); - try { - const response = await searchQuery({ - query: searchText, - pageNumber: 1, - pageSize: 10, - filters: '', - sortField: '', - sortOrder: '', - searchIndex: SearchIndex.USER, - includeDeleted: false, - trackTotalHits: false, - fetchSource: true, - includeFields: ['name', 'displayName'], - }); - - const options = response.hits.hits.map( - (hit: { - _source: { - name: string; - displayName?: string; - }; - }) => ({ - value: hit._source.name, - label: `${hit._source.displayName || hit._source.name} (${ - hit._source.name - })`, - }) - ); - - setUserOptions(options); - } catch (error) { - // Reset user options on error - this allows the user to try again - setUserOptions([]); - } finally { - setSearchingUsers(false); - } - }, 300), - [] + [t] ); - const handleUserSelect = async (username: string) => { - setSelectedUsername(username); - setLoadingPermissions(true); - try { - const response = await getPermissionDebugInfo(username); - setPermissionInfo(response.data); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setLoadingPermissions(false); - } - }; - - const handleEvaluate = async (values: EvaluationFormValues) => { - if (!selectedUsername) { - showErrorToast(t('message.select-user-first')); - - return; - } - setLoadingEvaluation(true); - try { - const response = await evaluatePermission( - selectedUsername, - values.resource, - values.operation, - values.resourceId - ); - setEvaluationInfo(response.data); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setLoadingEvaluation(false); - } - }; - - const renderEvaluationResult = () => { - if (!evaluationInfo) { - return null; - } - - return ( - - - - -
- - {t('label.decision') + ': '}{' '} - {evaluationInfo.finalDecision} - - - {t('label.user')} {evaluationInfo.user.name}{' '} - {t('label.is')}{' '} - - {evaluationInfo.allowed - ? t('label.allowed') - : t('label.denied')} - {' '} - {t('label.to-perform')}{' '} - {evaluationInfo.operation} {t('label.on')}{' '} - {evaluationInfo.resource} - {evaluationInfo.resourceId && ( - {` (${evaluationInfo.resourceId})`} - )} - -
- - {evaluationInfo.summary && ( -
- - - {t('label.policies-evaluated')}:{' '} - {evaluationInfo.summary.totalPoliciesEvaluated} - - - {t('label.rules-evaluated')}:{' '} - {evaluationInfo.summary.totalRulesEvaluated} - - - {t('label.matching-rule-plural')}:{' '} - {evaluationInfo.summary.matchingRules} - - - {t('label.allow-rule-plural')}:{' '} - {evaluationInfo.summary.allowRules} - - - {t('label.deny-rule-plural')}:{' '} - {evaluationInfo.summary.denyRules} - - - {t('label.time-ms', { - milliseconds: evaluationInfo.summary.evaluationTimeMs, - })} - - -
- )} - -
- - {t('label.evaluation-step-plural')}: - - {evaluationInfo.evaluationSteps.map((step) => ( - - - - {t('label.step')}{' '} - {step.stepNumber} - {' : '} - - - {step.policy.name} - - - {' - '} {t('label.rule') + ': '}{' '} - {step.rule} - - - } - /> - - - - {t('label.source') + ': '}{' '} - {step.source}{' '} - ({step.sourceEntity.name}) - - - {t('label.effect')}:{' '} - - {step.effect} - - - - {t('label.matched')}:{' '} - - {step.matched ? t('label.yes') : t('label.no')} - - - - {step.matchReason} - - {step.conditionEvaluations.length > 0 && ( -
- - {t('label.condition-plural')}: - - {step.conditionEvaluations.map((cond) => ( -
- - {cond.condition} - - - {' → '} - - {cond.result - ? t('label.true') - : t('label.false')} - - - - - (${cond.evaluationDetails}) - - -
- ))} -
- )} -
-
-
- ))} -
- - {evaluationInfo.summary?.reasonsForDecision && ( -
- - {t('label.reasons-for-decision')}: - - {evaluationInfo.summary.reasonsForDecision.map((reason) => ( - - {'• '} - {reason} - - ))} -
- )} -
-
-
- ); - }; - return ( - - - - - - - - - -
- - {t('label.select-user-to-debug-permissions')} - -
- - : null - } - options={userOptions} - placeholder={t('label.search-entity', { - entity: t('label.user'), - })} - style={{ maxWidth: 400 }} - onSearch={searchUsers} - onSelect={handleUserSelect} - /> - - {selectedUsername && ( - <> - - {t('label.selected-entity', { - entity: t('label.user-lowercase'), - })} - {': '} - - {selectedUsername} - - - - )} -
-
-
- - - - - {!selectedUsername ? ( - - {t('message.select-user-first')} - - ) : ( -
- - - - - - - - - - - - - - - - - - - -
- )} -
-
- - {renderEvaluationResult()} - - {loadingPermissions && ( -
- -
- )} - - {permissionInfo && !loadingPermissions && ( - - - - - - - )} -
-
+ +
+ +
+
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.style.less b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.style.less deleted file mode 100644 index 47c57c2d2b6ed..0000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.style.less +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2025 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@import (reference) '../../../../styles/variables.less'; - -.admin-permission-debugger { - .ant-typography-h5, - h5 { - margin-bottom: var(--om-space-8); - } - - .loading-container { - display: flex; - justify-content: center; - align-items: center; - min-height: 300px; - } - - .w-full { - width: 100%; - } - - .m-t-md { - margin-top: var(--om-space-16); - } - - .m-b-md { - margin-bottom: var(--om-space-16); - } - - .evaluation-summary { - padding: var(--om-space-16); - background-color: @grey-1; - border-radius: var(--om-radius-lg); - margin-bottom: var(--om-space-16); - - h4 { - margin-bottom: var(--om-space-8); - font-weight: var(--om-font-weight-semibold); - } - } - - .evaluation-stats { - padding: var(--om-space-12); - background-color: @grey-2; - border-radius: var(--om-radius-sm); - margin-bottom: var(--om-space-16); - - :is(.ant-space-item, .layout-space-item) { - padding: 0 var(--om-space-8); - border-right: 1px solid @grey-4; - - &:last-child { - border-right: none; - } - } - } - - .evaluation-steps { - margin-top: var(--om-space-24); - - .evaluation-step { - margin-bottom: var(--om-space-12); - - .condition-eval { - margin-left: var(--om-space-16); - padding: var(--om-space-4) var(--om-space-8); - background-color: @grey-2; - border-radius: var(--om-radius-sm); - margin-top: var(--om-space-4); - } - } - } - - .decision-reasons { - margin-top: var(--om-space-16); - padding: var(--om-space-16); - background-color: var(--om-legacy-color-f0f5ff); - border-radius: var(--om-radius-lg); - - h5 { - margin-bottom: var(--om-space-8); - } - - .decision-reason { - display: block; - margin-bottom: var(--om-space-4); - } - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx index 1dcbbf23925d5..174cfe595d1e1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx @@ -12,65 +12,27 @@ */ import { render, screen } from '@testing-library/react'; -import React from 'react'; import { HelmetProvider } from 'react-helmet-async'; import { MemoryRouter } from 'react-router-dom'; import AdminPermissionDebugger from './AdminPermissionDebugger.component'; -jest.mock('../../../../rest/searchAPI'); -jest.mock('../../../../rest/permissionAPI'); -jest.mock('../../../../utils/ToastUtils'); -jest.mock('../UsersProfile/UserPermissions/UserPermissions.component', () => ({ - __esModule: true, - default: jest.fn(() => ( -
User Permissions
- )), -})); - -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -const renderWithRouter = (component: React.ReactElement) => { - return render( - - {component} - - ); -}; +// The panel's own behaviour is covered by AccessControlDebuggerPanel.test.tsx. +jest.mock( + '../../../discovery/personal-space/Profile/tabs/access-control/AccessControlDebuggerPanel', + () => jest.fn(() =>
) +); describe('AdminPermissionDebugger', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should render the component', () => { - renderWithRouter(); - - expect(screen.getByText('label.permission-debugger')).toBeInTheDocument(); - expect( - screen.getByText('label.select-user-to-debug-permissions') - ).toBeInTheDocument(); - }); - - it('should render search input and evaluation form', () => { - renderWithRouter(); - - // Check for autocomplete - expect(screen.getByRole('combobox')).toBeInTheDocument(); - - // Check for cards - expect(screen.getByText('label.evaluate-permission')).toBeInTheDocument(); - expect(screen.getByText('message.select-user-first')).toBeInTheDocument(); - }); - - it('should render permission resources and operations', () => { - renderWithRouter(); - - // Check that constants are being used properly - expect(screen.queryByText('table')).not.toBeInTheDocument(); // Not visible until dropdown is opened - expect(screen.queryByText('ViewAll')).not.toBeInTheDocument(); // Not visible until dropdown is opened + it('should render the breadcrumb and the shared debugger panel', () => { + render( + + + + + + ); + + expect(screen.getByTestId('breadcrumb')).toBeInTheDocument(); + expect(screen.getByTestId('admin-permission-debugger')).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesAddPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesAddPage.tsx index ba2072c6f8c02..7853d96ab20ae 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesAddPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesAddPage.tsx @@ -12,91 +12,30 @@ */ import { - Box, - Button, - FieldProp, - FieldTypes, - FormField, - FormItemLabel, - getField, - HintText, - HookForm, - Typography, - useFieldDoc, + Box, + Button, + HookForm, + Typography } from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; -import { isArray, isUndefined, map, omit, omitBy, startCase } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { - CUSTOM_PROPERTIES_ICON_MAP, - ENTITY_REFERENCE_OPTIONS, - PROPERTY_TYPES_WITH_ENTITY_REFERENCE, - PROPERTY_TYPES_WITH_FORMAT, - SUPPORTED_FORMAT_MAP, - TABLE_TYPE_CUSTOM_PROPERTY, -} from '../../../../../../constants/CustomProperty.constants'; -import { CUSTOM_PROPERTY_NAME_REGEX } from '../../../../../../constants/regex.constants'; import { Category, Type } from '../../../../../../generated/entity/type'; -import { CustomProperty } from '../../../../../../generated/type/customProperty'; import { - addPropertyToEntity, - getTypeListByCategory, + addPropertyToEntity, + getTypeListByCategory } from '../../../../../../rest/metadataTypeAPI'; -import { getEntityName } from '../../../../../../utils/EntityNameUtils'; import { - showErrorToast, - showSuccessToast, + showErrorToast, + showSuccessToast } from '../../../../../../utils/ToastUtils'; -import RichTextEditor from '../../../../../common/RichTextEditor/RichTextEditor'; import { - AddCustomPropertyFormValues, - CustomPropertiesAddPageProps, - DescriptionFormFieldProps, + AddCustomPropertyFormValues, + CustomPropertiesAddPageProps } from './CustomPropertiesPanel.types'; -import { buildCustomPropertyConfig, toId } from './CustomPropertiesPanel.utils'; - -const DescriptionFormField: React.FC< - DescriptionFormFieldProps -> = ({ form, descriptionKey, initialValue = '' }) => { - const { t } = useTranslation(); - const descriptionDocProps = useFieldDoc({ - name: 'description', - label: t('label.description'), - doc: t('message.custom-property-description-help'), - }); - - return ( - - {({ field, fieldState }) => ( - - - - {fieldState.error?.message && ( - {fieldState.error.message} - )} - - )} - - ); -}; +import { toCustomPropertyPayload } from './CustomPropertiesPanel.utils'; +import CustomPropertyFormFields from './CustomPropertyFormFields'; const CustomPropertiesAddPage: React.FC = ({ entityType, @@ -119,11 +58,6 @@ const CustomPropertiesAddPage: React.FC = ({ }, }); - const watchedPropertyType = useWatch({ - control: form.control, - name: 'propertyType', - }); - const fetchPropertyTypes = useCallback(async () => { try { const response = await getTypeListByCategory(Category.Field); @@ -137,259 +71,18 @@ const CustomPropertiesAddPage: React.FC = ({ fetchPropertyTypes(); }, [fetchPropertyTypes]); - const propertyTypeOptions = useMemo( - () => - map(propertyTypes, (type) => { - const IconComp = - CUSTOM_PROPERTIES_ICON_MAP[ - type.name as keyof typeof CUSTOM_PROPERTIES_ICON_MAP - ]; - - return { - id: type.id ?? '', - label: startCase(getEntityName(type).replaceAll('-cp', '')), - icon: IconComp, - }; - }), - [propertyTypes] - ); - - const { - hasEnumConfig, - hasFormatConfig, - hasEntityReferenceConfig, - hasTableTypeConfig, - supportedFormats, - } = useMemo(() => { - // watchedPropertyType is a FormSelectItem object — extract the id to look up the type - const selectedId = toId(watchedPropertyType ?? undefined); - const found = propertyTypes.find((tp) => tp.id === selectedId); - const typeName = found?.name ?? ''; - - return { - hasEnumConfig: typeName === 'enum', - hasFormatConfig: PROPERTY_TYPES_WITH_FORMAT.includes(typeName), - hasEntityReferenceConfig: - PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(typeName), - hasTableTypeConfig: typeName === TABLE_TYPE_CUSTOM_PROPERTY, - supportedFormats: - SUPPORTED_FORMAT_MAP[typeName as keyof typeof SUPPORTED_FORMAT_MAP] ?? - [], - }; - }, [watchedPropertyType, propertyTypes]); - - const nameField: FieldProp = useMemo( - () => ({ - name: 'name', - label: t('label.name'), - type: FieldTypes.TEXT, - required: true, - placeholder: t('label.name'), - doc: t('message.custom-property-name-help'), - props: { 'data-testid': 'custom-property-name' }, - rules: { - required: t('label.field-required', { field: t('label.name') }), - maxLength: { - value: 256, - message: t('message.entity-size-in-between', { - entity: t('label.name'), - min: 1, - max: 256, - }), - }, - pattern: { - value: CUSTOM_PROPERTY_NAME_REGEX, - message: t('message.custom-property-name-validation'), - }, - }, - }), - [t] - ); - - const displayNameField: FieldProp = useMemo( - () => ({ - name: 'displayName', - label: t('label.display-name'), - type: FieldTypes.TEXT, - required: false, - placeholder: t('label.display-name'), - doc: t('message.custom-property-display-name-help'), - props: { 'data-testid': 'custom-property-display-name' }, - }), - [t] - ); - - const propertyTypeField: FieldProp = useMemo( - () => ({ - name: 'propertyType', - label: t('label.type'), - type: FieldTypes.SELECT, - required: true, - placeholder: t('label.select-field', { field: t('label.type') }), - doc: t('message.custom-property-type-help'), - rules: { - required: t('label.field-required', { field: t('label.type') }), - }, - props: { - 'data-testid': 'custom-property-type', - items: propertyTypeOptions, - }, - }), - [t, propertyTypeOptions] - ); - - const enumConfigField: FieldProp = useMemo( - () => ({ - name: 'enumConfig', - label: t('label.enum-value-plural'), - type: FieldTypes.MULTI_SELECT, - required: true, - placeholder: t('label.enum-value-plural'), - doc: t('message.custom-property-enum-config-help'), - rules: { - required: t('label.field-required', { - field: t('label.enum-value-plural'), - }), - }, - props: { - 'data-testid': 'custom-property-enum-config', - allowsCreation: true, - items: [], - }, - }), - [t] - ); - - const multiSelectField: FieldProp = useMemo( - () => ({ - name: 'multiSelect', - label: t('label.multi-select'), - type: FieldTypes.SWITCH, - required: false, - doc: t('message.custom-property-multi-select-help'), - props: { 'data-testid': 'custom-property-multi-select' }, - }), - [t] - ); - - const formatConfigField: FieldProp = useMemo( - () => ({ - name: 'formatConfig', - label: t('label.format'), - type: FieldTypes.SELECT, - required: false, - placeholder: t('label.format'), - doc: t('message.custom-property-format-config-help'), - rules: { - validate: (value: AddCustomPropertyFormValues['formatConfig']) => { - const id = toId(value ?? undefined); - if (id && !supportedFormats.includes(id)) { - return t('label.field-invalid', { field: t('label.format') }); - } - - return true; - }, - }, - props: { - 'data-testid': 'custom-property-format-config', - items: supportedFormats.map((fmt) => ({ id: fmt, label: fmt })), - }, - }), - [t, supportedFormats] - ); - - const entityReferenceConfigField: FieldProp = useMemo( - () => ({ - name: 'entityReferenceConfig', - label: t('label.entity-reference-types'), - type: FieldTypes.MULTI_SELECT, - required: true, - placeholder: t('label.select-field', { field: t('label.type') }), - doc: t('message.custom-property-entity-reference-config-help'), - rules: { - required: t('label.field-required', { - field: t('label.entity-reference-types'), - }), - }, - props: { - 'data-testid': 'custom-property-entity-ref-config', - items: ENTITY_REFERENCE_OPTIONS.map((opt) => ({ - id: opt.value, - label: opt.label, - })), - }, - }), - [t] - ); - - const columnsField: FieldProp = useMemo( - () => ({ - name: 'columns', - label: t('label.column-plural'), - type: FieldTypes.MULTI_SELECT, - required: true, - placeholder: t('label.column-plural'), - rules: { - required: t('label.field-required', { - field: t('label.column-plural'), - }), - validate: (value: AddCustomPropertyFormValues['columns']) => { - if (isArray(value) && value.length > 3) { - return t('message.maximum-count-allowed', { - count: 3, - label: t('label.column-plural'), - }); - } - - return true; - }, - }, - props: { - 'data-testid': 'custom-property-columns', - allowsCreation: true, - items: [], - }, - }), - [t] - ); - const handleSubmit = useCallback( async (data: AddCustomPropertyFormValues) => { if (!entityType.id) { return; } - const customPropertyConfig = buildCustomPropertyConfig( - data, - hasEnumConfig, - hasFormatConfig, - hasEntityReferenceConfig, - hasTableTypeConfig - ); - - const payload = omitBy( - { - ...omit(data, [ - 'multiSelect', - 'formatConfig', - 'entityReferenceConfig', - 'enumConfig', - 'columns', - ]), - propertyType: { - id: toId(data.propertyType ?? undefined), - type: 'type', - }, - ...(isUndefined(customPropertyConfig) - ? {} - : { customPropertyConfig }), - }, - isUndefined - ) as unknown as CustomProperty; - setIsSaving(true); try { - await addPropertyToEntity(entityType.id, payload); + await addPropertyToEntity( + entityType.id, + toCustomPropertyPayload(data, propertyTypes) + ); showSuccessToast( t('server.create-entity-success', { entity: t('label.custom-property'), @@ -404,16 +97,7 @@ const CustomPropertiesAddPage: React.FC = ({ setIsSaving(false); } }, - [ - entityType.id, - form, - hasEnumConfig, - hasEntityReferenceConfig, - hasFormatConfig, - hasTableTypeConfig, - onSuccess, - t, - ] + [entityType.id, form, onSuccess, propertyTypes, t] ); return ( @@ -436,23 +120,11 @@ const CustomPropertiesAddPage: React.FC = ({ )} showFieldDocs={showHint} onSubmit={form.handleSubmit(handleSubmit)}> -
-
{getField(nameField)}
-
{getField(displayNameField)}
-
- {getField(propertyTypeField)} - - {hasEnumConfig && ( - <> - {getField(enumConfigField)} - {getField(multiSelectField)} - - )} - {hasFormatConfig && getField(formatConfigField)} - {hasEntityReferenceConfig && getField(entityReferenceConfigField)} - {hasTableTypeConfig && getField(columnsField)} - - +
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.constants.ts index 2d40a03da70f5..c2dc8823674b3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.constants.ts @@ -65,3 +65,10 @@ export const GROUP_LABEL_KEYS: Record = { [GROUP_API]: 'label.api-uppercase', [GROUP_GOVERNANCE]: 'label.governance', }; + +/** + * Column name the earlier table-type custom property editor used as its + * internal row key and stripped on save. It stays reserved so a user column + * never takes that name and loses its data to an older client. + */ +export const RESERVED_TABLE_COLUMN_NAMES = ['__row_id__']; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.test.ts index 95e4ebd59a021..d1efe3fb0c91b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.test.ts @@ -17,14 +17,16 @@ import type { CustomProperty } from '../../../../../../generated/type/customProp import type { SettingMenuItem } from '../../../../../../utils/GlobalSettingsUtils'; import { CRUMB } from './CustomPropertiesPanel.constants'; import { - AddCustomPropertyFormValues, - FormSelectItem, + AddCustomPropertyFormValues, + FormSelectItem } from './CustomPropertiesPanel.types'; import { - buildCustomPropertyConfig, - getBreadcrumbItems, - getPageTitle, - toId, + buildCustomPropertyConfig, + getBreadcrumbItems, + getCustomPropertyTypeFlags, + getPageTitle, + toCustomPropertyPayload, + toId } from './CustomPropertiesPanel.utils'; jest.mock('../../../../../../utils/EntityNameUtils', () => ({ @@ -299,3 +301,44 @@ describe('CustomPropertiesPanel utils', () => { }); }); }); + +describe('toCustomPropertyPayload', () => { + const propertyTypes = [ + { id: 'enum-id', name: 'enum' }, + { id: 'date-id', name: 'date-cp' }, + ] as Type[]; + + it('flags the config the selected type needs', () => { + expect( + getCustomPropertyTypeFlags(propertyTypes, { id: 'enum-id' }) + ).toEqual( + expect.objectContaining({ hasEnumConfig: true, hasFormatConfig: false }) + ); + expect( + getCustomPropertyTypeFlags(propertyTypes, 'date-id').hasFormatConfig + ).toBe(true); + }); + + it('builds an enum property and drops an empty display name', () => { + expect( + toCustomPropertyPayload( + { + name: 'tier', + displayName: '', + description: 'Tier', + propertyType: { id: 'enum-id' }, + enumConfig: [{ id: 'gold' }, { id: 'silver' }], + multiSelect: true, + }, + propertyTypes + ) + ).toEqual({ + name: 'tier', + description: 'Tier', + propertyType: { id: 'enum-id', type: 'type' }, + customPropertyConfig: { + config: { multiSelect: true, values: ['gold', 'silver'] }, + }, + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.ts index 246bcff3a3dc8..694271d67cb86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertiesPanel.utils.ts @@ -12,13 +12,22 @@ */ import type { TFunction } from 'i18next'; +import { isUndefined, omit, omitBy } from 'lodash'; +import { + PROPERTY_TYPES_WITH_ENTITY_REFERENCE, + PROPERTY_TYPES_WITH_FORMAT, + SUPPORTED_FORMAT_MAP, + TABLE_TYPE_CUSTOM_PROPERTY +} from '../../../../../../constants/CustomProperty.constants'; +import { Type } from '../../../../../../generated/entity/type'; +import { CustomProperty } from '../../../../../../generated/type/customProperty'; import { getEntityName } from '../../../../../../utils/EntityNameUtils'; import { SettingMenuItem } from '../../../../../../utils/GlobalSettingsUtils'; import { CRUMB } from './CustomPropertiesPanel.constants'; import { - AddCustomPropertyFormValues, - CustomPropertiesSubView, - FormSelectItem, + AddCustomPropertyFormValues, + CustomPropertiesSubView, + FormSelectItem } from './CustomPropertiesPanel.types'; /** Extract the id string from a FormSelectItem (or return as-is if already a string). */ @@ -58,6 +67,60 @@ export function buildCustomPropertyConfig( return undefined; } +/** Which config fields the selected property type needs. */ +export const getCustomPropertyTypeFlags = ( + propertyTypes: Type[], + propertyType?: FormSelectItem | string | null +) => { + const selectedId = toId(propertyType ?? undefined); + const typeName = + propertyTypes.find((type) => type.id === selectedId)?.name ?? ''; + + return { + hasEnumConfig: typeName === 'enum', + hasFormatConfig: PROPERTY_TYPES_WITH_FORMAT.includes(typeName), + hasEntityReferenceConfig: + PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(typeName), + hasTableTypeConfig: typeName === TABLE_TYPE_CUSTOM_PROPERTY, + supportedFormats: + SUPPORTED_FORMAT_MAP[typeName as keyof typeof SUPPORTED_FORMAT_MAP] ?? [], + }; +}; + +/** Add-form values → the custom property the types API expects. */ +export const toCustomPropertyPayload = ( + data: AddCustomPropertyFormValues, + propertyTypes: Type[] +): CustomProperty => { + const flags = getCustomPropertyTypeFlags(propertyTypes, data.propertyType); + const customPropertyConfig = buildCustomPropertyConfig( + data, + flags.hasEnumConfig, + flags.hasFormatConfig, + flags.hasEntityReferenceConfig, + flags.hasTableTypeConfig + ); + + return omitBy( + { + ...omit(data, [ + 'multiSelect', + 'formatConfig', + 'entityReferenceConfig', + 'enumConfig', + 'columns', + ]), + displayName: data.displayName || undefined, + propertyType: { + id: toId(data.propertyType ?? undefined), + type: 'type', + }, + ...(isUndefined(customPropertyConfig) ? {} : { customPropertyConfig }), + }, + isUndefined + ) as unknown as CustomProperty; +}; + export interface ParsedCustomPropertiesHash { entityFqn: string | null; action: 'detail' | 'add' | 'edit'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertyFormFields.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertyFormFields.tsx new file mode 100644 index 0000000000000..c480be1eb3589 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/panels/CustomPropertiesPanel/CustomPropertyFormFields.tsx @@ -0,0 +1,344 @@ +/* + * 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, + FieldProp, + FieldTypes, + FormField, + FormItemLabel, + getField, + HintText, + useFieldDoc +} from '@openmetadata/ui-core-components'; +import { isArray, map, startCase } from 'lodash'; +import React, { ReactNode, useMemo } from 'react'; +import { UseFormReturn, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { + CUSTOM_PROPERTIES_ICON_MAP, + ENTITY_REFERENCE_OPTIONS +} from '../../../../../../constants/CustomProperty.constants'; +import { CUSTOM_PROPERTY_NAME_REGEX } from '../../../../../../constants/regex.constants'; +import { Type } from '../../../../../../generated/entity/type'; +import { getEntityName } from '../../../../../../utils/EntityNameUtils'; +import RichTextEditor from '../../../../../common/RichTextEditor/RichTextEditor'; +import { RESERVED_TABLE_COLUMN_NAMES } from './CustomPropertiesPanel.constants'; +import { + AddCustomPropertyFormValues, + DescriptionFormFieldProps +} from './CustomPropertiesPanel.types'; +import { getCustomPropertyTypeFlags, toId } from './CustomPropertiesPanel.utils'; + +interface CustomPropertyFormFieldsProps { + form: UseFormReturn; + propertyTypes: Type[]; + descriptionKey: number; + /** Rendered right after the type select. */ + typeAddon?: ReactNode; +} + +const DescriptionFormField: React.FC< + DescriptionFormFieldProps +> = ({ form, descriptionKey, initialValue = '' }) => { + const { t } = useTranslation(); + const descriptionDocProps = useFieldDoc({ + name: 'description', + label: t('label.description'), + doc: t('message.custom-property-description-help'), + }); + + return ( + + {({ field, fieldState }) => ( + + + + {fieldState.error?.message && ( + {fieldState.error.message} + )} + + )} + + ); +}; + +/** The add-custom-property fields shared by the AI panel and the settings page. */ +const CustomPropertyFormFields = ({ + form, + propertyTypes, + descriptionKey, + typeAddon, +}: CustomPropertyFormFieldsProps) => { + const { t } = useTranslation(); + const watchedPropertyType = useWatch({ + control: form.control, + name: 'propertyType', + }); + + const propertyTypeOptions = useMemo( + () => + map(propertyTypes, (type) => { + const IconComp = + CUSTOM_PROPERTIES_ICON_MAP[ + type.name as keyof typeof CUSTOM_PROPERTIES_ICON_MAP + ]; + + return { + id: type.id ?? '', + label: startCase(getEntityName(type).replaceAll('-cp', '')), + icon: IconComp, + }; + }), + [propertyTypes] + ); + + const { + hasEnumConfig, + hasFormatConfig, + hasEntityReferenceConfig, + hasTableTypeConfig, + supportedFormats, + } = getCustomPropertyTypeFlags(propertyTypes, watchedPropertyType); + + const nameField: FieldProp = useMemo( + () => ({ + name: 'name', + id: 'root/name', + label: t('label.name'), + type: FieldTypes.TEXT, + required: true, + placeholder: t('label.name'), + doc: t('message.custom-property-name-help'), + props: { 'data-testid': 'custom-property-name' }, + rules: { + required: t('label.field-required', { field: t('label.name') }), + maxLength: { + value: 256, + message: t('message.entity-size-in-between', { + entity: t('label.name'), + min: 1, + max: 256, + }), + }, + pattern: { + value: CUSTOM_PROPERTY_NAME_REGEX, + message: t('message.custom-property-name-validation'), + }, + }, + }), + [t] + ); + + const displayNameField: FieldProp = useMemo( + () => ({ + name: 'displayName', + id: 'root/displayName', + label: t('label.display-name'), + type: FieldTypes.TEXT, + required: false, + placeholder: t('label.display-name'), + doc: t('message.custom-property-display-name-help'), + props: { 'data-testid': 'custom-property-display-name' }, + }), + [t] + ); + + const propertyTypeField: FieldProp = useMemo( + () => ({ + name: 'propertyType', + id: 'root/propertyType', + label: t('label.type'), + type: FieldTypes.SELECT, + required: true, + placeholder: t('label.select-field', { field: t('label.type') }), + doc: t('message.custom-property-type-help'), + rules: { + required: t('label.field-required', { field: t('label.type') }), + }, + props: { + 'data-testid': 'custom-property-type', + items: propertyTypeOptions, + }, + }), + [t, propertyTypeOptions] + ); + + const enumConfigField: FieldProp = useMemo( + () => ({ + name: 'enumConfig', + id: 'root/enumConfig', + label: t('label.enum-value-plural'), + type: FieldTypes.MULTI_SELECT, + required: true, + placeholder: t('label.enum-value-plural'), + doc: t('message.custom-property-enum-config-help'), + rules: { + required: t('label.field-required', { + field: t('label.enum-value-plural'), + }), + }, + props: { + 'data-testid': 'custom-property-enum-config', + allowsCreation: true, + items: [], + }, + }), + [t] + ); + + const multiSelectField: FieldProp = useMemo( + () => ({ + name: 'multiSelect', + id: 'root/multiSelect', + label: t('label.multi-select'), + type: FieldTypes.SWITCH, + required: false, + doc: t('message.custom-property-multi-select-help'), + props: { 'data-testid': 'custom-property-multi-select' }, + }), + [t] + ); + + const formatConfigField: FieldProp = useMemo( + () => ({ + name: 'formatConfig', + id: 'root/formatConfig', + label: t('label.format'), + type: FieldTypes.SELECT, + required: false, + placeholder: t('label.format'), + doc: t('message.custom-property-format-config-help'), + rules: { + validate: (value: AddCustomPropertyFormValues['formatConfig']) => { + const id = toId(value ?? undefined); + if (id && !supportedFormats.includes(id)) { + return t('label.field-invalid', { field: t('label.format') }); + } + + return true; + }, + }, + props: { + 'data-testid': 'custom-property-format-config', + items: supportedFormats.map((fmt) => ({ id: fmt, label: fmt })), + }, + }), + [t, supportedFormats] + ); + + const entityReferenceConfigField: FieldProp = useMemo( + () => ({ + name: 'entityReferenceConfig', + id: 'root/entityReferenceConfig', + label: t('label.entity-reference-types'), + type: FieldTypes.MULTI_SELECT, + required: true, + placeholder: t('label.select-field', { field: t('label.type') }), + doc: t('message.custom-property-entity-reference-config-help'), + rules: { + required: t('label.field-required', { + field: t('label.entity-reference-types'), + }), + }, + props: { + 'data-testid': 'custom-property-entity-ref-config', + items: ENTITY_REFERENCE_OPTIONS.map((opt) => ({ + id: opt.value, + label: opt.label, + })), + }, + }), + [t] + ); + + const columnsField: FieldProp = useMemo( + () => ({ + name: 'columns', + id: 'root/columns', + label: t('label.column-plural'), + type: FieldTypes.MULTI_SELECT, + required: true, + placeholder: t('label.column-plural'), + rules: { + required: t('label.field-required', { + field: t('label.column-plural'), + }), + validate: (value: AddCustomPropertyFormValues['columns']) => { + if (isArray(value) && value.length > 3) { + return t('message.maximum-count-allowed', { + count: 3, + label: t('label.column-plural'), + }); + } + if ( + value?.some((column) => + RESERVED_TABLE_COLUMN_NAMES.includes(toId(column)) + ) + ) { + return t('message.reserved-column-name', { + name: RESERVED_TABLE_COLUMN_NAMES.join(', '), + }); + } + + return true; + }, + }, + props: { + 'data-testid': 'custom-property-columns', + allowsCreation: true, + items: [], + }, + }), + [t] + ); + + return ( + <> +
+
{getField(nameField)}
+
{getField(displayNameField)}
+
+ {getField(propertyTypeField)} + {typeAddon} + {hasEnumConfig && ( + <> + {getField(enumConfigField)} + {getField(multiSelectField)} + + )} + {hasFormatConfig && getField(formatConfigField)} + {hasEntityReferenceConfig && getField(entityReferenceConfigField)} + {hasTableTypeConfig && getField(columnsField)} + + + ); +}; + +export default CustomPropertyFormFields; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx index 0b9e1ec8139e7..41264add01c20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx @@ -12,7 +12,6 @@ */ import { Box, Tabs } from '@openmetadata/ui-core-components'; -import { useForm } from 'antd/lib/form/Form'; import { AxiosError } from 'axios'; import { isUndefined, startCase } from 'lodash'; import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; @@ -34,11 +33,11 @@ import { Type } from '../../generated/entity/type'; import { CustomProperty } from '../../generated/type/customProperty'; import { useEntityPermissions } from '../../hooks/useEntityPermissions/useEntityPermissions'; import { - addPropertyToEntity, - CustomPropertyChanges, - deleteCustomPropertyByName, - getTypeByFQN, - updateCustomPropertyByName, + addPropertyToEntity, + CustomPropertyChanges, + deleteCustomPropertyByName, + getTypeByFQN, + updateCustomPropertyByName } from '../../rest/metadataTypeAPI'; import { getCustomPropertyPageHeaderFromEntity } from '../../utils/CustomProperty.utils'; import { getSettingPageEntityBreadCrumb } from '../../utils/GlobalSettingsUtils'; @@ -108,7 +107,6 @@ const CustomEntityDetailV1 = () => { setActiveTab(activeKey as EntityTabs); }, []); - const [form] = useForm(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -116,10 +114,7 @@ const CustomEntityDetailV1 = () => { setIsDrawerOpen(true); }, []); - const handleDrawerClose = useCallback(() => { - setIsDrawerOpen(false); - form.resetFields(); - }, [form]); + const handleDrawerClose = useCallback(() => setIsDrawerOpen(false), []); const handleDrawerSubmit = useCallback( async (data: CustomProperty) => { @@ -312,7 +307,6 @@ const CustomEntityDetailV1 = () => {
Date: Tue, 13 Oct 2026 08:15:33 +0530 Subject: [PATCH 20/32] feat(ui): move the theme settings form to core HookForm AppearanceConfigSettingsPage drives its fields through react-hook-form; a primary colour change regenerates the hover and selected shades via a watch subscription. The profile sample field loses its layout-only antd Form.Item. --- .../resources/ui/eslint-suppressions.json | 7 +- .../ProfileSampleConfigField.tsx | 31 +- .../AppearanceConfigSettingsPage.test.tsx | 14 + .../AppearanceConfigSettingsPage.tsx | 413 +++++------------- 4 files changed, 147 insertions(+), 318 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index ad3236654e77a..bdccbe1af7a72 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1253,11 +1253,6 @@ "count": 1 } }, - "src/components/Settings/Services/Ingestion/IngestionWorkflowForm/ProfileSampleConfigField.tsx": { - "openmetadata-antd/no-antd-import": { - "count": 1 - } - }, "src/components/Settings/SettingItemCard/SettingItemCard.component.tsx": { "openmetadata-antd/no-antd-import": { "count": 2 @@ -1560,7 +1555,7 @@ }, "src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx": { "openmetadata-antd/no-antd-import": { - "count": 3 + "count": 1 } }, "src/pages/AuditLogsPage/AuditLogsPage.tsx": { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionWorkflowForm/ProfileSampleConfigField.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionWorkflowForm/ProfileSampleConfigField.tsx index d414f2acee3d9..5daf0714bd25f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionWorkflowForm/ProfileSampleConfigField.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionWorkflowForm/ProfileSampleConfigField.tsx @@ -12,28 +12,27 @@ */ import { - Button, - Card, - Grid, - Input, - Select, - Toggle, - Typography, + Button, + Card, + Grid, + Input, + Select, + Toggle, + Typography } from '@openmetadata/ui-core-components'; import { Plus, Trash01 } from '@openmetadata/ui-core-components/icons'; import { FieldProps } from '@rjsf/utils'; -import { Form } from 'antd'; import { isNil } from 'lodash'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MIN_PROFILE_SAMPLE } from '../../../../../constants/profiler.constant'; import { - ICSamplingConfig, - ProfileSampleConfig, - ProfileSampleType, - SampleConfigType, - SamplingMethodType, - Threshold, + ICSamplingConfig, + ProfileSampleConfig, + ProfileSampleType, + SampleConfigType, + SamplingMethodType, + Threshold } from '../../../../../generated/metadataIngestion/databaseServiceProfilerPipeline'; import { pickConfigForType } from '../../../../../utils/ProfileSampleConfigUtils'; @@ -170,7 +169,7 @@ const DynamicSampleConfig = ({ return (
- +
- +
{!(config.smartSampling ?? true) && ( <> { }) ); }); + + it('regenerates hover and selected colours from a new primary colour', async () => { + render(); + + fireEvent.change(screen.getByTestId('primaryColor-color-input'), { + target: { value: '#6809dc' }, + }); + + await waitFor(() => + expect(screen.getByTestId('hoverColor-color-input')).not.toHaveValue('') + ); + + expect(screen.getByTestId('selectedColor-color-input')).not.toHaveValue(''); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx index 6343c8c529662..128c9f2b328b7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AppearanceConfigSettingsPage/AppearanceConfigSettingsPage.tsx @@ -13,21 +13,27 @@ import Icon from '@ant-design/icons'; import { - Box, - Button, - Card, - Grid, - Typography, + Box, + Button, + Card, + FormField, + FormItemLabel, + Grid, + HintText, + HookForm, + Input, + Typography } from '@openmetadata/ui-core-components'; -import { Form, FormProps } from 'antd'; import { AxiosError } from 'axios'; import { startCase, toString } from 'lodash'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ReactComponent as DomainIcon } from '../../assets/svg/ic-domain.svg'; import { ReactComponent as ShareIcon } from '../../assets/svg/ic-share.svg'; import BrandImage from '../../components/common/BrandImage/BrandImage'; +import ColorPicker from '../../components/common/ColorPicker/ColorPicker.component'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; import PageHeader from '../../components/PageHeader/PageHeader.component'; @@ -38,43 +44,41 @@ import { LogoConfiguration } from '../../generated/configuration/logoConfigurati import { UIThemePreference } from '../../generated/configuration/uiThemePreference'; import { Settings, SettingType } from '../../generated/settings/settings'; import { useApplicationStore } from '../../hooks/useApplicationStore'; -import { FieldProp, FieldTypes } from '../../interface/FormUtils.interface'; import { updateSettingsConfig } from '../../rest/settingConfigAPI'; import { generatePalette } from '../../styles/colorPallet'; import { getLayoutGutter } from '../../utils/common/layout.utils'; -import { getField } from '../../utils/formUtils'; import { getSettingPageEntityBreadCrumb } from '../../utils/GlobalSettingsUtils'; +import { isValidUrl } from '../../utils/SSOUtils'; import { getThemeConfig } from '../../utils/ThemeUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import './appearance-config-settings-page.less'; const orEmpty = (value?: string): string => value ?? ''; +type AppearanceFormValues = Partial< + LogoConfiguration & UIThemePreference['customTheme'] +>; +type ThemeColorName = keyof UIThemePreference['customTheme']; + +const toFormValues = ( + config?: Partial +): AppearanceFormValues => ({ + ...config?.customLogoConfig, + ...config?.customTheme, +}); + const AppearanceConfigSettingsPage = () => { const navigate = useNavigate(); const { applicationConfig, setApplicationConfig } = useApplicationStore(); const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + mode: 'onChange', + defaultValues: toFormValues(applicationConfig), + }); + const formState = form.watch(); const [loading, setLoading] = useState(false); const [resetting, setResetting] = useState(false); - const [formState, setFormState] = useState< - Partial< - UIThemePreference['customLogoConfig'] & UIThemePreference['customTheme'] - > - >({ - ...applicationConfig?.customLogoConfig, - ...applicationConfig?.customTheme, - }); - - // Track if hover color was manually edited by user - const isHoverColorManuallyEdited = useRef(false); - const isSelectedColorManuallyEdited = useRef(false); - // Track the last primary color to detect changes - const lastPrimaryColor = useRef( - applicationConfig?.customTheme?.primaryColor ?? '' - ); - const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( () => getSettingPageEntityBreadCrumb( @@ -84,10 +88,7 @@ const AppearanceConfigSettingsPage = () => { [] ); - const handleSave: FormProps['onFinish'] = async ( - values: UIThemePreference['customLogoConfig'] & - UIThemePreference['customTheme'] - ) => { + const handleSave = async (values: AppearanceFormValues) => { setLoading(true); try { const configValues = { @@ -150,12 +151,6 @@ const AppearanceConfigSettingsPage = () => { ...configValues, customTheme: getThemeConfig(configValues.customTheme), }); - - // Reset the manual edit flag - isHoverColorManuallyEdited.current = false; - isSelectedColorManuallyEdited.current = false; - - lastPrimaryColor.current = ''; } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -163,265 +158,55 @@ const AppearanceConfigSettingsPage = () => { } }; - const themeFormFields: FieldProp[] = [ - { - name: 'primaryColor', - id: 'primaryColor', - label: 'Primary Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'primaryColor', - }, - }, - { - name: 'selectedColor', - id: 'selectedColor', - label: 'Selected Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'selectedColor', - }, - }, - { - name: 'hoverColor', - id: 'hoverColor', - label: 'Hover Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'hoverColor', - }, - }, - { - name: 'panelBackgroundColor', - id: 'panelBackgroundColor', - label: t('label.panel-background-color'), - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'panelBackgroundColor', - }, - }, - { - name: 'errorColor', - id: 'errorColor', - label: 'Error Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'errorColor', - }, - }, - { - name: 'successColor', - id: 'successColor', - label: 'Success Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'successColor', - }, - }, - { - name: 'warningColor', - id: 'warningColor', - label: 'Warning Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'warningColor', - }, - }, - { - name: 'infoColor', - id: 'infoColor', - label: 'Info Color', - required: false, - type: FieldTypes.COLOR_PICKER, - rules: [ - { - pattern: HEX_COLOR_CODE_REGEX, - message: t('message.hex-color-validation'), - }, - ], - props: { - 'data-testid': 'infoColor', - }, - }, + const themeFormFields: Array<{ name: ThemeColorName; label: string }> = [ + { name: 'primaryColor', label: 'Primary Color' }, + { name: 'selectedColor', label: 'Selected Color' }, + { name: 'hoverColor', label: 'Hover Color' }, + { name: 'panelBackgroundColor', label: t('label.panel-background-color') }, + { name: 'errorColor', label: 'Error Color' }, + { name: 'successColor', label: 'Success Color' }, + { name: 'warningColor', label: 'Warning Color' }, + { name: 'infoColor', label: 'Info Color' }, ]; - const customLogoFormFields: FieldProp[] = [ + const customLogoFormFields: Array<{ + name: keyof LogoConfiguration; + label: string; + placeholder: string; + }> = [ { name: 'customLogoUrlPath', label: t('label.logo-url'), - type: FieldTypes.TEXT, - required: false, - id: 'root/customLogoUrlPath', placeholder: 'URL path for the login page logo', - props: { - 'data-testid': 'customLogoUrlPath', - autoFocus: true, - }, - rules: [ - { - type: 'url', - message: t('message.entity-is-not-valid-url', { - entity: t('label.logo-url'), - }), - }, - ], }, { name: 'customMonogramUrlPath', label: t('label.monogram-url'), - type: FieldTypes.TEXT, - required: false, - id: 'root/customMonogramUrlPath', placeholder: 'URL path for the navbar logo', - props: { - 'data-testid': 'customMonogramUrlPath', - }, - rules: [ - { - type: 'url', - message: t('message.entity-is-not-valid-url', { - entity: t('label.monogram-url'), - }), - }, - ], }, { name: 'customFaviconUrlPath', label: t('label.favicon-url'), - type: FieldTypes.TEXT, - required: false, - id: 'root/customFaviconUrlPath', placeholder: 'URL path for the favicon', - props: { - 'data-testid': 'customFaviconUrlPath', - }, - rules: [ - { - type: 'url', - message: t('message.entity-is-not-valid-url', { - entity: t('label.favicon-url'), - }), - }, - ], }, ]; - const handleValuesChange = ( - changedValues: Partial< - UIThemePreference['customLogoConfig'] & UIThemePreference['customTheme'] - >, - allValues: UIThemePreference['customLogoConfig'] & - UIThemePreference['customTheme'] - ) => { - // Check if primary color changed - if ( - 'primaryColor' in changedValues && - changedValues.primaryColor !== lastPrimaryColor.current - ) { - lastPrimaryColor.current = changedValues.primaryColor ?? ''; - - // Always auto-generate hover color when primary color changes - if (changedValues.primaryColor) { - const palette = generatePalette(changedValues.primaryColor); - const generatedHoverColor = palette[2]; // 2nd element (index 3) - const generatedSelectedColor = palette[8]; - - // Update form field - form.setFieldValue('hoverColor', generatedHoverColor); - form.setFieldValue('selectedColor', generatedSelectedColor); - - // Update allValues with the generated hover color - allValues = { - ...allValues, - hoverColor: generatedHoverColor, - selectedColor: generatedSelectedColor, - }; - - // Reset manual edit flag since primary color changed - isHoverColorManuallyEdited.current = false; - isSelectedColorManuallyEdited.current = false; + // A new primary colour regenerates the hover and selected shades from its palette. + useEffect(() => { + const subscription = form.watch((values, { name }) => { + if (name === 'primaryColor' && values.primaryColor) { + const palette = generatePalette(values.primaryColor); + form.setValue('hoverColor', palette[2]); + form.setValue('selectedColor', palette[8]); } - } - - // Check if hover color was manually changed (without primary color change) - if ('hoverColor' in changedValues && !('primaryColor' in changedValues)) { - isHoverColorManuallyEdited.current = true; - } - // Check if Selected color was manually changed (without primary color change) - if ( - 'selectedColor' in changedValues && - !('primaryColor' in changedValues) - ) { - isSelectedColorManuallyEdited.current = true; - } + }); - // Update form state - setFormState({ ...allValues }); - }; + return () => subscription.unsubscribe(); + }, [form]); useEffect(() => { - const configValues = { - ...applicationConfig?.customLogoConfig, - ...applicationConfig?.customTheme, - }; - setFormState(configValues); - form.setFieldsValue(configValues); - - // Reset tracking refs when loading saved config - lastPrimaryColor.current = - applicationConfig?.customTheme?.primaryColor ?? ''; - isHoverColorManuallyEdited.current = false; - isSelectedColorManuallyEdited.current = false; - }, [applicationConfig]); + form.reset(toFormValues(applicationConfig)); + }, [applicationConfig, form]); return ( @@ -464,15 +249,7 @@ const AppearanceConfigSettingsPage = () => { -
+
@@ -484,7 +261,7 @@ const AppearanceConfigSettingsPage = () => { return ( { - {getField(field)} + + !value || + isValidUrl(value) || + t('message.entity-is-not-valid-url', { + entity: field.label, + }), + }}> + {({ field: control, fieldState }) => ( + + )} + { className="layout-row layout-grid w-full" style={{ ...getLayoutGutter(16, 16) }}> {themeFormFields.map((field) => { - const currentColor = - formState[ - field.name as keyof UIThemePreference['customTheme'] - ]; + const currentColor = formState[field.name]; return ( { - {getField(field)} + + {({ field: control, fieldState }) => ( + + + + control.onChange(e.target.value) + } + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + { {t('label.save')} - + From 9f60280496b3e1184f4b555bc3ae6bc62c037abc Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 08:19:10 +0530 Subject: [PATCH 21/32] feat(ui): share the core learning resource fields with the classic drawer The AI learning-resource field list moves into useLearningResourceFields; the classic LearningResourceForm drawer now renders it on react-hook-form and reuses the AI payload mappers. --- .../resources/ui/eslint-suppressions.json | 2 +- .../e2e/Pages/LearningResources.spec.ts | 78 ++--- .../LearningResourceSettingsForm.tsx | 143 +------- .../useLearningResourceFields.tsx | 144 ++++++++ .../LearningResourceForm.component.tsx | 314 +++--------------- .../LearningResourceForm.test.tsx | 102 +++--- 6 files changed, 277 insertions(+), 506 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useLearningResourceFields.tsx diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index bdccbe1af7a72..da242685e8aa9 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1610,7 +1610,7 @@ }, "src/pages/LearningResourcesPage/LearningResourceForm.component.tsx": { "openmetadata-antd/no-antd-import": { - "count": 5 + "count": 2 } }, "src/pages/LineageConfigPage/LineageConfigPage.tsx": { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LearningResources.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LearningResources.spec.ts index 5b427acdc7023..353dfa5670f1c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LearningResources.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LearningResources.spec.ts @@ -18,10 +18,11 @@ import { Glossary } from '../../support/glossary/Glossary'; import { LearningResourceClass } from '../../support/learning/LearningResourceClass'; import { AdminClass } from '../../support/user/AdminClass'; import { - createNewPage, - getApiContext, - redirectToHomePage, - uuid, + createNewPage, + getApiContext, + redirectToHomePage, + selectOptionWithRetry, + uuid } from '../../utils/common'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { dismissLineageMapOnboarding } from '../../utils/lineage'; @@ -48,14 +49,20 @@ async function goToLearningResourcesAdmin(page: Page) { await expect(page.getByTestId('learning-resources-table-body')).toBeVisible(); } -async function selectDropdownOption(page: Page, optionText: string) { - const option = page - .locator('.ant-select-dropdown:visible') - .locator('.ant-select-item-option') - .filter({ hasText: new RegExp(`^${optionText}$`, 'i') }); - - await expect(option).toBeVisible(); - await option.click(); +async function selectDropdownOption( + page: Page, + testId: string, + optionText: string +) { + const field = page + .getByTestId('learning-resource-form-drawer') + .getByTestId(testId); + const trigger = field.getByRole('button').or(field.getByRole('combobox')); + await selectOptionWithRetry( + trigger, + page.getByRole('option', { name: new RegExp(`^${optionText}$`, 'i') }) + ); + await page.keyboard.press('Escape'); } async function fillResourceForm( @@ -70,52 +77,25 @@ async function fillResourceForm( status: string; } ) { - const nameInput = page.getByTestId('name-input'); + const nameInput = page.getByTestId('name-input').getByRole('textbox'); await expect(nameInput).toBeVisible(); await nameInput.fill(data.resourceName); - const descriptionInput = page.getByTestId('description-input'); + const descriptionInput = page + .getByTestId('description-input') + .getByRole('textbox'); await expect(descriptionInput).toBeVisible(); await descriptionInput.fill(data.description); - const typeSelector = page - .getByTestId('resource-type-form-item') - .locator('.ant-select-selector'); - await expect(typeSelector).toBeVisible(); - await typeSelector.click(); - await selectDropdownOption(page, data.type); - - const categorySelector = page - .getByTestId('categories-form-item') - .locator('.ant-select-selector'); - await expect(categorySelector).toBeVisible(); - await categorySelector.click(); - await selectDropdownOption(page, data.category); - await page.locator('.ant-drawer-header').click(); - await expect(page.locator('.ant-select-dropdown:visible')).not.toBeVisible(); - - const contextSelector = page - .getByTestId('contexts-form-item') - .locator('.ant-select-selector'); - await expect(contextSelector).toBeVisible(); - await contextSelector.click(); - await page - .locator('.ant-select-dropdown:visible') - .getByTitle(data.context, { exact: true }) - .click(); - await page.locator('.ant-drawer-header').click(); - await expect(page.locator('.ant-select-dropdown:visible')).not.toBeVisible(); - - const urlInput = page.getByTestId('source-url-input'); + await selectDropdownOption(page, 'resource-type-select', data.type); + await selectDropdownOption(page, 'categories-select', data.category); + await selectDropdownOption(page, 'contexts-select', data.context); + + const urlInput = page.getByTestId('source-url-input').getByRole('textbox'); await expect(urlInput).toBeVisible(); await urlInput.fill(data.url); - const statusSelector = page - .getByTestId('status-form-item') - .locator('.ant-select-selector'); - await expect(statusSelector).toBeVisible(); - await statusSelector.click(); - await selectDropdownOption(page, data.status); + await selectDropdownOption(page, 'status-select', data.status); } async function scrollDrawerToShowResource(page: Page, resourceText: string) { 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 index a9b404b2f5ddf..29acb819b0ea2 100644 --- 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 @@ -11,44 +11,32 @@ * limitations under the License. */ -import type { FieldProp } from '@openmetadata/ui-core-components'; -import { Box, FieldTypes, getField } from '@openmetadata/ui-core-components'; +import { Box, getField } from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, 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, + createLearningResource, + getLearningResourceById, + LearningResource, + updateLearningResource } from '../../../../../../rest/learningResourceAPI'; import { - showErrorToast, - showSuccessToast, + showErrorToast, + showSuccessToast } from '../../../../../../utils/ToastUtils'; -import { ResourceTypeIcon } from '../../../../../Learning/ResourceTypeIcon/ResourceTypeIcon'; import { - CATEGORY_ITEMS, - CONTEXT_ITEMS, - DURATION_ITEMS, - LearningResourceFormValues, - STATUS_ITEMS, - toLearningResourceFormValues, - toLearningResourcePayload, + LearningResourceFormValues, + toLearningResourceFormValues, + toLearningResourcePayload } from './LearningResourceSettings.utils'; import type { PlatformSettingsFormProps } from './PlatformSettings.types'; import SettingsFormLayout, { - SettingsFormSection, - SettingsSkeleton, + SettingsFormSection, + SettingsSkeleton } from './SettingsFormLayout'; -import { isValidUrl } from './ThemeSettings.utils'; +import { useLearningResourceFields } from './useLearningResourceFields'; import { useSettingsFetch } from './useSettingsFetch'; /** Add (no `itemId`) or edit the learning resource with id `itemId`. */ @@ -90,108 +78,7 @@ const LearningResourceSettingsForm = ({ } }, [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 fields = useLearningResourceFields(Boolean(itemId), resourceType); const backToList = () => onNavigate({ type: 'page', page: 'learning-resources', isEditing: false }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useLearningResourceFields.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useLearningResourceFields.tsx new file mode 100644 index 0000000000000..cb481861aa5ad --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/useLearningResourceFields.tsx @@ -0,0 +1,144 @@ +/* + * 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 { FieldTypes } from '@openmetadata/ui-core-components'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ResourceType, + RESOURCE_TYPE_LABEL_KEYS, + RESOURCE_TYPE_VALUES, + SOURCE_URL_PLACEHOLDERS +} from '../../../../../../constants/Learning.constants'; +import { ResourceTypeIcon } from '../../../../../Learning/ResourceTypeIcon/ResourceTypeIcon'; +import { + CATEGORY_ITEMS, + CONTEXT_ITEMS, + DURATION_ITEMS, + LearningResourceFormValues, + STATUS_ITEMS +} from './LearningResourceSettings.utils'; +import { isValidUrl } from './ThemeSettings.utils'; + +/** The learning-resource form fields, shared by the AI settings panel and the classic drawer. */ +export const useLearningResourceFields = ( + isEditing: boolean, + resourceType: LearningResourceFormValues['resourceType'] +): FieldProp[] => { + const { t } = useTranslation(); + + const required = useCallback( + (labelKey: string) => ({ + required: t('label.field-required', { field: t(labelKey) }), + }), + [t] + ); + + return 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: isEditing }, + 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 }, + }, + ], + [isEditing, required, resourceType, t] + ); + +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourceForm.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourceForm.component.tsx index 407973c6930b6..ef927764137e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourceForm.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourceForm.component.tsx @@ -12,134 +12,71 @@ */ import { CloseOutlined } from '@ant-design/icons'; -import { Box, Button, Typography } from '@openmetadata/ui-core-components'; -import { Drawer, Form, Input, Select } from 'antd'; +import { + Button, + getField, + HookForm, + Typography +} from '@openmetadata/ui-core-components'; +import { Drawer } from 'antd'; import { AxiosError } from 'axios'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { ResourceTypeIcon } from '../../components/Learning/ResourceTypeIcon/ResourceTypeIcon'; import { - CATEGORIES, - DURATIONS, - LearningResourceStatus, - LEARNING_RESOURCE_STATUSES, - PAGE_IDS, - ResourceType, - RESOURCE_TYPE_LABEL_KEYS, - RESOURCE_TYPE_VALUES, - SOURCE_URL_PLACEHOLDERS, -} from '../../constants/Learning.constants'; + LearningResourceFormValues, + toLearningResourceFormValues, + toLearningResourcePayload +} from '../../components/discovery/personal-space/Profile/tabs/platform-settings/LearningResourceSettings.utils'; +import { useLearningResourceFields } from '../../components/discovery/personal-space/Profile/tabs/platform-settings/useLearningResourceFields'; import { - createLearningResource, - CreateLearningResource, - LearningResourceType, - updateLearningResource, + createLearningResource, + updateLearningResource } from '../../rest/learningResourceAPI'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import './learning-resource-form.less'; import { LearningResourceFormProps } from './LearningResourceForm.interface'; -const { TextArea } = Input; - -const normalizeToArray = (val: unknown): unknown[] => { - if (Array.isArray(val)) { - return val; - } - - return val != null ? [val] : []; -}; - export const LearningResourceForm: React.FC = ({ open, resource, onClose, }) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: toLearningResourceFormValues(), + }); const [isSubmitting, setIsSubmitting] = useState(false); - const selectedResourceType: LearningResourceType | undefined = Form.useWatch( - 'resourceType', - form - ); + const resourceType = useWatch({ control: form.control, name: 'resourceType' }); + const fields = useLearningResourceFields(Boolean(resource), resourceType); useEffect(() => { - if (resource) { - form.setFieldsValue({ - name: resource.name, - description: resource.description, - resourceType: resource.resourceType, - categories: resource.categories, - difficulty: resource.difficulty, - sourceUrl: resource.source.url, - sourceProvider: resource.source.provider, - estimatedDuration: resource.estimatedDuration, - contexts: resource.contexts?.map((ctx) => ctx.pageId) || [], - status: resource.status || LearningResourceStatus.Active, - }); - } else { - form.resetFields(); - } - }, [resource, form]); + form.reset(toLearningResourceFormValues(resource ?? undefined)); + }, [resource, open, form]); - const handleSubmit = useCallback(async () => { + const handleSubmit = async (values: LearningResourceFormValues) => { + setIsSubmitting(true); try { - const values = await form.validateFields(); - setIsSubmitting(true); - - const contexts = values.contexts.map( - (ctx: string | { pageId: string; componentId?: string }) => ({ - componentId: - typeof ctx === 'object' ? ctx.componentId || undefined : undefined, - pageId: typeof ctx === 'string' ? ctx : ctx.pageId, - }) - ); - - const payload: CreateLearningResource = { - categories: values.categories, - contexts, - description: values.description, - difficulty: values.difficulty, - estimatedDuration: values.estimatedDuration, - name: values.name, - resourceType: values.resourceType, - source: { - provider: values.sourceProvider, - url: values.sourceUrl, - }, - status: values.status, - }; - + const payload = toLearningResourcePayload(values, resource ?? undefined); if (resource) { await updateLearningResource(payload); - showSuccessToast( - t('server.entity-updated-success', { - entity: t('label.learning-resource'), - }) - ); } else { await createLearningResource(payload); - showSuccessToast( - t('server.create-entity-success', { - entity: t('label.learning-resource'), - }) - ); } - + showSuccessToast( + t( + resource + ? 'server.entity-updated-success' + : 'server.create-entity-success', + { entity: t('label.learning-resource') } + ) + ); onClose(); } catch (error) { - if (error && typeof error === 'object' && 'errorFields' in error) { - return; - } showErrorToast(error as AxiosError); } finally { setIsSubmitting(false); } - }, [form, resource, t, onClose]); - - const parseDuration = (duration: string): number => { - const match = duration.match(/(\d+)/); - - return match ? parseInt(match[1], 10) * 60 : 0; }; const drawerTitle = ( @@ -160,7 +97,7 @@ export const LearningResourceForm: React.FC = ({ color="primary" data-testid="save-resource" isLoading={isSubmitting} - onClick={handleSubmit}> + onClick={form.handleSubmit(handleSubmit)}> {t('label.save')}
@@ -178,181 +115,14 @@ export const LearningResourceForm: React.FC = ({ title={drawerTitle} width={600} onClose={onClose}> -
- - - - - -