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 e0937e4cc2fd..b2e6aaa06b4d 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 @@ -680,6 +680,7 @@ test.describe('SSO Configuration Tests', () => { page, }) => { await selectSSOProvider(page, 'ldap'); + await page.waitForResponse('/api/v1/roles/search?*'); const addMappingButton = page.getByTestId('add-mapping-btn'); const ldapGroupInputs = page.locator( @@ -747,6 +748,7 @@ test.describe('SSO Configuration Tests', () => { page, }) => { await selectSSOProvider(page, 'ldap'); + await page.waitForResponse('/api/v1/roles/search?*'); const field = page.getByTestId( 'sso-configuration-form-array-field-template-authReassignRoles' @@ -760,8 +762,13 @@ test.describe('SSO Configuration Tests', () => { // Opening the dropdown shows API-fetched role options await field.click(); await expect(dropdown).toBeVisible(); + await field.locator('input').fill(''); + await page.waitForResponse('/api/v1/roles/search?*'); await expect(dropdown.locator('.ant-select-item-option')).not.toHaveCount( - 0 + 0, + { + timeout: 15000, + } ); // Select the first available role — it appears as a selection tag @@ -780,15 +787,20 @@ test.describe('SSO Configuration Tests', () => { // Typing filters the visible options await field.click(); await field.locator('input').fill('Data'); + await page.waitForResponse('/api/v1/roles/search?*'); await expect( dropdown.locator( '.ant-select-item-option:not(.ant-select-item-option-disabled)' ) - ).not.toHaveCount(0); + ).not.toHaveCount(0, { timeout: 15000 }); // Pressing Enter on a non-existent value does not create an arbitrary tag await field.locator('input').clear(); + const missingRoleSearchResponse = page.waitForResponse( + '/api/v1/roles/search?*' + ); await field.locator('input').fill('NonExistentRoleXYZ123'); + await missingRoleSearchResponse; await field.locator('input').press('Enter'); await expect(field.locator('.ant-select-selection-item')).toHaveCount(0); }); 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 28a7e74d7e72..a8026937738b 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 @@ -79,7 +79,9 @@ test.describe.serial('Add role and assign it to the user', () => { test('Create new user and assign new role to him', async ({ page }) => { await settingClick(page, GlobalSettingOptions.USERS); + const initialRolesResponse = page.waitForResponse('/api/v1/roles/search?*'); await page.click('[data-testid="add-user"]'); + await initialRolesResponse; await page.fill('[data-testid="email"]', user.email); await page.fill('[data-testid="displayName"]', userDisplayName); @@ -96,7 +98,9 @@ test.describe.serial('Add role and assign it to the user', () => { await page.locator('.ant-select-dropdown').waitFor({ state: 'visible', }); + const rolesSearchResponse = page.waitForResponse('/api/v1/roles/search?*'); await page.fill('#roles', roleName); + await rolesSearchResponse; await page.click(`[title="${roleName}"]`); await page.keyboard.press('Escape'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts index fbadd237db9f..0d0c9b920c50 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts @@ -2714,7 +2714,9 @@ test.describe('Domains Rbac', () => { // Add domain role to the user await visitUserProfilePage(page, user1.responseData.name); + const initialRolesResponse = page.waitForResponse('/api/v1/roles/search?*'); await page.getByTestId('edit-roles-button').click(); + await initialRolesResponse; await page.locator('[data-testid="user-profile-edit-popover"]').isVisible(); const rolesCombobox = page.locator('input[role="combobox"]').nth(1); 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 aa91c801aa27..b9b7a6bf06b0 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 @@ -501,7 +501,11 @@ test.describe('User with different Roles', () => { await expect(adminPage.getByTestId('user-profile-roles')).toBeVisible(); + const initialRolesResponse = adminPage.waitForResponse( + '/api/v1/roles/search?*' + ); await adminPage.getByTestId('edit-roles-button').click(); + await initialRolesResponse; await expect( adminPage.getByTestId('profile-edit-roles-select') @@ -511,6 +515,15 @@ test.describe('User with different Roles', () => { state: 'visible', }); + await adminPage + .getByTestId('profile-edit-roles-select') + .locator('input') + .fill('Application'); + await adminPage.waitForResponse('/api/v1/roles/search?*'); + 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 }) 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 ecd114126fa2..caac21e988be 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts @@ -710,7 +710,7 @@ export const addUser = async ( await waitForAllLoadersToDisappear(page); await page.click('[data-testid="add-user"]'); - await page.waitForResponse('/api/v1/roles?default=false&limit=100&fields='); + await page.waitForResponse('/api/v1/roles/search?*'); await page.fill('[data-testid="email"]', email); await page.fill('[data-testid="displayName"]', name); @@ -726,7 +726,9 @@ export const addUser = async ( .getByRole('combobox'); await expect(rolesCombobox).toBeVisible({ timeout: 120000 }); await rolesCombobox.click(); + 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}$`) }) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.component.tsx index 1b3dee972fb6..4dff7b75a33b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.component.tsx @@ -14,16 +14,16 @@ import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; import { Button, Card, Col, Input, Row, Typography } from 'antd'; import { AxiosError } from 'axios'; -import { toLower } from 'lodash'; -import { FC, useEffect, useMemo, useState } from 'react'; +import { debounce, toLower, uniqBy } from 'lodash'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as IconBotProfile } from '../../../../assets/svg/bot-profile.svg'; -import { PAGE_SIZE_LARGE, TERM_ADMIN } from '../../../../constants/constants'; +import { TERM_ADMIN } from '../../../../constants/constants'; import { GlobalSettingOptions } from '../../../../constants/GlobalSettings.constants'; import { useLimitStore } from '../../../../context/LimitsProvider/useLimitsStore'; import { EntityType } from '../../../../enums/entity.enum'; import { Role } from '../../../../generated/entity/teams/role'; -import { getAllRoles } from '../../../../rest/rolesAPIV1'; +import { searchRoles } from '../../../../rest/rolesAPIV1'; import { getEntityName } from '../../../../utils/EntityUtils'; import { getSettingPath } from '../../../../utils/RouterUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; @@ -48,6 +48,8 @@ const BotDetails: FC = ({ 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(); const [disableFields, setDisableFields] = useState(['token']); @@ -74,15 +76,29 @@ const BotDetails: FC = ({ } }; - const fetchRoles = async () => { + const fetchRoles = useCallback(async (query = '') => { + setIsRolesLoading(true); + try { - const data = await getAllRoles('', false, PAGE_SIZE_LARGE); - setRoles(data); + const data = await searchRoles(query); + setRoles((prevRoles) => { + const selectedRoleOptions = prevRoles.filter((role) => + selectedRolesRef.current.includes(role.id) + ); + + return uniqBy([...selectedRoleOptions, ...data], 'id'); + }); } catch (err) { - setRoles([]); showErrorToast(err as AxiosError); + } finally { + setIsRolesLoading(false); } - }; + }, []); + + const debouncedFetchRoles = useMemo( + () => debounce(fetchRoles, 300), + [fetchRoles] + ); const onDisplayNameChange = (e: React.ChangeEvent) => { setDisplayName(e.target.value); @@ -190,7 +206,9 @@ const BotDetails: FC = ({ setSelectedRoles(selectedRoles) @@ -206,13 +224,36 @@ const BotDetails: FC = ({ ); }; + useEffect(() => { + selectedRolesRef.current = selectedRoles; + }, [selectedRoles]); + useEffect(() => { fetchRoles(); initLimits(); }, []); + useEffect(() => { + return () => { + debouncedFetchRoles.cancel(); + }; + }, [debouncedFetchRoles]); + useEffect(() => { prepareSelectedRoles(); + setRoles((prevRoles) => + uniqBy( + [ + ...prevRoles, + ...((botUserData.roles ?? []).map((role) => ({ + id: role.id, + name: role.name ?? '', + displayName: role.displayName, + })) as Role[]), + ], + 'id' + ) + ); }, [botUserData]); return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.test.tsx index 1ac3b8620a4e..268cac3d4cad 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotDetails/BotDetails.test.tsx @@ -14,6 +14,7 @@ import { act, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; +import { searchRoles } from '../../../../rest/rolesAPIV1'; import { getAuthMechanismForBotUser } from '../../../../rest/userAPI'; import AccessTokenCard from '../../Users/AccessTokenCard/AccessTokenCard.component'; import BotDetails from './BotDetails.component'; @@ -93,6 +94,10 @@ jest.mock('../../../../utils/PermissionsUtils', () => ({ checkPermission: jest.fn().mockReturnValue(true), })); +jest.mock('../../../../rest/rolesAPIV1', () => ({ + searchRoles: jest.fn().mockResolvedValue([]), +})); + const mockGetResourceLimit = jest.fn().mockResolvedValue({ configuredLimit: { disabledFields: [] }, }); @@ -146,6 +151,10 @@ jest.mock('../../../../context/LimitsProvider/useLimitsStore', () => ({ })); describe('Test BotsDetail Component', () => { + beforeEach(() => { + (searchRoles as jest.Mock).mockResolvedValue([]); + }); + it('Should render all child elements', async () => { await act(async () => { render(, { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx index 32fa737ec8fb..6d007e91b0b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx @@ -24,8 +24,16 @@ import { Switch, } from 'antd'; import { AxiosError } from 'axios'; -import { compact, isEmpty, isUndefined, map, trim } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; +import { + compact, + debounce, + isEmpty, + isUndefined, + map, + trim, + uniqBy, +} from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { ReactComponent as IconSync } from '../../../../assets/svg/ic-sync.svg'; @@ -55,8 +63,8 @@ import { } from '../../../../interface/FormUtils.interface'; import { generateRandomPwd } from '../../../../rest/auth-API'; import { getAllPersonas } from '../../../../rest/PersonaAPI'; +import { searchRoles } from '../../../../rest/rolesAPIV1'; import { getJWTTokenExpiryOptions } from '../../../../utils/BotsUtils'; -import { handleSearchFilterOption } from '../../../../utils/CommonUtils'; import { getEntityName, getEntityReferenceListFromEntities, @@ -72,7 +80,6 @@ import TeamsSelectable from '../../Team/TeamsSelectable/TeamsSelectable'; import { CreateUserProps } from './CreateUser.interface'; const CreateUser = ({ - roles, isLoading, onCancel, onSave, @@ -92,6 +99,10 @@ const CreateUser = ({ const [selectedTeams, setSelectedTeams] = useState< Array >([]); + const [roleOptions, setRoleOptions] = useState< + Array<{ label: string; value: string }> + >([]); + const [isRolesLoading, setIsRolesLoading] = useState(false); const [isPasswordGenerating, setIsPasswordGenerating] = useState(false); const { activeDomainEntityRef } = useDomainStore(); const selectedDomain = @@ -135,12 +146,35 @@ const CreateUser = ({ const selectedRoles = Form.useWatch('roles', form); const selectedPersonas = Form.useWatch('personas', form); - const roleOptions = useMemo(() => { - return map(roles, (role) => ({ - label: getEntityName(role), - value: role.id, - })); - }, [roles]); + const fetchRoleOptions = useCallback( + async (searchText = '') => { + setIsRolesLoading(true); + + try { + const roles = await searchRoles(searchText); + const nextOptions = map(roles, (role) => ({ + label: getEntityName(role), + value: role.id, + })); + + setRoleOptions((prevOptions) => { + const selectedRoleOptions = prevOptions.filter((option) => + (selectedRoles ?? []).includes(String(option.value)) + ); + + return uniqBy([...selectedRoleOptions, ...nextOptions], 'value'); + }); + } catch (error) { + showErrorToast( + error as AxiosError, + t('server.entity-fetch-error', { entity: t('label.role-plural') }) + ); + } finally { + setIsRolesLoading(false); + } + }, + [selectedRoles, t] + ); const fetchPersonaOptions = async (_searchText: string, page?: number) => { try { @@ -264,6 +298,23 @@ const CreateUser = ({ generateRandomPassword(); }, []); + useEffect(() => { + if (!forceBot && !isAdminPage) { + fetchRoleOptions(); + } + }, [forceBot, isAdminPage]); + + const debouncedFetchRoleOptions = useMemo( + () => debounce(fetchRoleOptions, 300), + [fetchRoleOptions] + ); + + useEffect(() => { + return () => { + debouncedFetchRoleOptions.cancel(); + }; + }, [debouncedFetchRoleOptions]); + return (