Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 119 additions & 76 deletions apps/sim/components/access-requests/organization-access-requests.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
'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 {
accessRequestUrlOptions,
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
Expand All @@ -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 (
<div className='flex flex-col gap-5'>
const content = (
<div className='flex flex-col gap-7'>
{settings.isPending ? (
<SettingsEmptyState variant='inline'>Loading request settings...</SettingsEmptyState>
) : settings.isError ? (
Expand Down Expand Up @@ -85,77 +98,83 @@ export function OrganizationAccessRequests({
}
/>
)}
<div className='flex items-center justify-between gap-2'>
<h2 className='text-[var(--text-body)] text-sm'>Requests</h2>
<ChipDropdown
value={params['request-status']}
onChange={(value) =>
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'
/>
</div>
{requests.isPending ? (
<p className='text-[var(--text-muted)] text-sm' role='status'>
Loading requests...
</p>
) : requests.isError ? (
<EmptyState
title='Unable to load requests'
description={requests.error.message}
action={<Chip onClick={() => void requests.refetch()}>Try again</Chip>}
/>
) : (
<div className={RESOURCE_LIST_STACK}>
{requests.data.requests.length === 0 && (
<EmptyState
title='No access requests'
description='Requests from your members will appear here.'
/>
)}
{requests.data.requests.map((request) => (
<SettingsResourceRow
key={request.id}
title={request.targetLabel}
description={`${request.requester.name || request.requester.email} · ${new Date(request.createdAt).toLocaleDateString()}`}
badge={
<ChipTag variant='gray'>{ACCESS_REQUEST_STATUS_LABELS[request.status]}</ChipTag>
}
onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })}
clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`}
navigable
/>
))}
</div>
)}
{(page > 0 || requests.data?.hasMore) && (
<div className='flex items-center justify-between'>
<Chip
disabled={page === 0 || requests.isFetching}
onClick={() => void setParams({ 'request-page': page - 1 })}
>
Previous
</Chip>
<span className='text-[var(--text-muted)] text-sm'>Page {page + 1}</span>
<Chip
disabled={!requests.data?.hasMore || requests.isFetching}
onClick={() => void setParams({ 'request-page': page + 1 })}
>
Next
</Chip>
</div>
)}
<SettingsSection
label='Requests'
action={
<ChipDropdown
value={params['request-status']}
onChange={(value) =>
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 ? (
<SettingsEmptyState variant='inline'>
<span role='status'>Loading requests...</span>
</SettingsEmptyState>
) : requests.isError ? (
<SettingsQueryErrorState
variant='inline'
error={requests.error}
fallback='Unable to load requests'
isRetrying={requests.isFetching}
onRetry={() => void requests.refetch()}
/>
) : (
<div className={RESOURCE_LIST_STACK}>
{requests.data.requests.length === 0 && (
<SettingsEmptyState variant='inline'>
{debouncedSearch
? `No requests found matching "${searchTerm.trim()}"`
: 'No access requests. Requests from your members will appear here.'}
</SettingsEmptyState>
)}
{requests.data.requests.map((request) => (
<SettingsResourceRow
key={request.id}
title={request.targetLabel}
description={`${request.requester.name || request.requester.email} · ${new Date(request.createdAt).toLocaleDateString()}`}
badge={
<ChipTag variant='gray'>{ACCESS_REQUEST_STATUS_LABELS[request.status]}</ChipTag>
}
onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })}
clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`}
navigable
/>
))}
</div>
)}
{!searchPending && (page > 0 || requests.data?.hasMore) && (
<div className='mt-5 flex items-center justify-between'>
<Chip
disabled={page === 0 || requests.isFetching}
onClick={() => void setParams({ 'request-page': page - 1 })}
>
Previous
</Chip>
<span className='text-[var(--text-muted)] text-sm'>Page {page + 1}</span>
<Chip
disabled={!requests.data?.hasMore || requests.isFetching}
onClick={() => void setParams({ 'request-page': page + 1 })}
>
Next
</Chip>
</div>
)}
</SettingsSection>
{params['request-id'] && (
<AccessRequestReview
key={params['request-id']}
Expand All @@ -166,4 +185,28 @@ export function OrganizationAccessRequests({
)}
</div>
)

const search = {
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search requests...',
maxLength: ACCESS_REQUEST_MAX_SEARCH_LENGTH,
}

return standalone ? (
<div className='flex flex-col gap-7'>
<ChipInput
icon={Search}
value={search.value}
onChange={(event) => search.onChange(event.target.value)}
placeholder={search.placeholder}
maxLength={search.maxLength}
aria-label='Search requests'
autoComplete='off'
/>
{content}
</div>
) : (
<SettingsPanel search={search}>{content}</SettingsPanel>
)
}
2 changes: 2 additions & 0 deletions apps/sim/components/access-requests/search-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
2 changes: 2 additions & 0 deletions apps/sim/components/access-requests/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
12 changes: 10 additions & 2 deletions apps/sim/components/settings/settings-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,7 @@ export interface SettingsHeaderSearch {
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
maxLength?: number
}

export interface SettingsBackAction {
Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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'
/>
Expand Down
Loading
Loading