Skip to content

Commit ed437fc

Browse files
authored
fix(settings): stabilize group search and modal dropdowns (#7980)
* fix(settings): stabilize group search and modal dropdowns * fix(emcn): describe dropdown validation accessibly
1 parent 4ad9387 commit ed437fc

23 files changed

Lines changed: 692 additions & 153 deletions
Lines changed: 119 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
11
'use client'
22

3-
import { Chip, ChipDropdown, ChipSwitch, ChipTag, toast } from '@sim/emcn'
3+
import { Chip, ChipDropdown, ChipInput, ChipSwitch, ChipTag, toast } from '@sim/emcn'
4+
import { Search } from '@sim/emcn/icons'
45
import { useQueryStates } from 'nuqs'
56
import { AccessRequestReview } from '@/components/access-requests/access-request-review'
67
import {
78
accessRequestUrlOptions,
89
accessReviewSearchParams,
910
} from '@/components/access-requests/search-params'
1011
import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status'
11-
import { EmptyState } from '@/components/empty-state/empty-state'
12+
import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/lib/permission-access-requests/constants'
13+
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
1214
import {
1315
SettingsEmptyState,
1416
SettingsQueryErrorState,
1517
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
18+
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1619
import {
1720
RESOURCE_LIST_STACK,
1821
SettingsResourceRow,
1922
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
23+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
2024
import {
2125
ACCESS_REQUEST_PAGE_SIZE,
2226
useAccessRequestSettings,
2327
useOrganizationAccessRequests,
2428
useUpdateAccessRequestSettings,
2529
} from '@/hooks/queries/access-requests'
30+
import { useDebounce } from '@/hooks/use-debounce'
31+
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
2632

2733
interface OrganizationAccessRequestsProps {
2834
organizationId: string
@@ -37,17 +43,24 @@ export function OrganizationAccessRequests({
3743
...accessRequestUrlOptions,
3844
urlKeys: { 'request-id': standalone ? 'requestId' : 'request-id' },
3945
})
46+
const searchTerm = params['request-search']
47+
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
48+
setParams({ 'request-search': value, 'request-page': 0 }, options)
49+
)
50+
const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS)
51+
const searchPending = searchTerm.trim() !== debouncedSearch
4052
const page = params['request-page']
4153
const requests = useOrganizationAccessRequests(
4254
organizationId,
4355
page * ACCESS_REQUEST_PAGE_SIZE,
44-
params['request-status']
56+
params['request-status'],
57+
debouncedSearch
4558
)
4659
const settings = useAccessRequestSettings(organizationId)
4760
const updateSettings = useUpdateAccessRequestSettings(organizationId)
4861

49-
return (
50-
<div className='flex flex-col gap-5'>
62+
const content = (
63+
<div className='flex flex-col gap-7'>
5164
{settings.isPending ? (
5265
<SettingsEmptyState variant='inline'>Loading request settings...</SettingsEmptyState>
5366
) : settings.isError ? (
@@ -85,77 +98,83 @@ export function OrganizationAccessRequests({
8598
}
8699
/>
87100
)}
88-
<div className='flex items-center justify-between gap-2'>
89-
<h2 className='text-[var(--text-body)] text-sm'>Requests</h2>
90-
<ChipDropdown
91-
value={params['request-status']}
92-
onChange={(value) =>
93-
void setParams({
94-
'request-status': value as (typeof params)['request-status'],
95-
'request-page': 0,
96-
})
97-
}
98-
options={[
99-
{ value: 'pending', label: 'Pending' },
100-
{ value: 'fulfilled', label: ACCESS_REQUEST_STATUS_LABELS.fulfilled },
101-
{ value: 'declined', label: 'Declined' },
102-
{ value: 'cancelled', label: 'Cancelled' },
103-
{ value: 'closed', label: 'Closed' },
104-
{ value: 'all', label: 'All requests' },
105-
]}
106-
aria-label='Filter request status'
107-
/>
108-
</div>
109-
{requests.isPending ? (
110-
<p className='text-[var(--text-muted)] text-sm' role='status'>
111-
Loading requests...
112-
</p>
113-
) : requests.isError ? (
114-
<EmptyState
115-
title='Unable to load requests'
116-
description={requests.error.message}
117-
action={<Chip onClick={() => void requests.refetch()}>Try again</Chip>}
118-
/>
119-
) : (
120-
<div className={RESOURCE_LIST_STACK}>
121-
{requests.data.requests.length === 0 && (
122-
<EmptyState
123-
title='No access requests'
124-
description='Requests from your members will appear here.'
125-
/>
126-
)}
127-
{requests.data.requests.map((request) => (
128-
<SettingsResourceRow
129-
key={request.id}
130-
title={request.targetLabel}
131-
description={`${request.requester.name || request.requester.email} · ${new Date(request.createdAt).toLocaleDateString()}`}
132-
badge={
133-
<ChipTag variant='gray'>{ACCESS_REQUEST_STATUS_LABELS[request.status]}</ChipTag>
134-
}
135-
onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })}
136-
clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`}
137-
navigable
138-
/>
139-
))}
140-
</div>
141-
)}
142-
{(page > 0 || requests.data?.hasMore) && (
143-
<div className='flex items-center justify-between'>
144-
<Chip
145-
disabled={page === 0 || requests.isFetching}
146-
onClick={() => void setParams({ 'request-page': page - 1 })}
147-
>
148-
Previous
149-
</Chip>
150-
<span className='text-[var(--text-muted)] text-sm'>Page {page + 1}</span>
151-
<Chip
152-
disabled={!requests.data?.hasMore || requests.isFetching}
153-
onClick={() => void setParams({ 'request-page': page + 1 })}
154-
>
155-
Next
156-
</Chip>
157-
</div>
158-
)}
101+
<SettingsSection
102+
label='Requests'
103+
action={
104+
<ChipDropdown
105+
value={params['request-status']}
106+
onChange={(value) =>
107+
void setParams({
108+
'request-status': value as (typeof params)['request-status'],
109+
'request-page': 0,
110+
})
111+
}
112+
options={[
113+
{ value: 'pending', label: 'Pending' },
114+
{ value: 'fulfilled', label: ACCESS_REQUEST_STATUS_LABELS.fulfilled },
115+
{ value: 'declined', label: 'Declined' },
116+
{ value: 'cancelled', label: 'Cancelled' },
117+
{ value: 'closed', label: 'Closed' },
118+
{ value: 'all', label: 'All requests' },
119+
]}
120+
aria-label='Filter request status'
121+
/>
122+
}
123+
>
124+
{searchPending || requests.isPending ? (
125+
<SettingsEmptyState variant='inline'>
126+
<span role='status'>Loading requests...</span>
127+
</SettingsEmptyState>
128+
) : requests.isError ? (
129+
<SettingsQueryErrorState
130+
variant='inline'
131+
error={requests.error}
132+
fallback='Unable to load requests'
133+
isRetrying={requests.isFetching}
134+
onRetry={() => void requests.refetch()}
135+
/>
136+
) : (
137+
<div className={RESOURCE_LIST_STACK}>
138+
{requests.data.requests.length === 0 && (
139+
<SettingsEmptyState variant='inline'>
140+
{debouncedSearch
141+
? `No requests found matching "${searchTerm.trim()}"`
142+
: 'No access requests. Requests from your members will appear here.'}
143+
</SettingsEmptyState>
144+
)}
145+
{requests.data.requests.map((request) => (
146+
<SettingsResourceRow
147+
key={request.id}
148+
title={request.targetLabel}
149+
description={`${request.requester.name || request.requester.email} · ${new Date(request.createdAt).toLocaleDateString()}`}
150+
badge={
151+
<ChipTag variant='gray'>{ACCESS_REQUEST_STATUS_LABELS[request.status]}</ChipTag>
152+
}
153+
onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })}
154+
clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`}
155+
navigable
156+
/>
157+
))}
158+
</div>
159+
)}
160+
{!searchPending && (page > 0 || requests.data?.hasMore) && (
161+
<div className='mt-5 flex items-center justify-between'>
162+
<Chip
163+
disabled={page === 0 || requests.isFetching}
164+
onClick={() => void setParams({ 'request-page': page - 1 })}
165+
>
166+
Previous
167+
</Chip>
168+
<span className='text-[var(--text-muted)] text-sm'>Page {page + 1}</span>
169+
<Chip
170+
disabled={!requests.data?.hasMore || requests.isFetching}
171+
onClick={() => void setParams({ 'request-page': page + 1 })}
172+
>
173+
Next
174+
</Chip>
175+
</div>
176+
)}
177+
</SettingsSection>
159178
{params['request-id'] && (
160179
<AccessRequestReview
161180
key={params['request-id']}
@@ -166,4 +185,28 @@ export function OrganizationAccessRequests({
166185
)}
167186
</div>
168187
)
188+
189+
const search = {
190+
value: searchTerm,
191+
onChange: setSearchTerm,
192+
placeholder: 'Search requests...',
193+
maxLength: ACCESS_REQUEST_MAX_SEARCH_LENGTH,
194+
}
195+
196+
return standalone ? (
197+
<div className='flex flex-col gap-7'>
198+
<ChipInput
199+
icon={Search}
200+
value={search.value}
201+
onChange={(event) => search.onChange(event.target.value)}
202+
placeholder={search.placeholder}
203+
maxLength={search.maxLength}
204+
aria-label='Search requests'
205+
autoComplete='off'
206+
/>
207+
{content}
208+
</div>
209+
) : (
210+
<SettingsPanel search={search}>{content}</SettingsPanel>
211+
)
169212
}

‎apps/sim/components/access-requests/search-params.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,7 @@ describe('access request URL bounds', () => {
2020
expect(accessRequestSearchParams.requestId.parse('request-1')).toBe('request-1')
2121
expect(accessRequestSearchParams.requestId.parse('x'.repeat(129))).toBeNull()
2222
expect(accessRequestSearchParams.search.parse('x'.repeat(201))).toBeNull()
23+
expect(accessReviewSearchParams['request-search'].parse('Tables')).toBe('Tables')
24+
expect(accessReviewSearchParams['request-search'].parse('x'.repeat(201))).toBeNull()
2325
})
2426
})

