diff --git a/apps/sim/components/access-requests/organization-access-requests.tsx b/apps/sim/components/access-requests/organization-access-requests.tsx index a37570b024d..08bb9d4eee8 100644 --- a/apps/sim/components/access-requests/organization-access-requests.tsx +++ b/apps/sim/components/access-requests/organization-access-requests.tsx @@ -1,6 +1,7 @@ 'use client' -import { Chip, ChipDropdown, ChipSwitch, ChipTag, toast } from '@sim/emcn' +import { Chip, ChipDropdown, ChipInput, ChipSwitch, ChipTag, toast } from '@sim/emcn' +import { Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' import { AccessRequestReview } from '@/components/access-requests/access-request-review' import { @@ -8,21 +9,26 @@ import { accessReviewSearchParams, } from '@/components/access-requests/search-params' import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' -import { EmptyState } from '@/components/empty-state/empty-state' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/lib/permission-access-requests/constants' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { ACCESS_REQUEST_PAGE_SIZE, useAccessRequestSettings, useOrganizationAccessRequests, useUpdateAccessRequestSettings, } from '@/hooks/queries/access-requests' +import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' interface OrganizationAccessRequestsProps { organizationId: string @@ -37,17 +43,24 @@ export function OrganizationAccessRequests({ ...accessRequestUrlOptions, urlKeys: { 'request-id': standalone ? 'requestId' : 'request-id' }, }) + const searchTerm = params['request-search'] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setParams({ 'request-search': value, 'request-page': 0 }, options) + ) + const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) + const searchPending = searchTerm.trim() !== debouncedSearch const page = params['request-page'] const requests = useOrganizationAccessRequests( organizationId, page * ACCESS_REQUEST_PAGE_SIZE, - params['request-status'] + params['request-status'], + debouncedSearch ) const settings = useAccessRequestSettings(organizationId) const updateSettings = useUpdateAccessRequestSettings(organizationId) - return ( -
+ const content = ( +
{settings.isPending ? ( Loading request settings... ) : settings.isError ? ( @@ -85,77 +98,83 @@ export function OrganizationAccessRequests({ } /> )} -
-

Requests

- - void setParams({ - 'request-status': value as (typeof params)['request-status'], - 'request-page': 0, - }) - } - options={[ - { value: 'pending', label: 'Pending' }, - { value: 'fulfilled', label: ACCESS_REQUEST_STATUS_LABELS.fulfilled }, - { value: 'declined', label: 'Declined' }, - { value: 'cancelled', label: 'Cancelled' }, - { value: 'closed', label: 'Closed' }, - { value: 'all', label: 'All requests' }, - ]} - aria-label='Filter request status' - /> -
- {requests.isPending ? ( -

- Loading requests... -

- ) : requests.isError ? ( - void requests.refetch()}>Try again} - /> - ) : ( -
- {requests.data.requests.length === 0 && ( - - )} - {requests.data.requests.map((request) => ( - {ACCESS_REQUEST_STATUS_LABELS[request.status]} - } - onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })} - clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`} - navigable - /> - ))} -
- )} - {(page > 0 || requests.data?.hasMore) && ( -
- void setParams({ 'request-page': page - 1 })} - > - Previous - - Page {page + 1} - void setParams({ 'request-page': page + 1 })} - > - Next - -
- )} + + void setParams({ + 'request-status': value as (typeof params)['request-status'], + 'request-page': 0, + }) + } + options={[ + { value: 'pending', label: 'Pending' }, + { value: 'fulfilled', label: ACCESS_REQUEST_STATUS_LABELS.fulfilled }, + { value: 'declined', label: 'Declined' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'closed', label: 'Closed' }, + { value: 'all', label: 'All requests' }, + ]} + aria-label='Filter request status' + /> + } + > + {searchPending || requests.isPending ? ( + + Loading requests... + + ) : requests.isError ? ( + void requests.refetch()} + /> + ) : ( +
+ {requests.data.requests.length === 0 && ( + + {debouncedSearch + ? `No requests found matching "${searchTerm.trim()}"` + : 'No access requests. Requests from your members will appear here.'} + + )} + {requests.data.requests.map((request) => ( + {ACCESS_REQUEST_STATUS_LABELS[request.status]} + } + onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })} + clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`} + navigable + /> + ))} +
+ )} + {!searchPending && (page > 0 || requests.data?.hasMore) && ( +
+ void setParams({ 'request-page': page - 1 })} + > + Previous + + Page {page + 1} + void setParams({ 'request-page': page + 1 })} + > + Next + +
+ )} +
{params['request-id'] && ( ) + + const search = { + value: searchTerm, + onChange: setSearchTerm, + placeholder: 'Search requests...', + maxLength: ACCESS_REQUEST_MAX_SEARCH_LENGTH, + } + + return standalone ? ( +
+ search.onChange(event.target.value)} + placeholder={search.placeholder} + maxLength={search.maxLength} + aria-label='Search requests' + autoComplete='off' + /> + {content} +
+ ) : ( + {content} + ) } diff --git a/apps/sim/components/access-requests/search-params.test.ts b/apps/sim/components/access-requests/search-params.test.ts index b13b6dff99e..9a6f1a531b0 100644 --- a/apps/sim/components/access-requests/search-params.test.ts +++ b/apps/sim/components/access-requests/search-params.test.ts @@ -20,5 +20,7 @@ describe('access request URL bounds', () => { expect(accessRequestSearchParams.requestId.parse('request-1')).toBe('request-1') expect(accessRequestSearchParams.requestId.parse('x'.repeat(129))).toBeNull() expect(accessRequestSearchParams.search.parse('x'.repeat(201))).toBeNull() + expect(accessReviewSearchParams['request-search'].parse('Tables')).toBe('Tables') + expect(accessReviewSearchParams['request-search'].parse('x'.repeat(201))).toBeNull() }) }) diff --git a/apps/sim/components/access-requests/search-params.ts b/apps/sim/components/access-requests/search-params.ts index afc9c44f46a..639adf68736 100644 --- a/apps/sim/components/access-requests/search-params.ts +++ b/apps/sim/components/access-requests/search-params.ts @@ -39,6 +39,7 @@ export const accessRequestSearchParams = { export const accessReviewSearchParams = { 'access-view': parseAsStringLiteral(['groups', 'requests'] as const).withDefault('groups'), 'request-id': accessRequestIdParser, + 'request-search': accessRequestSearchParser, 'request-page': accessRequestPageParser, 'request-status': parseAsStringLiteral([ 'pending', @@ -57,5 +58,6 @@ export const accessRequestEntrySearchParams = { organizationId: accessRequestIdParser, view: parseAsStringLiteral(['requests', 'catalog', 'admin'] as const).withDefault('requests'), 'request-page': accessReviewSearchParams['request-page'], + 'request-search': accessReviewSearchParams['request-search'], 'request-status': accessReviewSearchParams['request-status'], } as const diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 27788ef7293..65c51512658 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -13,7 +13,8 @@ import { useRef, useState, } from 'react' -import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn' +import { Chip, ChipInput, ChipLink, cn, Tooltip } from '@sim/emcn' +import { Search } from '@sim/emcn/icons' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect @@ -48,6 +49,7 @@ export interface SettingsHeaderSearch { onChange: (value: string) => void placeholder?: string disabled?: boolean + maxLength?: number } export interface SettingsBackAction { @@ -110,7 +112,12 @@ function computeSignature(config: SettingsHeaderConfig | null): string { action.onPrefetch ? 1 : 0, ]), search: config.search - ? [config.search.value, config.search.placeholder ?? '', config.search.disabled ?? false] + ? [ + config.search.value, + config.search.placeholder ?? '', + config.search.disabled ?? false, + config.search.maxLength ?? null, + ] : null, }) } @@ -333,6 +340,7 @@ export function SettingsHeaderShell({ meta, children }: SettingsHeaderShellProps value={search.value} onChange={(event) => configRef?.current?.search?.onChange(event.target.value)} disabled={search.disabled} + maxLength={search.maxLength} autoComplete='off' className='w-full' /> diff --git a/apps/sim/ee/access-control/components/access-control-layout.test.tsx b/apps/sim/ee/access-control/components/access-control-layout.test.tsx new file mode 100644 index 00000000000..80ee57e878e --- /dev/null +++ b/apps/sim/ee/access-control/components/access-control-layout.test.tsx @@ -0,0 +1,175 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ groups: vi.fn(), requests: vi.fn() })) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace' }), + usePathname: () => '/settings/access-control', +})) +vi.mock('@/ee/access-control/components/group-detail', () => ({ GroupDetail: () => null })) +vi.mock('@/components/access-requests/access-request-review', () => ({ + AccessRequestReview: () => null, +})) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useCreatePermissionGroup: () => ({ isPending: false, mutateAsync: vi.fn() }), + useOrganizationWorkspaces: () => ({ data: [], isPending: false }), + usePermissionGroups: mocks.groups, + useUserPermissionConfig: () => ({ data: { entitled: true }, isPending: false }), +})) +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizationBilling: () => ({ data: undefined, isPending: false }), +})) +vi.mock('@/hooks/queries/access-requests', () => ({ + ACCESS_REQUEST_PAGE_SIZE: 25, + useOrganizationAccessRequests: mocks.requests, + useAccessRequestSettings: () => ({ data: { allowRequests: true }, isPending: false }), + useUpdateAccessRequestSettings: () => ({ mutate: vi.fn(), isPending: false }), +})) + +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { SettingsSectionProvider } from '@/components/settings/settings-panel' +import { AccessControl } from '@/ee/access-control/components/access-control' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mocks.groups.mockReturnValue({ data: [], isPending: false }) + mocks.requests.mockReturnValue({ data: { requests: [], hasMore: false }, isPending: false }) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() +}) + +function render(searchParams = '') { + act(() => + root.render( + + + + + + + + + + ) + ) +} + +function switchView(value: string) { + const button = container.querySelector( + `[aria-label="Access Control views"] [role="radio"][value="${value}"]` + ) + expect(button).not.toBeNull() + act(() => button!.click()) +} + +describe('permission groups search layout', () => { + it('resets pagination and hides stale results while a new request search is debounced', async () => { + vi.useFakeTimers() + mocks.requests.mockReturnValue({ + data: { + requests: [ + { + id: 'request', + targetLabel: 'Previous result', + requester: { name: 'Member' }, + status: 'pending', + createdAt: '2026-09-01T00:00:00Z', + }, + ], + hasMore: true, + }, + isPending: false, + }) + render('?access-view=requests&request-page=2') + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 50, 'pending', '') + const input = container.querySelector('input')! + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Tables' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(container.textContent).toContain('Loading requests...') + expect(container.textContent).not.toContain('Previous result') + expect(container.textContent).not.toContain('Page 3') + await act(async () => vi.advanceTimersByTimeAsync(500)) + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') + }) + it('keeps the same search input above the switch and restores each view’s search', () => { + render('?search=Design&request-search=Tables') + const input = container.querySelector('input')! + const viewSwitch = container.querySelector('[aria-label="Access Control views"]')! + expect(input.value).toBe('Design') + expect( + input.compareDocumentPosition(viewSwitch) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + + switchView('requests') + expect(container.querySelector('input')).toBe(input) + expect(input.placeholder).toBe('Search requests...') + expect(input.value).toBe('Tables') + expect(input.maxLength).toBe(200) + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') + expect(container.textContent).toContain('No requests found matching "Tables"') + + switchView('groups') + expect(container.querySelector('input')).toBe(input) + expect(input.value).toBe('Design') + expect(input.placeholder).toBe('Search permission groups...') + expect(input.hasAttribute('maxlength')).toBe(false) + }) + + it('retains search when either list is loading or fails', () => { + mocks.groups.mockReturnValue({ isPending: true }) + render() + const input = container.querySelector('input')! + expect(input.disabled).toBe(true) + mocks.requests.mockReturnValue({ isPending: true }) + switchView('requests') + expect(container.querySelector('input')).toBe(input) + expect(input.disabled).toBe(false) + expect(container.textContent).toContain('Loading requests...') + + mocks.groups.mockReturnValue({ + isPending: false, + error: new Error('Groups unavailable'), + isFetching: false, + refetch: vi.fn(), + }) + switchView('groups') + expect(container.querySelector('input')).toBe(input) + expect(container.textContent).toContain('Groups unavailable') + + mocks.requests.mockReturnValue({ + isPending: false, + isError: true, + error: new Error('Requests unavailable'), + isFetching: false, + refetch: vi.fn(), + }) + switchView('requests') + expect(container.querySelector('input')).toBe(input) + expect(container.textContent).toContain('Requests unavailable') + }) +}) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 97aaf7dac7c..10869e2f3e3 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -78,9 +78,7 @@ export function AccessControl(props: AccessControlProps) { onChange={(value) => void setParams({ 'access-view': value, 'request-id': null })} /> {params['access-view'] === 'requests' ? ( - - - + ) : ( )} @@ -150,9 +148,6 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl ...groupIdUrlKeys, }) - // Params scoped to the detail sub-view are cleared alongside the group id, so - // a tab/search/filter can't linger on the list URL after going back. nuqs - // batches these same-tick writes into a single URL update. const [, setGroupTab] = useQueryState(groupTabParam.key, { ...groupTabParam.parser, ...groupTabUrlKeys, @@ -197,16 +192,13 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl [organizationWorkspaces] ) - const filteredGroups = useMemo(() => { - if (!searchTerm.trim()) return permissionGroups - const searchLower = searchTerm.toLowerCase() - return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower)) - }, [permissionGroups, searchTerm]) - - const selectedGroup = useMemo( - () => (selectedGroupId ? permissionGroups.find((g) => g.id === selectedGroupId) : undefined), - [permissionGroups, selectedGroupId] - ) + const searchLower = searchTerm.trim().toLowerCase() + const filteredGroups = searchLower + ? permissionGroups.filter((group) => group.name.toLowerCase().includes(searchLower)) + : permissionGroups + const selectedGroup = selectedGroupId + ? permissionGroups.find((group) => group.id === selectedGroupId) + : undefined const handleCreatePermissionGroup = async () => { if (!newGroupName.trim() || !organizationId) return @@ -284,7 +276,7 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl if (groupsError) { return ( - +
- -
+ + {(aria) => ( - {!newGroupIsDefault && ( -

- Applies to all members of the selected workspaces. Restrict to specific people - later from the group's Members section. -

- )} -
+ )}
{createError} diff --git a/apps/sim/ee/access-control/components/workspace-select.tsx b/apps/sim/ee/access-control/components/workspace-select.tsx index 8f6ab20a7fd..0b5c758c32a 100644 --- a/apps/sim/ee/access-control/components/workspace-select.tsx +++ b/apps/sim/ee/access-control/components/workspace-select.tsx @@ -1,8 +1,12 @@ 'use client' -import { ChipDropdown } from '@sim/emcn' +import { ChipDropdown, type ChipDropdownProps } from '@sim/emcn' -interface WorkspaceSelectProps { +interface WorkspaceSelectProps + extends Pick< + ChipDropdownProps, + 'id' | 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'aria-required' | 'aria-invalid' + > { workspaceIds: string[] onChange: (ids: string[]) => void options: { value: string; label: string }[] @@ -31,9 +35,11 @@ export function WorkspaceSelect({ fullWidth = false, className, allowAllWorkspaces = true, + ...fieldAria }: WorkspaceSelectProps) { return ( { mcpServers: NonNullable['mcpServers'] = [ { ...provider, enabled: true }, ], - options: NonNullable['options'] = [] + options: NonNullable['options'] = [], + searchParams = '' ) { await act(async () => root.render( - + { expect(button?.disabled).toBe(false) await act(async () => button?.click()) } + + it('filters integrations without dropping hidden providers from configuration updates', async () => { + await render([], [gmail, github], '?credential-group-provider=+GMAIL+') + expect(container.textContent).toContain('Gmail') + expect(container.textContent).not.toContain('GitHub') + await clickButton('Update configurations') + expect( + mocks.update.mock.calls[0][0].update.options.map( + (option: { provider: string }) => option.provider + ) + ).toEqual(['gmail', 'github-repositories']) + }) + + it('distinguishes an integration search miss from an unconfigured group', async () => { + await render([], [gmail], '?credential-group-provider=missing') + expect(container.textContent).toContain('No integrations match your search') + expect(container.textContent).not.toContain('Add an integration to start connecting accounts.') + }) async function fill(labelText: string, value: string) { const label = Array.from(document.querySelectorAll('label')).find((node) => node.textContent?.startsWith(labelText) diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx index d303f264094..c6bf38321cc 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -12,6 +12,7 @@ import { } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' @@ -20,8 +21,13 @@ import { type CredentialGroupProvider, getCredentialGroupProviderService, } from '@/lib/credential-groups/providers' +import { + credentialGroupProviderSearchParam, + credentialGroupProviderSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, SettingsResourceRow, @@ -38,6 +44,7 @@ import { useRemoveOrganizationAccountMcpProvider, useUpdateOrganizationAccounts, } from '@/hooks/queries/organization-accounts' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' interface OrganizationAccountProvidersProps { organizationId: string @@ -50,6 +57,11 @@ export function OrganizationAccountProviders({ group, availableProviders, }: OrganizationAccountProvidersProps) { + const [searchTerm, setSearchParam] = useQueryState(credentialGroupProviderSearchParam.key, { + ...credentialGroupProviderSearchParam.parser, + ...credentialGroupProviderSearchUrlKeys, + }) + const setSearchTerm = useDebouncedSearchSetter(setSearchParam) const [catalogOpen, setCatalogOpen] = useState(false) const [removing, setRemoving] = useState(null) const [slackOpen, setSlackOpen] = useState(false) @@ -149,6 +161,8 @@ export function OrganizationAccountProviders({ choice: { kind: 'mcp', connectorId: server.managedConnectorId } as const, })), ].sort((left, right) => left.name.localeCompare(right.name)) + const normalizedSearch = searchTerm.trim().toLowerCase() + const visibleRows = rows.filter((row) => row.name.toLowerCase().includes(normalizedSearch)) const error = update.error ?? addMcp.error ?? removeMcp.error const removingName = removing ? removing.kind === 'oauth' @@ -157,7 +171,9 @@ export function OrganizationAccountProviders({ : '' return ( -
+ )}
- {rows.map(({ id, name, icon: Icon, configure, choice }) => ( + {visibleRows.map(({ id, name, icon: Icon, configure, choice }) => ( } @@ -221,9 +237,11 @@ export function OrganizationAccountProviders({ } /> ))} - {!rows.length && ( + {!visibleRows.length && ( - Add an integration to start connecting accounts. + {normalizedSearch + ? 'No integrations match your search' + : 'Add an integration to start connecting accounts.'} )}
@@ -295,6 +313,6 @@ export function OrganizationAccountProviders({ /> )} -
+ ) } diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx index 04e7cc23740..af9046a7d8c 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx @@ -3,6 +3,7 @@ */ import { act, type ComponentProps, type ReactNode } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { @@ -32,6 +33,9 @@ vi.mock('@sim/emcn', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess }, })) vi.mock('@sim/emcn/icons', () => ({ Workspaces: () => null, Plus: () => null })) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children: ReactNode }) => <>{children}, +})) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccountWorkspaceAccess: mocks.useAccess, useUpdateOrganizationAccountWorkspaceAccess: () => ({ @@ -134,12 +138,18 @@ function setAccess(grants = gmailGrants(['workspace-1']), revision = 3) { }) } -function renderAccess() { +function renderAccess(searchParams = '') { const container = document.createElement('div') const root = createRoot(container) mountedRoots.push(root) const rerender = () => - act(() => root.render()) + act(() => + root.render( + + + + ) + ) const button = (label: string, scope: ParentNode = container) => { const match = [...scope.querySelectorAll('button')].find( (candidate) => candidate.textContent === label @@ -218,7 +228,8 @@ it('adds an explicit All integrations grant', async () => { it('edits one workspace without changing other workspace grants', async () => { setAccess(gmailGrants(['workspace-1', 'workspace-2'])) - const editor = renderAccess() + const editor = renderAccess('?credential-group-workspace=+FINANCE+') + expect(editor.rows()).toEqual(['Finance']) const finance = editor.container.querySelector('[data-workspace="Finance"]') if (!finance) throw new Error('Finance row not found') act(() => editor.button('Edit access', finance).click()) diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx index bc29477405e..396a3c8a593 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx @@ -4,22 +4,26 @@ import { useState } from 'react' import { Chip, toast } from '@sim/emcn' import { Plus, Workspaces } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' import type { OrganizationAccountWorkspaceAccess as WorkspaceAccess } from '@/lib/api/contracts/organization-accounts' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { OrganizationWorkspaceGrantModal } from '@/ee/credential-groups/components/organization-workspace-grant-modal' +import { credentialGroupWorkspaceSearchParam } from '@/ee/credential-groups/search-params' import { useOrganizationAccountWorkspaceAccess, useUpdateOrganizationAccountWorkspaceAccess, } from '@/hooks/queries/organization-accounts' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' type Grant = WorkspaceAccess['grants'][number] type GrantEditor = @@ -34,31 +38,44 @@ export function OrganizationAccountWorkspaceAccess({ organizationId, }: OrganizationAccountWorkspaceAccessProps) { const access = useOrganizationAccountWorkspaceAccess(organizationId) - if (access.error) - return ( - void access.refetch()} - /> - ) - if (!access.data) - return

Loading workspace access…

+ const [searchTerm, setSearchParam] = useQueryState(credentialGroupWorkspaceSearchParam.key, { + ...credentialGroupWorkspaceSearchParam.parser, + history: 'replace', + clearOnDefault: true, + }) + const setSearchTerm = useDebouncedSearchSetter(setSearchParam) return ( - + + {access.error ? ( + void access.refetch()} + variant='inline' + /> + ) : !access.data ? ( + Loading workspace access… + ) : ( + + )} + ) } interface WorkspaceAccessFormProps extends OrganizationAccountWorkspaceAccessProps { access: WorkspaceAccess + searchTerm: string } -function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProps) { +function WorkspaceAccessForm({ organizationId, access, searchTerm }: WorkspaceAccessFormProps) { const update = useUpdateOrganizationAccountWorkspaceAccess() const [editor, setEditor] = useState(null) const byId = new Map(access.workspaces.map((workspace) => [workspace.id, workspace])) @@ -76,6 +93,10 @@ function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProp } } const allowedWorkspaces = access.workspaces.filter((workspace) => grantsById.has(workspace.id)) + const normalizedSearch = searchTerm.trim().toLowerCase() + const visibleWorkspaces = allowedWorkspaces.filter((workspace) => + workspace.name.toLowerCase().includes(normalizedSearch) + ) const availableWorkspaces = access.workspaces.filter((workspace) => !grantsById.has(workspace.id)) const save = async (grants: WorkspaceAccess['grants'], revision: number) => { @@ -135,11 +156,13 @@ function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProp {update.error.message}

)} - {!allowedWorkspaces.length ? ( - No workspaces have access + {!visibleWorkspaces.length ? ( + + {normalizedSearch ? 'No workspaces match your search' : 'No workspaces have access'} + ) : (
- {allowedWorkspaces.map((workspace) => { + {visibleWorkspaces.map((workspace) => { const grant = grantsById.get(workspace.id)! return ( ) if (!accounts.data) - return

Loading Credential Groups…

+ return Loading Credential Groups… if (!accounts.data.canManage) - return

An organization admin manages Credential Groups.

+ return An organization admin manages Credential Groups. const group = accounts.data.credentialGroup if (!group) return ( @@ -59,9 +62,10 @@ export function OrganizationConnectedAccounts({
) return ( -
+
void setView({ tab: value })} options={[ diff --git a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx index e2c01a12cb2..5c27e1aa1a1 100644 --- a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx @@ -160,6 +160,18 @@ describe('workspace integration grant editor', () => { }) }) + it('connects the integration selector to its required state and explanatory hint', async () => { + await render({ mode: 'all' }) + const trigger = document.querySelector('[aria-label="Integrations"]')! + expect(trigger.hasAttribute('aria-required')).toBe(false) + const description = trigger + .getAttribute('aria-describedby')! + .split(' ') + .map((id) => document.getElementById(id)?.textContent) + .join(' ') + expect(description).toBe('Includes integrations added in the future. Required.') + }) + it.each(['all', 'selected'] as const)( 'does not treat clearing the last %s selection as unrestricted access', async (mode) => { diff --git a/apps/sim/ee/credential-groups/search-params.ts b/apps/sim/ee/credential-groups/search-params.ts index c720460dca3..942578d66f0 100644 --- a/apps/sim/ee/credential-groups/search-params.ts +++ b/apps/sim/ee/credential-groups/search-params.ts @@ -1,7 +1,12 @@ -import { parseAsStringLiteral } from 'nuqs/server' +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' export const credentialGroupsParsers = { tab: parseAsStringLiteral(['providers', 'people', 'workspace-access'] as const).withDefault( 'providers' ), } + +export const credentialGroupWorkspaceSearchParam = { + key: 'credential-group-workspace', + parser: parseAsString.withDefault(''), +} as const diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts index 2c1b6d3a38a..dfa5d75d6d0 100644 --- a/apps/sim/hooks/queries/access-requests.ts +++ b/apps/sim/hooks/queries/access-requests.ts @@ -106,14 +106,20 @@ export function useMyAccessRequests( export function useOrganizationAccessRequests( organizationId: string, offset = 0, - status: AccessRequestStatus | 'all' = 'pending' + status: AccessRequestStatus | 'all' = 'pending', + search = '' ) { return useQuery({ - queryKey: accessRequestKeys.organization(organizationId, offset, status), + queryKey: accessRequestKeys.organization(organizationId, offset, status, search), queryFn: ({ signal }) => requestJson(listOrganizationAccessRequestsContract, { params: { id: organizationId }, - query: { offset, limit: ACCESS_REQUEST_PAGE_SIZE, ...(status === 'all' ? {} : { status }) }, + query: { + offset, + limit: ACCESS_REQUEST_PAGE_SIZE, + ...(status === 'all' ? {} : { status }), + ...(search ? { search } : {}), + }, signal, }), enabled: Boolean(organizationId), diff --git a/apps/sim/hooks/queries/utils/access-request-keys.ts b/apps/sim/hooks/queries/utils/access-request-keys.ts index 3f53588e4d4..a279fab5bfe 100644 --- a/apps/sim/hooks/queries/utils/access-request-keys.ts +++ b/apps/sim/hooks/queries/utils/access-request-keys.ts @@ -11,8 +11,13 @@ export const accessRequestKeys = { lists: () => [...accessRequestKeys.all, 'list'] as const, mine: (scope: AccessRequestScope, offset: number, requestId?: string) => [...accessRequestKeys.lists(), 'mine', scope, offset, requestId ?? ''] as const, - organization: (organizationId: string, offset: number, status: AccessRequestStatus | 'all') => - [...accessRequestKeys.lists(), 'organization', organizationId, offset, status] as const, + organization: ( + organizationId: string, + offset: number, + status: AccessRequestStatus | 'all', + search = '' + ) => + [...accessRequestKeys.lists(), 'organization', organizationId, offset, status, search] as const, discoveries: () => [...accessRequestKeys.all, 'discovery'] as const, discovery: (query: DiscoverAccessRequestsQuery) => [...accessRequestKeys.discoveries(), query] as const, diff --git a/apps/sim/lib/api/contracts/access-requests.test.ts b/apps/sim/lib/api/contracts/access-requests.test.ts index 22c27d5fedc..c3fa161235b 100644 --- a/apps/sim/lib/api/contracts/access-requests.test.ts +++ b/apps/sim/lib/api/contracts/access-requests.test.ts @@ -8,10 +8,24 @@ import { accessRequestTargetSchema, createAccessRequestBodySchema, discoverAccessRequestsQuerySchema, + listOrganizationAccessRequestsQuerySchema, resolveAccessRequestBodySchema, } from '@/lib/api/contracts/access-requests' describe('access request contracts', () => { + it('normalizes and bounds organization request search without changing pagination', () => { + expect( + listOrganizationAccessRequestsQuerySchema.parse({ + search: ' Tables ', + status: 'pending', + offset: '25', + limit: '25', + }) + ).toEqual({ search: 'Tables', status: 'pending', offset: 25, limit: 25 }) + expect( + listOrganizationAccessRequestsQuerySchema.safeParse({ search: 'x'.repeat(201) }).success + ).toBe(false) + }) it('requires one explicit scope and rejects mixed or empty scope IDs', () => { expect( accessRequestScopeSchema.safeParse({ kind: 'workspace', workspaceId: 'workspace-1' }).success diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index c9294e73dea..53f40b4f172 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -134,6 +134,7 @@ export type OrganizationAccessRequestDetailParams = z.input< export const listOrganizationAccessRequestsQuerySchema = z .object({ ...paginationShape, + search: z.string().trim().max(ACCESS_REQUEST_MAX_SEARCH_LENGTH).optional(), status: z.enum(ACCESS_REQUEST_STATUSES).optional(), }) .strict() diff --git a/apps/sim/lib/permission-access-requests/application/requests.ts b/apps/sim/lib/permission-access-requests/application/requests.ts index e3e023492b3..0f5ed0715ed 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.ts +++ b/apps/sim/lib/permission-access-requests/application/requests.ts @@ -517,6 +517,7 @@ interface OrganizationListInput extends OrganizationInput { limit: number offset: number status?: AccessRequestStatus + search?: string } const organizationScope = (input: OrganizationInput): AccessRequestScope => ({ kind: 'organization', @@ -534,7 +535,8 @@ export const listOrganizationAccessRequests = defineAuthorizedAccessRequestUseCa input.status ? eq(permissionAccessRequest.status, input.status) : undefined )!, input.limit, - input.offset + input.offset, + input.search ), }) diff --git a/apps/sim/lib/permission-access-requests/repository.postgres.test.ts b/apps/sim/lib/permission-access-requests/repository.postgres.test.ts new file mode 100644 index 00000000000..ba9e215772d --- /dev/null +++ b/apps/sim/lib/permission-access-requests/repository.postgres.test.ts @@ -0,0 +1,100 @@ +/** @vitest-environment node */ +import { permissionAccessRequest } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { listAccessRequestRecords } from '@/lib/permission-access-requests/repository' + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + +const databaseUrl = process.env.ACCESS_REQUESTS_TEST_DATABASE_URL + +async function createFixture() { + const url = new URL(databaseUrl ?? '') + if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + url.pathname !== '/sim_access_requests_test' + ) + throw new Error('Use a disposable local sim_access_requests_test database') + const schema = `access_search_${generateId().replaceAll('-', '')}` + const client = postgres(url.toString(), { max: 1, onnotice: () => undefined }) + await client.unsafe(`CREATE SCHEMA "${schema}"`) + await client.unsafe(`SET search_path TO "${schema}"`) + await client.unsafe(` + CREATE TABLE "user" (id text PRIMARY KEY, name text, email text); + CREATE TABLE permission_access_request ( + id text PRIMARY KEY, organization_id text, workspace_id text, requester_id text, + target jsonb DEFAULT '{"kind":"feature","configKey":"hideTablesTab"}', + target_label text, reason text DEFAULT '', status text, decision_reason text, + created_at timestamp DEFAULT now(), decided_at timestamp, group_name text + ); + INSERT INTO "user" VALUES + ('one', 'Alex Example', 'alex@example.com'), + ('two', 'Jamie Example', 'jamie@example.com'); + INSERT INTO permission_access_request (id, organization_id, requester_id, target_label, status) VALUES + ('a', 'org', 'one', 'Tables', 'pending'), + ('b', 'org', 'two', 'Tables', 'pending'), + ('c', 'org', 'one', 'Tables', 'fulfilled'), + ('d', 'other', 'one', 'Tables', 'pending'), + ('e', 'org', 'two', '100%_complete', 'pending'), + ('f', 'org', 'one', '100percent-complete', 'pending'); + `) + return { + executor: drizzle(client), + async cleanup() { + try { + await client.unsafe(`DROP SCHEMA "${schema}" CASCADE`) + } finally { + await client.end() + } + }, + } +} + +describe.skipIf(!databaseUrl)('organization request search on PostgreSQL', () => { + let fixture: Awaited> + beforeAll(async () => { + fixture = await createFixture() + }) + afterAll(async () => { + await fixture?.cleanup() + }) + + const where = and( + eq(permissionAccessRequest.organizationId, 'org'), + eq(permissionAccessRequest.status, 'pending') + )! + + it('searches before pagination and keeps totals scoped to the organization and status', async () => { + const first = await listAccessRequestRecords(fixture.executor, where, 1, 0, ' TABLES ') + expect(first.requests.map((request) => request.id)).toEqual(['b']) + expect(first.total).toBe(2) + expect(first.hasMore).toBe(true) + const second = await listAccessRequestRecords(fixture.executor, where, 1, 1, 'tables') + expect(second.requests.map((request) => request.id)).toEqual(['a']) + expect(second.total).toBe(2) + expect(second.hasMore).toBe(false) + }) + + it.each(['Alex', 'ALEX@EXAMPLE.COM'])( + 'matches requester name or email with %s without broadening access', + async (search) => { + const result = await listAccessRequestRecords(fixture.executor, where, 25, 0, search) + expect(result.requests.map((request) => request.id)).toEqual(['f', 'a']) + expect(result.total).toBe(2) + } + ) + + it('treats SQL wildcard characters literally and supports clearing search', async () => { + const result = await listAccessRequestRecords(fixture.executor, where, 25, 0, '%_') + expect(result.requests.map((request) => request.id)).toEqual(['e']) + expect(result.total).toBe(1) + const cleared = await listAccessRequestRecords(fixture.executor, where, 25, 0, ' ') + expect(cleared.total).toBe(4) + const missing = await listAccessRequestRecords(fixture.executor, where, 25, 0, 'missing') + expect(missing).toEqual({ requests: [], total: 0, hasMore: false }) + }) +}) diff --git a/apps/sim/lib/permission-access-requests/repository.ts b/apps/sim/lib/permission-access-requests/repository.ts index c43e8986bf5..ce13fc5748a 100644 --- a/apps/sim/lib/permission-access-requests/repository.ts +++ b/apps/sim/lib/permission-access-requests/repository.ts @@ -1,5 +1,6 @@ import { permissionAccessRequest, user } from '@sim/db/schema' -import { and, count, desc, eq, type SQL } from 'drizzle-orm' +import { and, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm' +import { escapeLikePattern } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { storedAccessRequestTargetSchema } from '@/lib/permission-access-requests/schemas' @@ -79,8 +80,21 @@ export async function listAccessRequestRecords( executor: DbOrTx, where: SQL, limit: number, - offset: number + offset: number, + search?: string ): Promise { + const searchTerm = search?.trim() + const pattern = searchTerm ? `%${escapeLikePattern(searchTerm)}%` : undefined + const filteredWhere = and( + where, + pattern + ? or( + ilike(permissionAccessRequest.targetLabel, pattern), + ilike(user.name, pattern), + ilike(user.email, pattern) + ) + : undefined + ) const rows = await executor .select({ row: { @@ -100,14 +114,15 @@ export async function listAccessRequestRecords( }) .from(permissionAccessRequest) .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) - .where(where) + .where(filteredWhere) .orderBy(desc(permissionAccessRequest.createdAt), desc(permissionAccessRequest.id)) .limit(limit) .offset(offset) const [aggregate] = await executor .select({ total: count() }) .from(permissionAccessRequest) - .where(where) + .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) + .where(filteredWhere) const total = aggregate?.total ?? 0 return { requests: rows.map(({ row, requester }) => projectAccessRequest(row, requester)), diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx index 0e9ace53aba..0e08a1bb5f9 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx @@ -4,12 +4,15 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it } from 'vitest' -import { ChipDropdown } from './chip-dropdown' +import { ChipDropdown, type ChipDropdownProps } from './chip-dropdown' let root: Root | null = null let container: HTMLDivElement | null = null -function mount(fullWidth: boolean): HTMLButtonElement { +function mount( + fullWidth: boolean, + aria: Pick = {} +): HTMLButtonElement { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) @@ -21,6 +24,7 @@ function mount(fullWidth: boolean): HTMLButtonElement { options={[{ value: 'workflow', label: 'Workflow' }]} fullWidth={fullWidth} aria-label='Principal type' + {...aria} /> ) ) @@ -53,4 +57,45 @@ describe('ChipDropdown', () => { expect(label?.className).toContain('text-clip') expect(label?.className).not.toContain('truncate') }) + + it.each([true, 'true'] as const)( + 'announces %s field states alongside the supplied error description', + (state) => { + const trigger = mount(true, { + 'aria-required': state, + 'aria-invalid': state, + 'aria-describedby': 'field-error', + }) + const error = document.createElement('p') + error.id = 'field-error' + error.textContent = 'Choose an available principal.' + container?.appendChild(error) + + const description = trigger + .getAttribute('aria-describedby')! + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent) + .join(' ') + + expect(description).toBe('Choose an available principal. Required. Invalid selection.') + expect(trigger.getAttribute('aria-haspopup')).toBe('menu') + expect(trigger.hasAttribute('aria-required')).toBe(false) + expect(trigger.hasAttribute('aria-invalid')).toBe(false) + expect(trigger.textContent).toBe('Workflow') + } + ) + + it.each([false, 'false'] as const)( + 'keeps the original description when field states are %s', + (state) => { + const trigger = mount(true, { + 'aria-required': state, + 'aria-invalid': state, + 'aria-describedby': 'field-hint', + }) + + expect(trigger.getAttribute('aria-describedby')).toBe('field-hint') + expect(container?.textContent).toBe('Workflow') + } + ) }) diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index c4d5af5f49d..65b9a922b68 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -1,10 +1,12 @@ 'use client' import { + type AriaAttributes, type ComponentType, forwardRef, type ReactNode, useContext, + useId, useMemo, useState, } from 'react' @@ -86,6 +88,12 @@ interface ChipDropdownBaseProps extends VariantProps { * selected value is dropped from the accessible name. */ 'aria-labelledby'?: string + /** Required state announced through the menu button's accessible description. */ + 'aria-required'?: AriaAttributes['aria-required'] + /** Validation state announced through the menu button's accessible description. */ + 'aria-invalid'?: AriaAttributes['aria-invalid'] + /** Hint and error descriptions supplied by the enclosing field. */ + 'aria-describedby'?: AriaAttributes['aria-describedby'] /** Id for the trigger button. Needed to reference it from `aria-labelledby`. */ id?: string } @@ -186,9 +194,21 @@ const ChipDropdown = forwardRef( fullWidth, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, + 'aria-required': ariaRequired, + 'aria-invalid': ariaInvalid, + 'aria-describedby': ariaDescribedBy, id, } = props + const fieldStateId = useId() + const fieldState = [ + (ariaRequired === true || ariaRequired === 'true') && 'Required.', + ariaInvalid && ariaInvalid !== 'false' && 'Invalid selection.', + ] + .filter(Boolean) + .join(' ') + const describedBy = [ariaDescribedBy, fieldState && fieldStateId].filter(Boolean).join(' ') + const isMultiple = props.multiple === true const selectedValues = useMemo( () => (isMultiple ? (props.value ?? []) : props.value != null ? [props.value] : []), @@ -325,6 +345,7 @@ const ChipDropdown = forwardRef( disabled={disabled} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} + aria-describedby={describedBy || undefined} className={cn( chipVariants({ variant, shape, active, fullWidth }), hasTriggerBorder && TRIGGER_BORDER_CLASS, @@ -338,11 +359,17 @@ const ChipDropdown = forwardRef( + {fieldState && ( + + {fieldState} + + )} event.preventDefault() : undefined} className={cn( matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none', + insideModal && 'max-h-[min(240px,var(--radix-popper-available-height,240px))]', contentClassName )} >