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
)}
>