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'
45import { useQueryStates } from 'nuqs'
56import { AccessRequestReview } from '@/components/access-requests/access-request-review'
67import {
78 accessRequestUrlOptions ,
89 accessReviewSearchParams ,
910} from '@/components/access-requests/search-params'
1011import { 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'
1214import {
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'
1619import {
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'
2024import {
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
2733interface 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}
0 commit comments