From b41ba6ece4cd38ad939e39f4e6b4a5012436d425 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 01:52:56 +0530 Subject: [PATCH 01/47] 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 - .../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 +- 62 files changed, 1008 insertions(+), 2277 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 07a326d32289..34fcdc9ce409 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 560b3e9dced8..f7b1fb7d26d4 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 1dcd4a3fa05a..592569ed5539 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 ef09cf171943..dc8ce7d386b9 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 0abb682b9ca2..ba3ef082a3c2 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 2021a6758cd5..9455a46a5b4b 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 a126f4276971..89f20454d543 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 @@ -521,25 +521,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/*'); @@ -550,9 +550,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( @@ -565,12 +563,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) => @@ -578,29 +576,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 = @@ -619,13 +612,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 = @@ -713,15 +708,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 1c55339bac20..34de5ebbe41a 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 ac9bfbcacc38..546568452f47 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 a12b4d5378c2..45406b5538e1 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 235552753f1f..8844abcd8296 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 002d45f4bc8a..d07baef919e0 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 695f89c45acf..bf2f5a9a3473 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -387,8 +387,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 f96cd695db5f..c7b22cda1f25 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 d4a1774a22e1..69fa84334810 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 2d1a4c5d2adc..dd2b28ad5ede 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 6968ea41c4db..85d18dc6f263 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 616708caefc9..8fdbb6978cae 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 ba1ae2d2cae8..409294e8250a 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 7048d39e29ac..711723fc280b 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 4cabdeceb8e0..984398aa2e81 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 1bb522de502a..a5c663f279b1 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 0cd40a8fc51d..000000000000 --- 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 70271560b1b4..90e19672c994 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 058c778497ec..9a4ddaa0fcdd 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 b7fe64722007..c7780b3246b1 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 8f7eff399715..ac538268f1b8 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 e4990f2a0031..d3cb14725840 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 859e379d9a93..a6f0a2ff0589 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 5ace051701a3..126f42f12173 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 224d420bdff9..7bc197d14649 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 4aad9483397d..80fb0c61997b 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 ef9fd4151857..74a2b0e6273b 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 a16081558c30..366f54c085c8 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) => { - true} id="select-role" - loading={isRolesLoading} - mode="multiple" - options={userRolesOption} + items={userRolesOption} placeholder={`${t('label.role-plural')}...`} - onChange={handleOnRolesChange} - onSearch={searchRolesOptions} - /> + selectedItems={selectedRoleItems} + onItemCleared={handleRoleCleared} + onItemInserted={handleRoleInserted} + onSearchChange={searchRolesOptions}> + {(item) => ( + + )} +
- - - + + + + + + + + + - - + +
); 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 d9393b0ee82a..8088698ff1c4 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 039c12e2f2d0..cbec908a95b9 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 ab3475f3c4d1..57f3e540ad48 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 4d11e61e1bda..27b2b4387f84 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 8f84f38a0da9..ec36a42fc378 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 7e49ddcfefd5..dada1f804b45 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 a209f8834fb0..000000000000 --- 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 6acab74de1c2..000000000000 --- 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 02aa7795d18d..afdc51d7f555 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 77e0c7e877a81115e7e67da8c5a3b662a214ec5d Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 07:52:35 +0530 Subject: [PATCH 16/47] 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 073210c69bb9..4939d7ef6f4a 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -539,7 +539,7 @@ }, "playwright/e2e/Pages/Policies.spec.ts": { "om-playwright/no-positional-locator": { - "count": 10 + "count": 8 } }, "playwright/e2e/Pages/ProfilerConfigurationPage.spec.ts": { @@ -1048,11 +1048,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 @@ -1093,21 +1088,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 @@ -1258,11 +1238,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 @@ -1273,16 +1248,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 @@ -1338,14 +1303,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": { @@ -1623,16 +1583,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 @@ -1698,36 +1648,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 @@ -1738,11 +1663,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 @@ -1790,7 +1710,7 @@ }, "src/pages/TeamsPage/AddTeamForm.tsx": { "openmetadata-antd/no-antd-import": { - "count": 5 + "count": 1 } }, "src/utils/Alerts/AlertsUtil.tsx": { @@ -1803,11 +1723,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 06aabe2148d6..47d8646ad366 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 750d7b10610c..a7331f0a1a2d 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 cede1d00d58c..817b4d0d4fed 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 5f1876c68ff4..1a14328aa2cc 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 c9ccb4425d3f..3a04a19d8820 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 850e9a9a8cdc..e16977b90a37 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 6816c639756d..c1e3810e27c4 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 c28e0198d1fa..2f625ee6cc5c 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 667d9666eeb1..31fbfd324267 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 1451cd85ef55..76629fa7502a 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 5076367131c7..db6f5cd8456a 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: ( - - + // 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 2474947d42bf..7056045f07af 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 7d0fa764b137..65d3410bc93b 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 282586ced17bd6604ca85fabc2059e60edf2308d Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 08:12:02 +0530 Subject: [PATCH 19/47] 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 344c0fdf2f80..efbbdf1aa654 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1238,11 +1238,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 @@ -1288,11 +1283,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 @@ -1583,11 +1573,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 01c97662f82b..53b0f09b09bf 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 5036a3122a4b..412d775fabd8 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 f2fa7cf29ac7..80c0975c921d 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 2d960d6da5a9..7d0dc8ce1528 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 47c57c2d2b6e..000000000000 --- 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 1dcbbf23925d..174cfe595d1e 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 ba2072c6f8c0..7853d96ab20a 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 2d40a03da70f..c2dc8823674b 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 95e4ebd59a02..d1efe3fb0c91 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 246bcff3a3dc..694271d67cb8 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 000000000000..c480be1eb358 --- /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 0b9e1ec8139e..41264add01c2 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/47] 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 efbbdf1aa654..4642c805c4c7 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1248,11 +1248,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 @@ -1555,7 +1550,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 d414f2acee3d..5daf0714bd25 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 6343c8c52966..128c9f2b328b 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 e447977e2dc49add3ec29571d5134653d4a1d9e7 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 13 Oct 2026 08:19:10 +0530 Subject: [PATCH 21/47] 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 4642c805c4c7..1c9df26443fb 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -1605,7 +1605,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 5b427acdc702..353dfa5670f1 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 a9b404b2f5dd..29acb819b0ea 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 000000000000..cb481861aa5a --- /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 407973c6930b..ef927764137e 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}> -
- - - - - -