diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx index 290ca1ac9c..3239816d49 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx @@ -16,55 +16,73 @@ beforeAll(() => { }); const ALLOWED = ['admin', 'auditor', 'employee', 'contractor']; +const CUSTOM_ROLES = [ + { id: 'orole_1', name: 'Security Reviewer', permissions: { control: ['read'] } }, +]; -async function openAndGetRoleItem(name: string) { - const user = userEvent.setup(); - await user.click(screen.getByRole('combobox')); - const item = (await screen.findByText(name)).closest('[cmdk-item]'); - if (!(item instanceof HTMLElement)) { - throw new Error(`Role item "${name}" not found`); - } - return { user, item }; -} - -describe('MultiRoleCombobox selection', () => { - it('does not cancel pointerdown on a role item (regression: role not selectable)', async () => { - // The role items previously had `onPointerDown={(e) => e.preventDefault()}`. - // Cancelling pointerdown suppresses the browser's native pointer->click - // sequence that drives cmdk's `onSelect`, so clicking a role did nothing. - // jsdom fires a synthetic click that bypasses the pointer sequence, so we - // assert the underlying DOM contract directly: the item must let the - // pointerdown default action proceed. +describe('MultiRoleCombobox (CS-748)', () => { + it('forces pointer-events on the popover content so roles stay clickable inside a modal dialog', async () => { + // This combobox is used inside the modal "Add User" Radix Dialog, which + // locks `body { pointer-events: none }`. A Radix dismissable-layer version + // skew (react-dialog -> 1.1.15 vs react-popover -> 1.1.11) puts the two in + // separate module-level layer contexts, so the portaled popover never + // re-enables pointer events and its role items inherit `none` — unclickable + // and unhoverable. The fix forces pointer-events on the content. jsdom can't + // hit-test, so we assert the fix is present here; the end-to-end click + // behavior inside the modal is verified in a real browser. + const user = userEvent.setup(); render( , ); - const { item } = await openAndGetRoleItem('Admin'); - const pointerDown = new PointerEvent('pointerdown', { bubbles: true, cancelable: true }); - item.dispatchEvent(pointerDown); + await user.click(screen.getByRole('combobox')); - expect(pointerDown.defaultPrevented).toBe(false); + const content = document.querySelector('[data-slot="popover-content"]'); + expect(content).not.toBeNull(); + expect(content).toHaveStyle({ pointerEvents: 'auto' }); }); - it('adds a role to the selection when clicked', async () => { + it('selects a built-in role when clicked', async () => { const handleChange = vi.fn(); + const user = userEvent.setup(); render( , ); - const { user, item } = await openAndGetRoleItem('Admin'); - await user.click(item); + await user.click(screen.getByRole('combobox')); + await user.click(await screen.findByText('Admin')); expect(handleChange).toHaveBeenCalledWith(['admin']); }); + + it('selects a custom role when clicked (the customer-reported path)', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(await screen.findByText('Security Reviewer')); + + expect(handleChange).toHaveBeenCalledWith(['Security Reviewer']); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx index 12e6b38c87..7e2e19619e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx @@ -169,7 +169,22 @@ export function MultiRoleCombobox({ /> - + {/* + This Popover is portaled to while rendered inside a modal Radix + Dialog (the invite "Add User" dialog). The Dialog locks + `body { pointer-events: none }`, and a Radix version skew + (react-dialog -> react-dismissable-layer@1.1.15 vs + react-popover -> react-dismissable-layer@1.1.11) means the two live in + separate module-level layer contexts, so the popover never re-enables + pointer events on itself and its items inherit `none` — making every + role unclickable/unhoverable. Forcing pointer-events here restores + interactivity for the whole popover subtree. See CS-748. + */} +