From eeba08b906f4f9112a2c9200a5a99b611fc8b7ac Mon Sep 17 00:00:00 2001 From: AnuradhaSK Date: Fri, 30 Oct 2026 02:17:26 +0530 Subject: [PATCH] Add ouId prop to GroupMembers Signed-off-by: AnuradhaSK --- .../admin/GroupMembers/GroupMembers.tsx | 47 +++++++--- .../admin/__tests__/adminComponents.test.tsx | 88 ++++++++++++++++++- .../components/admin/admin-components.test.ts | 62 +++++++++++++ .../admin/GroupMembers/GroupMembers.ts | 43 +++++++-- .../react/src/pages/explorer/adminStories.tsx | 11 ++- .../vue/src/pages/explorer/adminStories.ts | 24 ++++- 6 files changed, 247 insertions(+), 28 deletions(-) diff --git a/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx b/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx index 5e92eaa3..ace9abfd 100644 --- a/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx +++ b/packages/react/src/components/admin/GroupMembers/GroupMembers.tsx @@ -22,6 +22,7 @@ import { getUsers, removeGroupMembers, resolveManagedIdentityName, + toEqualityFilter, toManagementErrorMessage, } from '@thunderid/browser'; import {FC, ReactElement, useMemo} from 'react'; @@ -37,6 +38,14 @@ interface GroupMembersVariables { members: Pick[]; } +/** + * A candidate page with the filter it was listed with. + */ +interface FilteredPage { + filter: string | undefined; + page: T; +} + /** * Query keys to refetch after a group's membership changes. */ @@ -63,6 +72,10 @@ export type GroupMembersProps = Omit< */ editable?: boolean; groupId: string; + /** + * Organization unit the users and agents offered for adding are listed from. + */ + ouId?: string; }; /** @@ -77,6 +90,7 @@ const GroupMembers: FC = ({ administrationMode, editable = false, groupId, + ouId, preferences, ...rest }: GroupMembersProps): ReactElement => { @@ -84,19 +98,20 @@ const GroupMembers: FC = ({ const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups'); const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const filter: string | undefined = toEqualityFilter('ouId', ouId); const members: ResourceQueryResult = useResourceQuery( [GroupQueryKeys.GROUP_MEMBERS, groupId, {limit: 100}], (fetcher) => getGroupMembers({...groupsEndpoint, fetcher, groupId, limit: 100}), {enabled: Boolean(groupId)}, ); - const users: ResourceQueryResult = useResourceQuery( - [UserQueryKeys.USERS, {limit: 100}], - (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + const users: ResourceQueryResult> = useResourceQuery( + [UserQueryKeys.USERS, {filter, limit: 100}], + async (fetcher) => ({filter, page: await getUsers({...usersEndpoint, fetcher, filter, limit: 100})}), {enabled: editable}, ); - const agents: ResourceQueryResult = useResourceQuery( - [AgentQueryKeys.AGENTS, {limit: 100}], - (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + const agents: ResourceQueryResult> = useResourceQuery( + [AgentQueryKeys.AGENTS, {filter, limit: 100}], + async (fetcher) => ({filter, page: await getAgents({...agentsEndpoint, fetcher, filter, limit: 100})}), {enabled: editable}, ); const add: ResourceMutationResult = useResourceMutation( @@ -110,16 +125,22 @@ const GroupMembers: FC = ({ getMembershipInvalidatedKeys, ); - const candidates: Partial> = useMemo( - () => ({ - agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: getAgentName(agent)})), - user: (users.data?.users ?? []).map((user: ManagedUser) => ({ + const candidates: Partial> = useMemo(() => { + // A page listed for an earlier ouId stays in a query until the next one succeeds, so it is not offered. + const current = (data: FilteredPage | undefined): T | undefined => + data && data.filter === filter ? data.page : undefined; + + return { + agent: (current(agents.data)?.agents ?? []).map((agent: BasicAgent) => ({ + id: agent.id, + label: getAgentName(agent), + })), + user: (current(users.data)?.users ?? []).map((user: ManagedUser) => ({ id: user.id, label: resolveManagedIdentityName(user), })), - }), - [agents.data, users.data], - ); + }; + }, [agents.data, filter, users.data]); return ( { }); }); + it('lists the group member candidates from the organization unit it is given', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({count: 0, members: [], startIndex: 1, totalResults: 0})); + + renderWithProviders(, {fetcher}); + + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(3)); + const filters: Record = Object.fromEntries( + fetcher.mock.calls.map(([url]: [string, RequestInit]) => { + const {pathname, searchParams} = new URL(url); + + return [pathname, searchParams.get('filter')]; + }), + ); + expect(filters).toEqual({ + '/agents': 'ouId eq "ou-1"', + '/groups/g-1/members': null, + '/users': 'ouId eq "ou-1"', + }); + }); + + it.each<[string, () => Promise]>([ + ['is pending', () => new Promise(() => undefined)], + ['has failed', () => Promise.resolve(respond({}, 500))], + ])( + 'offers no group member candidates from the previous organization unit while the new list %s', + async (_state, listNext) => { + const fetcher: FetcherMock = vi.fn().mockImplementation((url: string) => + new URL(url).searchParams.get('filter') === 'ouId eq "ou-2"' + ? listNext() + : Promise.resolve( + respond({ + agents: [{attributes: {name: 'Deploy bot'}, id: 'ag-1'}], + count: 1, + members: [], + startIndex: 1, + totalResults: 1, + users: [{display: 'Aiko Tanaka', id: 'u-1'}], + }), + ), + ); + let candidates: BaseGroupMembersRenderProps['candidates']; + const view = (ouId: string): ReactElement => ( + + {(props: BaseGroupMembersRenderProps) => { + candidates = props.candidates; + + return null; + }} + + ); + + const {rerender} = renderWithProviders(view('ou-1'), {fetcher}); + await waitFor(() => expect(candidates?.user).toHaveLength(1)); + expect(candidates?.agent).toHaveLength(1); + + rerender(view('ou-2')); + + await waitFor(() => + expect( + fetcher.mock.calls + .filter(([url]: [string, RequestInit]) => url.includes('ou-2')) + .map(([url]) => new URL(url).pathname), + ).toEqual(expect.arrayContaining(['/users', '/agents'])), + ); + await act(async () => { + await Promise.resolve(); + }); + expect(candidates).toEqual({agent: [], user: []}); + }, + ); + + it('lists the group member candidates without a filter when no organization unit is given', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({count: 0, members: [], startIndex: 1, totalResults: 0})); + + renderWithProviders(, {fetcher}); + + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(3)); + fetcher.mock.calls.forEach(([url]: [string, RequestInit]) => { + expect(new URL(url).searchParams.has('filter')).toBe(false); + }); + }); + it('hands the search to a list render function', () => { const onSearch: Mock = vi.fn(); diff --git a/packages/vue/src/__tests__/components/admin/admin-components.test.ts b/packages/vue/src/__tests__/components/admin/admin-components.test.ts index b062287c..2119a81a 100644 --- a/packages/vue/src/__tests__/components/admin/admin-components.test.ts +++ b/packages/vue/src/__tests__/components/admin/admin-components.test.ts @@ -655,6 +655,68 @@ describe('admin components (vue)', () => { expect(invalidatedKeys(invalidate)).toEqual(membershipKeys); }); + it('GroupMembers lists the candidates from the organization unit it is given', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + context.fetcher.mockImplementation(respondWith()); + mountWith(GroupMembers, {editable: true, groupId: 'g1', ouId: 'ou-1'}, context); + + await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(3)); + const filter = 'ouId eq "ou-1"'; + const query: string = new URLSearchParams({filter, include: 'display', limit: '100'}).toString(); + expect(requests(context)).toEqual( + expect.arrayContaining([ + 'GET /groups/g1/members?include=display&limit=100', + `GET /users?${query}`, + `GET /agents?${query}`, + ]), + ); + expect(keys()).toEqual([ + [GroupQueryKeys.GROUP_MEMBERS, 'g1', {limit: 100}], + [UserQueryKeys.USERS, {filter, limit: 100}], + [AgentQueryKeys.AGENTS, {filter, limit: 100}], + ]); + }); + + it.each<[string, () => Promise]>([ + ['is pending', () => new Promise(() => undefined)], + ['has failed', () => Promise.resolve(jsonResponse({}, 500))], + ])( + 'GroupMembers offers no candidates from the previous organization unit while the new list %s', + async (_state, listNext) => { + const context = createManagementContext(); + context.fetcher.mockImplementation((url: string, init: RequestInit) => + new URL(url).searchParams.get('filter') === 'ouId eq "ou-2"' + ? listNext() + : respondWith(() => ({ + ...page, + agents: [{attributes: {name: 'Deploy bot'}, id: 'ag-1'}], + members: [], + users: [{display: 'Aiko Tanaka', id: 'u-1'}], + }))(url, init), + ); + let renderProps: BaseGroupMembersRenderProps | undefined; + const {wrapper} = mountWith(GroupMembers, {editable: true, groupId: 'g1', ouId: 'ou-1'}, context, { + default: (props: BaseGroupMembersRenderProps) => { + renderProps = props; + + return h('span'); + }, + }); + + await vi.waitFor(() => expect(renderProps?.candidates?.user).toHaveLength(1)); + expect(renderProps?.candidates?.agent).toHaveLength(1); + + await wrapper.setProps({ouId: 'ou-2'}); + + await vi.waitFor(() => + expect(requests(context).filter((request: string) => request.includes('ou-2'))).toHaveLength(2), + ); + await nextTick(); + expect(renderProps?.candidates).toEqual({agent: [], user: []}); + }, + ); + it('RoleAssignments loads assignments and candidates, and assigns and unassigns', async () => { const context = createManagementContext(); const keys = subscribedKeys(context); diff --git a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts index 69b2e36f..28372a95 100644 --- a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts +++ b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts @@ -13,6 +13,7 @@ import { getUsers, removeGroupMembers, resolveManagedIdentityName, + toEqualityFilter, toManagementErrorMessage, } from '@thunderid/browser'; import type { @@ -41,6 +42,7 @@ type GroupMembersProps = Readonly<{ description: string | undefined; editable: boolean; groupId: string; + ouId: string | undefined; preferences: Preferences | undefined; title: string | undefined; }>; @@ -50,6 +52,14 @@ interface GroupMembersVariables { members: Pick[]; } +/** + * A candidate page with the filter it was listed with. + */ +interface FilteredPage { + filter: string | undefined; + page: T; +} + /** * Lists a group's members and, unless `editable` is `false`, lets the signed-in user add users and * agents to it or remove members. @@ -75,6 +85,10 @@ const GroupMembers: Component = defineComponent({ editable: {default: false, type: Boolean}, /** Identifier of the group. */ groupId: {required: true, type: String}, + /** + * Organization unit the users and agents offered for adding are listed from. + */ + ouId: {default: undefined, type: String}, /** Component-level preferences, for example `i18n.bundles` overrides. */ preferences: {default: undefined, type: Object as PropType}, /** Heading of the section. Pass an empty string to hide it. */ @@ -98,14 +112,23 @@ const GroupMembers: Component = defineComponent({ (fetcher) => getGroupMembers({...groupsEndpoint, fetcher, groupId: props.groupId, limit: 100}), {enabled: () => Boolean(props.groupId)}, ); - const users: ResourceQueryResult = useResourceQuery( - () => [UserQueryKeys.USERS, {limit: 100}], - (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + const filter = (): string | undefined => toEqualityFilter('ouId', props.ouId); + const users: ResourceQueryResult> = useResourceQuery( + () => [UserQueryKeys.USERS, {filter: filter(), limit: 100}], + async (fetcher) => { + const listed: string | undefined = filter(); + + return {filter: listed, page: await getUsers({...usersEndpoint, fetcher, filter: listed, limit: 100})}; + }, {enabled: editable}, ); - const agents: ResourceQueryResult = useResourceQuery( - () => [AgentQueryKeys.AGENTS, {limit: 100}], - (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + const agents: ResourceQueryResult> = useResourceQuery( + () => [AgentQueryKeys.AGENTS, {filter: filter(), limit: 100}], + async (fetcher) => { + const listed: string | undefined = filter(); + + return {filter: listed, page: await getAgents({...agentsEndpoint, fetcher, filter: listed, limit: 100})}; + }, {enabled: editable}, ); const add: ResourceMutationResult = useResourceMutation( @@ -125,13 +148,17 @@ const GroupMembers: Component = defineComponent({ invalidatedByMembership, ); + // A page listed for an earlier ouId stays in a query until the next one succeeds, so it is not offered. + const current = (data: FilteredPage | undefined): T | undefined => + data && data.filter === filter() ? data.page : undefined; + return (): VNode => { const candidates: Partial> = { - agent: (agents.data.value?.agents ?? []).map((agent: BasicAgent) => ({ + agent: (current(agents.data.value)?.agents ?? []).map((agent: BasicAgent) => ({ id: agent.id, label: getAgentName(agent), })), - user: (users.data.value?.users ?? []).map((user: ManagedUser) => ({ + user: (current(users.data.value)?.users ?? []).map((user: ManagedUser) => ({ id: user.id, label: resolveManagedIdentityName(user), })), diff --git a/playgrounds/react/src/pages/explorer/adminStories.tsx b/playgrounds/react/src/pages/explorer/adminStories.tsx index 2c34f81d..5dd0379d 100644 --- a/playgrounds/react/src/pages/explorer/adminStories.tsx +++ b/playgrounds/react/src/pages/explorer/adminStories.tsx @@ -687,7 +687,7 @@ const STORIES: ComponentDef[] = [ { code: (args: Args) => isLive(args) - ? {imports: ['GroupMembers'], jsx: jsx('GroupMembers', pick(args, 'groupId', 'editable', 'title'))} + ? {imports: ['GroupMembers'], jsx: jsx('GroupMembers', pick(args, 'groupId', 'ouId', 'editable', 'title'))} : { imports: ['BaseGroupMembers'], jsx: jsx('BaseGroupMembers', pick(args, 'title', 'isLoading', 'error'), { @@ -702,12 +702,18 @@ const STORIES: ComponentDef[] = [ controls: [ SOURCE_CONTROL, idControl('groupId'), + { + control: 'text', + description: 'Live only: the organization unit the member picker lists from.', + name: 'ouId', + type: 'string', + }, TITLE, {control: 'bool', description: 'Adds the member picker and remove buttons.', name: 'editable', type: 'boolean'}, LOADING, ERROR, ], - defaults: {editable: true, error: '', groupId: '', isLoading: false, source: 'base', title: ''}, + defaults: {editable: true, error: '', groupId: '', isLoading: false, ouId: '', source: 'base', title: ''}, group: 'Admin: groups', id: 'groupmembers', live: true, @@ -720,6 +726,7 @@ const STORIES: ComponentDef[] = [ diff --git a/playgrounds/vue/src/pages/explorer/adminStories.ts b/playgrounds/vue/src/pages/explorer/adminStories.ts index dcbec124..4b4bb532 100644 --- a/playgrounds/vue/src/pages/explorer/adminStories.ts +++ b/playgrounds/vue/src/pages/explorer/adminStories.ts @@ -829,8 +829,11 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ isLive(args) ? { imports: ['GroupMembers'], - template: tpl('GroupMembers', omit(args, 'source', 'isLoading', 'isMutating', 'error', 'groupId'), { - extra: [`group-id="${str(args, 'groupId') || SAMPLE_GROUPS[0].id}"`], + template: tpl('GroupMembers', omit(args, 'source', 'isLoading', 'isMutating', 'error', 'groupId', 'ouId'), { + extra: [ + `group-id="${str(args, 'groupId') || SAMPLE_GROUPS[0].id}"`, + ...(str(args, 'ouId') ? [`ou-id="${str(args, 'ouId')}"`] : []), + ], }), } : { @@ -845,7 +848,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ] : []), ], - template: tpl('BaseGroupMembers', omit(args, 'source', 'editable', 'groupId'), { + template: tpl('BaseGroupMembers', omit(args, 'source', 'editable', 'groupId', 'ouId'), { extra: [ ':members="members"', ...(bool(args, 'editable') @@ -859,6 +862,12 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ...titleControls, {control: 'bool', description: 'Shows the add form and remove buttons.', name: 'editable', type: 'boolean'}, idControl('groupId', 'group'), + { + control: 'text', + description: 'Live only: the organization unit the add form lists from.', + name: 'ouId', + type: 'string', + }, {control: 'bool', description: 'Base only: an add or remove is in flight.', name: 'isMutating', type: 'boolean'}, ...baseStateControls, ], @@ -869,6 +878,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ groupId: '', isLoading: false, isMutating: false, + ouId: '', source: 'base', title: '', }, @@ -886,7 +896,13 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ pick: firstOf('groups'), query: () => useApiQuery(() => getGroups({...useManagementRequestConfig('groups'), limit: 1})), render: (groupId: string): VNode => - h(GroupMembers, {...heading(args), editable: bool(args, 'editable'), groupId, key: groupId}), + h(GroupMembers, { + ...heading(args), + editable: bool(args, 'editable'), + groupId, + key: groupId, + ouId: str(args, 'ouId') || undefined, + }), }) : h(BaseGroupMembers, { ...heading(args),