feat: add search support for Roles entity with scalable typeahead UI (#27540)#27550
feat: add search support for Roles entity with scalable typeahead UI (#27540)#27550rohan911438 wants to merge 3 commits intoopen-metadata:mainfrom
Conversation
|
Hi there 👋 Thanks for your contribution! The OpenMetadata team will review the PR shortly! Once it has been labeled as Let us know if you need any help! |
There was a problem hiding this comment.
Pull request overview
This PR makes Roles a first-class searchable entity and updates role-picking UI controls to use search-backed typeahead instead of paginated role listing, improving scalability and fixing cases where roles beyond the first page were unreachable.
Changes:
- Added backend search index support for Roles (index mapping +
RoleIndex+ factory wiring). - Extended UI search types to include
SearchIndex.ROLE/RoleSearchSource. - Replaced role dropdowns in multiple UI surfaces with
AsyncSelectbacked by/v1/search/query.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/RoleIndex.java | Adds Role search document builder with excluded heavy fields. |
| openmetadata-service/src/main/java/org/openmetadata/service/search/SearchIndexFactory.java | Wires Entity.ROLE to build a Role search index document. |
| openmetadata-spec/src/main/resources/elasticsearch/indexMapping.json | Registers role index metadata (index name, mapping file, alias). |
| openmetadata-spec/src/main/resources/elasticsearch/en/role_index_mapping.json | Introduces Elasticsearch mapping for role search index. |
| openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts | Adds SearchIndex.ROLE. |
| openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts | Adds RoleSearchSource and maps it to SearchIndex.ROLE. |
| openmetadata-ui/src/main/resources/ui/src/pages/CreateUserPage/CreateUserPage.component.tsx | Removes eager “fetch all roles” and stops passing roles as props. |
| openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.interface.ts | Removes roles prop from CreateUser component contract. |
| openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx | Uses search-backed AsyncSelect for role selection. |
| openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/UsersProfile/UserProfileRoles/UserProfileRoles.component.tsx | Switches roles editor to search-backed AsyncSelect (plus Admin pseudo-option). |
| openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOConfigurationForm/SsoRolesSelectField.tsx | Uses search-backed AsyncSelect for SSO role selection. |
| openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/LdapRoleMappingWidget/LdapRoleMappingWidget.tsx | Uses search-backed AsyncSelect for LDAP role mapping. |
| case Entity.USER -> new UserIndex((User) entity); | ||
| case Entity.TEAM -> new TeamIndex((Team) entity); | ||
| case Entity.ROLE -> new RoleIndex((Role) entity); | ||
| case Entity.METRIC -> new MetricIndex((Metric) entity); |
There was a problem hiding this comment.
RoleIndex is referenced in the switch but not imported, so this file will not compile. Add the missing import for org.openmetadata.service.search.indexes.RoleIndex (and ensure it’s included in the imports section with the other index classes).
| "role": { | ||
| "indexName": "role_search_index", | ||
| "indexMappingFile": "/elasticsearch/%s/role_index_mapping.json", | ||
| "alias": "role", | ||
| "parentAliases": [], | ||
| "childAliases": [] | ||
| }, |
There was a problem hiding this comment.
indexMappingFile is language-scoped (/elasticsearch/%s/role_index_mapping.json), but the PR only adds the mapping under elasticsearch/en/. Add corresponding role_index_mapping.json files for the other supported languages (jp/ru/zh) or switch to a non-localized mapping path to avoid startup/index creation failures when searchIndexMappingLanguage is not en.
| Typography as AntDTypography, | ||
| } from 'antd'; | ||
| import { AxiosError } from 'axios'; | ||
| import { startCase } from 'lodash'; |
There was a problem hiding this comment.
startCase is imported but not used in this widget, which should fail lint/typecheck with unused imports enabled. Remove the unused import (or use it if intended).
| import { startCase } from 'lodash'; |
| const fetchRoleOptions = async (searchText: string, page?: number) => { | ||
| try { | ||
| const response = await searchQuery({ | ||
| query: searchText || '*', | ||
| searchIndex: SearchIndex.ROLE, | ||
| pageSize: 10, | ||
| pageNumber: page ?? 1, | ||
| fetchSource: true, | ||
| }); | ||
|
|
||
| fetchRoles(); | ||
| }, []); | ||
| return { | ||
| data: response.hits.hits.map((hit) => ({ | ||
| label: getEntityName(hit._source), | ||
| value: hit._source.name, | ||
| })), | ||
| paging: { | ||
| total: response.hits.total.value, | ||
| }, | ||
| }; | ||
| } catch (error) { | ||
| showErrorToast(error as AxiosError); | ||
|
|
||
| return { data: [], paging: { total: 0 } }; | ||
| } | ||
| }; |
There was a problem hiding this comment.
fetchRoleOptions returns { data, paging }, but this AsyncSelect usage does not set enableInfiniteScroll, so AsyncSelect will treat the response as an options array and pass an object to AntD Select (runtime break). Either set enableInfiniteScroll on the AsyncSelect or change fetchRoleOptions to return a plain options array. (Also, if you keep returning plain options, you can drop the now-unused RoleOption type.)
| const fetchRoleOptions = async (searchText: string, page?: number) => { | ||
| try { | ||
| const response = await searchQuery({ | ||
| query: searchText || '*', | ||
| searchIndex: SearchIndex.ROLE, | ||
| pageSize: PAGE_SIZE_LARGE, | ||
| pageNumber: page ?? 1, | ||
| fetchSource: true, | ||
| }); | ||
| } | ||
|
|
||
| return options; | ||
| }, [roles, isUserAdmin, getEntityName]); | ||
| const options = response.hits.hits.map((hit) => ({ | ||
| label: getEntityName(hit._source), | ||
| value: hit._source.id, | ||
| data: hit._source, | ||
| })); | ||
|
|
||
| const fetchRoles = async () => { | ||
| try { | ||
| const response = await getRoles( | ||
| '', | ||
| undefined, | ||
| undefined, | ||
| false, | ||
| PAGE_SIZE_LARGE | ||
| ); | ||
| setRoles(response.data); | ||
| } catch (err) { | ||
| if (!isUserAdmin && (isEmpty(searchText) || toLower(TERM_ADMIN).includes(toLower(searchText)))) { | ||
| options.push({ | ||
| label: TERM_ADMIN, | ||
| value: toLower(TERM_ADMIN), | ||
| data: undefined, | ||
| } as any); | ||
| } | ||
|
|
||
| return { | ||
| data: options, | ||
| paging: { | ||
| total: response.hits.total.value + (!isUserAdmin ? 1 : 0), | ||
| }, | ||
| }; |
There was a problem hiding this comment.
fetchRoleOptions returns a paginated { data, paging } object, but the AsyncSelect below does not set enableInfiniteScroll. With infinite scroll disabled, AsyncSelect expects the api to return a plain options array; otherwise it will pass an object to AntD Select as options and break the dropdown. Fix by either enabling infinite scroll or returning an array. Also, paging.total currently adds +1 even when the Admin option is not added; only increment when that option is actually included.
| if (!isUserAdmin && (isEmpty(searchText) || toLower(TERM_ADMIN).includes(toLower(searchText)))) { | ||
| options.push({ | ||
| label: TERM_ADMIN, | ||
| value: toLower(TERM_ADMIN), | ||
| data: undefined, | ||
| } as any); |
There was a problem hiding this comment.
Avoid as any here. AsyncSelect options are DefaultOptionType, so create a properly typed option object (or define a local option type) instead of casting to any when adding the Admin pseudo-option.
| open={isDropdownOpen} | ||
| options={useRolesOption} | ||
| popupClassName="roles-custom-dropdown-class" | ||
| ref={dropdownRef as any} |
There was a problem hiding this comment.
This ref={dropdownRef as any} is an any cast and will fail the project’s TypeScript type-safety standards. Prefer typing the ref to the correct Select ref type (or wrapping element type) so the cast isn’t needed.
| <AsyncSelect | ||
| enableInfiniteScroll | ||
| showSearch | ||
| api={fetchRoleOptions} | ||
| data-testid="roles-dropdown" | ||
| disabled={isEmpty(roles)} | ||
| filterOption={handleSearchFilterOption} | ||
| getPopupContainer={(triggerNode) => triggerNode.parentElement} |
There was a problem hiding this comment.
AsyncSelect is designed for server-side searching and sets filterOption={false} internally. Passing filterOption={handleSearchFilterOption} here overrides that and can re-filter remote results unexpectedly. Remove the filterOption prop when using AsyncSelect with api={fetchRoleOptions}.
| const fetchRoleOptions = async (searchText: string, page?: number) => { | ||
| try { | ||
| const response = await searchQuery({ | ||
| query: searchText || '*', | ||
| searchIndex: SearchIndex.ROLE, | ||
| pageSize: 10, | ||
| pageNumber: page ?? 1, | ||
| fetchSource: true, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| getRoles('*', undefined, undefined, true, 1000) | ||
| .then((response) => { | ||
| setRoleOptions( | ||
| (response.data || []).map((role) => ({ | ||
| label: role.displayName || role.name, | ||
| value: role.name, | ||
| })) | ||
| ); | ||
| }) | ||
| .catch((error: AxiosError) => showErrorToast(error)); | ||
| }, []); | ||
| return { | ||
| data: response.hits.hits.map((hit) => ({ | ||
| label: getEntityName(hit._source), | ||
| value: hit._source.name, | ||
| })), | ||
| paging: { | ||
| total: response.hits.total.value, | ||
| }, | ||
| }; | ||
| } catch (error) { | ||
| showErrorToast(error as AxiosError); | ||
|
|
||
| return { data: [], paging: { total: 0 } }; | ||
| } | ||
| }; |
There was a problem hiding this comment.
fetchRoleOptions returns { data, paging }, but the AsyncSelect here doesn’t enable infinite scroll. When enableInfiniteScroll is false, AsyncSelect expects the api to return a plain options array; otherwise it will pass an object as options to AntD Select. Either set enableInfiniteScroll or return an array from fetchRoleOptions.
|
Hi there 👋 Thanks for your contribution! The OpenMetadata team will review the PR shortly! Once it has been labeled as Let us know if you need any help! |
|
Hi there 👋 Thanks for your contribution! The OpenMetadata team will review the PR shortly! Once it has been labeled as Let us know if you need any help! |
Code Review ✅ Approved 3 resolved / 3 findingsImplements role search with scalable typeahead UI, resolving issues with unsafe type casting, incorrect import paths, and missing index mappings. ✅ 3 resolved✅ Quality: Unsafe
|
| Compact |
|
Was this helpful? React with 👍 / 👎 | Gitar
| const { value, onChange, id, disabled, readonly } = props; | ||
|
|
||
| const [mappings, setMappings] = useState<RoleMappingEntry[]>([]); | ||
| const [availableRoles, setAvailableRoles] = useState<RoleOption[]>([]); | ||
| const [isLoadingRoles, setIsLoadingRoles] = useState(false); | ||
| const [errors, setErrors] = useState<MappingError>({}); |
There was a problem hiding this comment.
After removing the availableRoles/isLoadingRoles state, the RoleOption type defined above is no longer referenced anywhere, which can trigger unused-locals lint/typecheck failures. Please remove the unused type (or reintroduce a use for it).
| @@ -250,7 +254,6 @@ const UserProfileRoles = ({ | |||
| )} | |||
There was a problem hiding this comment.
maxTagPlaceholder is explicitly typed as any[], which weakens type-safety and can mask real issues. Please remove the any annotation (let TS infer it) or use the proper AntD Select omitted-values type.
| open={isDropdownOpen} | ||
| options={useRolesOption} | ||
| popupClassName="roles-custom-dropdown-class" | ||
| ref={dropdownRef as any} |
There was a problem hiding this comment.
AsyncSelect is a function component (not forwardRef), so passing ref={... as any} will not work and will generate a runtime warning (function components cannot receive refs). Please remove this ref usage or update AsyncSelect to forwardRef and type the ref properly.
| ref={dropdownRef as any} |
Summary
This PR introduces full search support for the Roles entity, addressing the scalability limitations of the existing paginated API (
GET /v1/roles) used across the UI.Previously, role selection dropdowns relied on pagination, causing roles beyond the first page to be inaccessible and unsearchable. This PR elevates Roles to a first-class searchable entity, aligning it with existing entities like Users and Teams.
Closes #27540
Problem
limit=1000were not scalableUserCreationWithPersona.spec.ts) were failing due to missing rolesSolution
Implemented end-to-end search support for Roles across backend and frontend:
Backend Changes
RoleIndex.javato define Role search indexingindexMapping.jsonwith Role index schemaSearchIndexFactorySearchRepository/v1/search/queryAPIFrontend Changes
Search Infrastructure
SearchIndexenum to includeROLEUI Enhancements
Replaced pagination-based dropdowns with typeahead search (AsyncSelect) in:
CreateUser.component.tsxUserProfileRoles.component.tsx(including Admin role handling)SsoRolesSelectField.tsxLdapRoleMappingWidget.tsxImprovements
Testing
Migration / Deployment Note
After deploying this change, run the Role Search Re-index job from:
Settings → Global Settings → Search IndexThis ensures all existing roles are indexed and searchable.
Impact
Additional Notes
This implementation follows existing search architecture patterns and ensures consistency across entities while improving performance and maintainability.
Please let me know if any refinements or additional test coverage are needed