‎apps/sim/components/access-requests/search-params.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const accessRequestSearchParams = {
3939
export const accessReviewSearchParams = {
4040
'access-view': parseAsStringLiteral(['groups', 'requests'] as const).withDefault('groups'),
4141
'request-id': accessRequestIdParser,
42+
'request-search': accessRequestSearchParser,
4243
'request-page': accessRequestPageParser,
4344
'request-status': parseAsStringLiteral([
4445
'pending',
@@ -57,5 +58,6 @@ export const accessRequestEntrySearchParams = {
5758
organizationId: accessRequestIdParser,
5859
view: parseAsStringLiteral(['requests', 'catalog', 'admin'] as const).withDefault('requests'),
5960
'request-page': accessReviewSearchParams['request-page'],
61+
'request-search': accessReviewSearchParams['request-search'],
6062
'request-status': accessReviewSearchParams['request-status'],
6163
} as const

‎apps/sim/components/settings/settings-header.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
useRef,
1414
useState,
1515
} from 'react'
16-
import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn'
16+
import { Chip, ChipInput, ChipLink, cn, Tooltip } from '@sim/emcn'
17+
import { Search } from '@sim/emcn/icons'
1718
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
1819

1920
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
@@ -48,6 +49,7 @@ export interface SettingsHeaderSearch {
4849
onChange: (value: string) => void
4950
placeholder?: string
5051
disabled?: boolean
52+
maxLength?: number
5153
}
5254

5355
export interface SettingsBackAction {
@@ -110,7 +112,12 @@ function computeSignature(config: SettingsHeaderConfig | null): string {
110112
action.onPrefetch ? 1 : 0,
111113
]),
112114
search: config.search
113-
? [config.search.value, config.search.placeholder ?? '', config.search.disabled ?? false]
115+
? [
116+
config.search.value,
117+
config.search.placeholder ?? '',
118+
config.search.disabled ?? false,
119+
config.search.maxLength ?? null,
120+
]
114121
: null,
115122
})
116123
}
@@ -333,6 +340,7 @@ export function SettingsHeaderShell({ meta, children }: SettingsHeaderShellProps
333340
value={search.value}
334341
onChange={(event) => configRef?.current?.search?.onChange(event.target.value)}
335342
disabled={search.disabled}
343+
maxLength={search.maxLength}
336344
autoComplete='off'
337345
className='w-full'
338346
/>

0 commit comments

Comments
 (0)