feat(ui-patterns): add async selection feedback

This commit is contained in:
Danny White committed 2026-09-01 20:28:29 +10:00
1 parent 6e39b17d3c
commit 68c1e8ba59
13 files changed
+556 -296

No files matched your search

@@ -1,5 +1,5 @@
import { useParams } from 'common'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import {
@@ -13,11 +13,11 @@ import {
SelectItem,
SelectSeparator,
SelectTrigger,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { SelectionListState } from 'ui-patterns/SelectionListState'
import {
CREATE_NEW_KEY,
@@ -25,6 +25,11 @@ import {
STORED_SECRET_PLACEHOLDER,
} from '../DestinationForm.constants'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
import {
isMetadataListErrorVisible,
isMetadataListLoading,
useRefreshOnOpen,
} from '../useRefreshOnOpen'
import { InlineLink } from '@/components/ui/InlineLink'
import { useAnalyticsBucketsQuery } from '@/data/storage/analytics-buckets-query'
import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query'
@@ -52,6 +57,19 @@ const getS3AccessKeyTriggerLabel = ({
return value
}
const getNamespaceTriggerLabel = ({
canSelectNamespace,
value,
}: {
canSelectNamespace: boolean
value: string | undefined
}) => {
if (!canSelectNamespace) return 'Select a bucket first'
if (value === CREATE_NEW_NAMESPACE) return 'Create a new namespace'
return value || 'Select a namespace'
}
export const AnalyticsBucketFields = ({
form,
editMode,
@@ -73,10 +91,14 @@ export const AnalyticsBucketFields = ({
const {
data: keysData,
isSuccess: isSuccessKeys,
isPending: isLoadingKeys,
isPending: isPendingKeys,
isFetching: isFetchingKeys,
isError: isErrorKeys,
refetch: refetchKeys,
} = useStorageCredentialsQuery({ projectRef })
const s3Keys = keysData?.data ?? []
const isLoadingKeys = isMetadataListLoading(isPendingKeys || isFetchingKeys, s3Keys.length)
const isKeysErrorVisible = isMetadataListErrorVisible(isErrorKeys, s3Keys.length)
const keyNoLongerExists =
(s3AccessKeyId ?? '').length > 0 &&
s3AccessKeyId !== CREATE_NEW_KEY &&
@@ -84,20 +106,44 @@ export const AnalyticsBucketFields = ({
const {
data: analyticsBuckets = [],
isPending: isLoadingBuckets,
isPending: isPendingBuckets,
isFetching: isFetchingBuckets,
isError: isErrorBuckets,
refetch: refetchBuckets,
} = useAnalyticsBucketsQuery({ projectRef })
const isLoadingBuckets = isMetadataListLoading(
isPendingBuckets || isFetchingBuckets,
analyticsBuckets.length
)
const isBucketsErrorVisible = isMetadataListErrorVisible(isErrorBuckets, analyticsBuckets.length)
const canSelectNamespace = !!warehouseName
const {
data: namespaces = [],
isPending: isLoadingNamespaces,
isPending: isPendingNamespaces,
isFetching: isFetchingNamespaces,
isError: isErrorNamespaces,
refetch: refetchNamespaces,
} = useIcebergNamespacesQuery(
{ projectRef, warehouse: warehouseName },
{ enabled: !!warehouseName }
)
const isLoadingNamespaces = isMetadataListLoading(
isPendingNamespaces || isFetchingNamespaces,
namespaces.length
)
const isNamespacesErrorVisible = isMetadataListErrorVisible(isErrorNamespaces, namespaces.length)
const { handleOpenChange: handleRefreshBucketsOnOpen } = useRefreshOnOpen({
refetch: refetchBuckets,
})
const { handleOpenChange: handleRefreshNamespacesOnOpen } = useRefreshOnOpen({
isEnabled: canSelectNamespace,
refetch: refetchNamespaces,
})
const { handleOpenChange: handleRefreshKeysOnOpen } = useRefreshOnOpen({
refetch: refetchKeys,
})
return (
<div className="flex flex-col gap-y-6 p-5">
@@ -113,61 +159,45 @@ export const AnalyticsBucketFields = ({
layout="horizontal"
description="The Analytics Bucket where data will be stored"
>
{isLoadingBuckets ? (
<Button
disabled
variant="default"
className="w-full justify-between"
size="small"
iconRight={<Loader2 className="animate-spin" />}
<FormControl>
<Select
value={field.value}
onOpenChange={handleRefreshBucketsOnOpen}
onValueChange={(value) => {
if (value === 'new-bucket') {
onSelectNewBucket()
} else {
field.onChange(value)
// [Joshen] Ideally should select the first namespace of the selected bucket
form.setValue('namespace', '')
}
}}
>
Retrieving buckets
</Button>
) : isErrorBuckets ? (
<Button
disabled
variant="default"
className="w-full justify-start"
size="small"
icon={<WarningIcon />}
>
Failed to retrieve buckets
</Button>
) : (
<FormControl>
<Select
value={field.value}
onValueChange={(value) => {
if (value === 'new-bucket') {
onSelectNewBucket()
} else {
field.onChange(value)
// [Joshen] Ideally should select the first namespace of the selected bucket
form.setValue('namespace', '')
}
}}
>
<SelectTrigger>{field.value || 'Select a bucket'}</SelectTrigger>
<SelectContent>
<SelectGroup>
{analyticsBuckets.length === 0 ? (
<SelectItem value="__no_buckets__" disabled>
No buckets available
</SelectItem>
) : (
analyticsBuckets.map((bucket) => (
<SelectItem key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectItem>
))
)}
<SelectSeparator />
<SelectItem value="new-bucket">Create a new bucket</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
)}
<SelectTrigger>{field.value || 'Select a bucket'}</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectionListState
isLoading={isLoadingBuckets}
isError={isBucketsErrorVisible}
isEmpty={
!isLoadingBuckets &&
!isBucketsErrorVisible &&
analyticsBuckets.length === 0
}
emptyLabel="No buckets available"
errorLabel="Unable to load buckets"
/>
{analyticsBuckets.map((bucket) => (
<SelectItem key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value="new-bucket">Create a new bucket</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
@@ -181,62 +211,42 @@ export const AnalyticsBucketFields = ({
layout="horizontal"
description="The namespace within the bucket where tables will be organized"
>
{isLoadingNamespaces && canSelectNamespace ? (
<Button
disabled
variant="default"
className="w-full justify-between"
size="small"
iconRight={<Loader2 className="animate-spin" />}
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={!canSelectNamespace}
onOpenChange={handleRefreshNamespacesOnOpen}
>
Retrieving namespaces
</Button>
) : isErrorNamespaces ? (
<Button
disabled
variant="default"
className="w-full justify-start"
size="small"
icon={<WarningIcon />}
>
Failed to retrieve namespaces
</Button>
) : (
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={!canSelectNamespace}
>
<SelectTrigger>
{!canSelectNamespace
? 'Select a warehouse first'
: field.value === CREATE_NEW_NAMESPACE
? 'Create a new namespace'
: field.value || 'Select a namespace'}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{namespaces.length === 0 ? (
<SelectItem value="__no_namespaces__" disabled>
No namespaces available
</SelectItem>
) : (
namespaces.map((namespace) => (
<SelectItem key={namespace} value={namespace}>
{namespace}
</SelectItem>
))
)}
<SelectSeparator />
<SelectItem key={CREATE_NEW_NAMESPACE} value={CREATE_NEW_NAMESPACE}>
Create a new namespace
<SelectTrigger>
{getNamespaceTriggerLabel({ canSelectNamespace, value: field.value })}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectionListState
isLoading={isLoadingNamespaces}
isError={isNamespacesErrorVisible}
isEmpty={
!isLoadingNamespaces &&
!isNamespacesErrorVisible &&
namespaces.length === 0
}
emptyLabel="No namespaces available"
errorLabel="Unable to load namespaces"
/>
{namespaces.map((namespace) => (
<SelectItem key={namespace} value={namespace}>
{namespace}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
)}
))}
<SelectSeparator />
<SelectItem key={CREATE_NEW_NAMESPACE} value={CREATE_NEW_NAMESPACE}>
Create a new namespace
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
@@ -341,49 +351,38 @@ export const AnalyticsBucketFields = ({
</div>
}
>
{isLoadingKeys ? (
<Button
disabled
variant="default"
className="w-full justify-between"
size="small"
iconRight={<Loader2 className="animate-spin" />}
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
onOpenChange={handleRefreshKeysOnOpen}
>
Retrieving keys
</Button>
) : isErrorKeys ? (
<Button
disabled
variant="default"
className="w-full justify-start"
size="small"
icon={<WarningIcon />}
>
Failed to retrieve keys
</Button>
) : (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
{getS3AccessKeyTriggerLabel({ value: field.value, editMode })}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{s3Keys.map((key) => (
<SelectItem key={key.id} value={key.access_key}>
{key.access_key}
<p className="text-foreground-lighter">{key.description}</p>
</SelectItem>
))}
<SelectSeparator />
<SelectItem key={CREATE_NEW_KEY} value={CREATE_NEW_KEY}>
Create a new key
<SelectTrigger>
{getS3AccessKeyTriggerLabel({ value: field.value, editMode })}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectionListState
isLoading={isLoadingKeys}
isError={isKeysErrorVisible}
isEmpty={!isLoadingKeys && !isKeysErrorVisible && s3Keys.length === 0}
emptyLabel="No access keys available"
errorLabel="Unable to load access keys"
/>
{s3Keys.map((key) => (
<SelectItem key={key.id} value={key.access_key}>
{key.access_key}
<p className="text-foreground-lighter">{key.description}</p>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
)}
))}
<SelectSeparator />
<SelectItem key={CREATE_NEW_KEY} value={CREATE_NEW_KEY}>
Create a new key
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
@@ -1,4 +1,4 @@
import { Check, Database, Eye, EyeOff, Loader2, Plus, SlidersHorizontal } from 'lucide-react'
import { Check, Database, Eye, EyeOff, Plus, SlidersHorizontal } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useWatch, type UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
@@ -20,14 +20,19 @@ import {
SelectGroup,
SelectItem,
SelectTrigger,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { SelectionListState } from 'ui-patterns/SelectionListState'
import { DEFAULT_DUCKLAKE_POOL_SIZE, STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
import {
isMetadataListErrorVisible,
isMetadataListLoading,
useRefreshOnOpen,
} from '../useRefreshOnOpen'
import {
DUCKLAKE_MODE_CUSTOM,
DUCKLAKE_MODE_SUPABASE,
@@ -690,8 +695,10 @@ const ProjectSelection = ({
const {
data: projectsData,
isPending: isLoadingProjects,
isPending: isPendingProjects,
isFetching: isFetchingProjects,
isError: isErrorProjects,
refetch: refetchProjects,
} = useOrgProjectsInfiniteQuery(
{ slug: organization?.slug, statuses: [PROJECT_STATUS.ACTIVE_HEALTHY] },
{ enabled: !!organization?.slug }
@@ -704,6 +711,7 @@ const ProjectSelection = ({
),
[projectsData]
)
const isProjectsErrorVisible = isMetadataListErrorVisible(isErrorProjects, projects.length)
const projectsByRef = useMemo(
() => new Map(projects.map((project) => [project.ref, project])),
@@ -715,54 +723,40 @@ const ProjectSelection = ({
const project = projectsByRef.get(ref)
return project ? `${project.name} · ${project.ref}` : ref
}
const { handleOpenChange: handleRefreshProjectsOnOpen } = useRefreshOnOpen({
isEnabled: !!organization?.slug,
refetch: refetchProjects,
})
if (isLoadingProjects) {
return (
<Button
disabled
variant="default"
className="w-full justify-between"
size="small"
iconRight={<Loader2 className="animate-spin" />}
>
Retrieving projects
</Button>
)
}
if (isErrorProjects) {
return (
<Button
disabled
variant="default"
className="w-full justify-start"
size="small"
icon={<WarningIcon />}
>
Failed to retrieve projects
</Button>
)
}
return (
<Select value={value || ''} onValueChange={onChange}>
<Select value={value || ''} onValueChange={onChange} onOpenChange={handleRefreshProjectsOnOpen}>
<SelectTrigger>{projectLabel(value) ?? placeholder}</SelectTrigger>
<SelectContent>
<SelectGroup>
{projects.length === 0 ? (
<SelectItem value="__no_projects__" disabled>
No active projects available
<SelectionListState
isLoading={isMetadataListLoading(
isPendingProjects || isFetchingProjects,
projects.length
)}
isError={isProjectsErrorVisible}
isEmpty={
!isMetadataListLoading(isPendingProjects || isFetchingProjects, projects.length) &&
!isProjectsErrorVisible &&
projects.length === 0
}
emptyLabel="No active projects available"
errorLabel="Unable to load projects"
/>
{projects.map((project) => (
<SelectItem key={project.ref} value={project.ref}>
<div className="flex flex-col">
<span>{project.name}</span>
<span className="text-foreground-lighter">
{project.ref} · {project.region}
</span>
</div>
</SelectItem>
) : (
projects.map((project) => (
<SelectItem key={project.ref} value={project.ref}>
<div className="flex flex-col">
<span>{project.name}</span>
<span className="text-foreground-lighter">
{project.ref} · {project.region}
</span>
</div>
</SelectItem>
))
)}
))}
</SelectGroup>
</SelectContent>
</Select>
@@ -785,8 +779,10 @@ const BucketSelection = ({
const {
data: bucketsData,
isPending: isLoadingBuckets,
isPending: isPendingBuckets,
isFetching: isFetchingBuckets,
isError: isErrorBuckets,
refetch: refetchBuckets,
} = usePaginatedBucketsQuery(
{ projectRef: ducklakeStorageProjectRef },
{ enabled: !!ducklakeStorageProjectRef }
@@ -799,44 +795,24 @@ const BucketSelection = ({
),
[bucketsData]
)
const isBucketsErrorVisible = isMetadataListErrorVisible(isErrorBuckets, buckets.length)
const { handleOpenChange: handleRefreshBucketsOnOpen } = useRefreshOnOpen({
isEnabled: !!ducklakeStorageProjectRef,
refetch: refetchBuckets,
})
if (!ducklakeStorageProjectRef) {
return (
<Button disabled variant="default" className="w-full justify-start" size="small">
Select a storage project first
</Button>
)
}
if (isLoadingBuckets) {
return (
<Button
disabled
variant="default"
className="w-full justify-between"
size="small"
iconRight={<Loader2 className="animate-spin" />}
>
Retrieving buckets
</Button>
)
}
if (isErrorBuckets) {
return (
<Button
disabled
variant="default"
className="w-full justify-start"
size="small"
icon={<WarningIcon />}
>
Failed to retrieve buckets
</Button>
<Select disabled>
<SelectTrigger>Select a storage project first</SelectTrigger>
</Select>
)
}
return (
<Select
value={value || ''}
onOpenChange={handleRefreshBucketsOnOpen}
onValueChange={(e) => {
if (e) onChange(e)
}}
@@ -844,17 +820,22 @@ const BucketSelection = ({
<SelectTrigger>{value || 'Select a bucket'}</SelectTrigger>
<SelectContent>
<SelectGroup>
{buckets.length === 0 ? (
<SelectItem value="__no_buckets__" disabled>
No buckets available
<SelectionListState
isLoading={isMetadataListLoading(isPendingBuckets || isFetchingBuckets, buckets.length)}
isError={isBucketsErrorVisible}
isEmpty={
!isMetadataListLoading(isPendingBuckets || isFetchingBuckets, buckets.length) &&
!isBucketsErrorVisible &&
buckets.length === 0
}
emptyLabel="No buckets available"
errorLabel="Unable to load buckets"
/>
{buckets.map((bucket) => (
<SelectItem key={bucket.id} value={bucket.id}>
{bucket.name}
</SelectItem>
) : (
buckets.map((bucket) => (
<SelectItem key={bucket.id} value={bucket.id}>
{bucket.name}
</SelectItem>
))
)}
))}
</SelectGroup>
</SelectContent>
</Select>
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
isMetadataListErrorVisible,
isMetadataListLoading,
isMetadataValueLoading,
} from './useRefreshOnOpen'
describe('replication metadata loading UI', () => {
it('shows a list skeleton only while pending or fetching an empty list', () => {
expect(isMetadataListLoading(true, 0)).toBe(true)
expect(isMetadataListLoading(true, 3)).toBe(false)
expect(isMetadataListLoading(false, 0)).toBe(false)
})
it('shows a list error only when the request failed and the list is empty', () => {
expect(isMetadataListErrorVisible(true, 0)).toBe(true)
expect(isMetadataListErrorVisible(true, 3)).toBe(false)
expect(isMetadataListErrorVisible(false, 0)).toBe(false)
})
it('shows a record skeleton only while fetching a missing value', () => {
expect(isMetadataValueLoading(true, undefined)).toBe(true)
expect(isMetadataValueLoading(true, { name: 'analytics' })).toBe(false)
expect(isMetadataValueLoading(false, undefined)).toBe(false)
})
})
@@ -0,0 +1,32 @@
// Replication metadata (publication names, publication tables, source tables,
// columns) and similar destination-form option lists follow one rule:
//
// - Opening a picker always refetches.
// - Loading and error UI only appear when there is nothing to show, so a
// background refresh never replaces existing options with a skeleton.
//
// Pass `isPending || isFetching` into the loading helpers. `isPending` covers
// "query not started yet" (including while a parent id is still loading);
// `isFetching` covers an in-flight request. A populated list stays visible.
export const isMetadataListLoading = (isPendingOrFetching: boolean, itemCount: number) =>
isPendingOrFetching && itemCount === 0
export const isMetadataListErrorVisible = (isError: boolean, itemCount: number) =>
isError && itemCount === 0
export const isMetadataValueLoading = (isPendingOrFetching: boolean, value: unknown) =>
isPendingOrFetching && value == null
interface UseRefreshOnOpenProps {
isEnabled?: boolean
refetch: () => unknown
}
export const useRefreshOnOpen = ({ isEnabled = true, refetch }: UseRefreshOnOpenProps) => {
const handleOpenChange = (isOpen: boolean) => {
if (isOpen && isEnabled) void refetch()
}
return { handleOpenChange }
}
@@ -72,7 +72,13 @@ const mockUpdateCustomerProfile = () => {
}
const addRecipient = async (email: string) => {
const input = screen.getByPlaceholderText('Add additional recipients')
const input = screen
.getAllByRole('combobox')
.find((element): element is HTMLInputElement => element instanceof HTMLInputElement)
expect(input).toBeDefined()
if (!input) return
await userEvent.click(input)
await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'true'))
// fireEvent.change (rather than userEvent.type) avoids racing the popover's open-state
+8
View File
@@ -582,6 +582,14 @@
"import": "./src/Row/index.tsx",
"types": "./src/Row/index.tsx"
},
"./SelectionListState/SelectionListState": {
"import": "./src/SelectionListState/SelectionListState.tsx",
"types": "./src/SelectionListState/SelectionListState.tsx"
},
"./SelectionListState": {
"import": "./src/SelectionListState/index.ts",
"types": "./src/SelectionListState/index.ts"
},
"./ShimmeringLoader/index.css": {
"import": "./src/ShimmeringLoader/index.css",
"types": "./src/ShimmeringLoader/index.css"
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { SelectionListState } from './SelectionListState'
describe('SelectionListState', () => {
it('keeps one persistent live region while its state changes', () => {
const { container, rerender } = render(<SelectionListState isLoading />)
const liveRegion = container.querySelector('[aria-live="polite"]')
expect(liveRegion).not.toBeNull()
if (!liveRegion) throw new Error('Expected an aria-live status region')
expect(liveRegion).toHaveAttribute('aria-live', 'polite')
expect(liveRegion).toHaveClass('sr-only')
expect(liveRegion).toHaveTextContent('Loading options')
rerender(<SelectionListState isEmpty emptyLabel="No columns found" />)
expect(screen.getByText('No columns found')).toBe(liveRegion)
expect(liveRegion).not.toHaveClass('sr-only')
})
})
@@ -0,0 +1,49 @@
import { cn } from 'ui'
import { GenericSelectionSkeletonLoader } from '../ShimmeringLoader'
interface SelectionListStateProps {
className?: string
emptyLabel?: string
errorLabel?: string
isEmpty?: boolean
isError?: boolean
isLoading?: boolean
skeletonVariant?: 'command' | 'multi-select' | 'select'
}
export const SelectionListState = ({
className,
emptyLabel = 'No options available',
errorLabel = 'Unable to load options',
isEmpty = false,
isError = false,
isLoading = false,
skeletonVariant = 'select',
}: SelectionListStateProps) => {
let statusLabel: string | undefined
if (isLoading) statusLabel = 'Loading options'
else if (isError) statusLabel = errorLabel
else if (isEmpty) statusLabel = emptyLabel
return (
<>
{isLoading && (
<GenericSelectionSkeletonLoader
className={cn('w-full', className)}
variant={skeletonVariant}
/>
)}
<div
aria-live="polite"
className={cn(
'px-2 py-3 text-xs text-foreground-lighter',
(isLoading || statusLabel === undefined) && 'sr-only',
className
)}
>
{statusLabel}
</div>
</>
)
}
@@ -0,0 +1 @@
export * from './SelectionListState'
@@ -29,3 +29,10 @@
background-position: 1000px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shimmering-loader,
.dark .shimmering-loader {
animation: none;
}
}
@@ -37,6 +37,42 @@ export const GenericSkeletonLoader = ({ className }: GenericSkeletonLoaderProps)
</div>
)
interface GenericSelectionSkeletonLoaderProps extends GenericSkeletonLoaderProps {
// Selection primitives have different row indicators: command lists have none, Select uses
// radio circles, and MultiSelector uses checkboxes.
variant?: 'command' | 'multi-select' | 'select'
}
export const GenericSelectionSkeletonLoader = ({
className,
variant = 'multi-select',
}: GenericSelectionSkeletonLoaderProps) => {
const hasIndicator = variant !== 'command'
const isSelect = variant === 'select'
return (
<div className={cn(className, 'flex flex-col')} aria-hidden="true">
{['w-2/3', 'w-1/2', 'w-3/4'].map((width, index) => (
<div
key={width}
className={cn('flex items-center px-2', isSelect ? 'h-8 gap-2.5' : 'h-7 gap-2')}
>
{hasIndicator && (
<ShimmeringLoader
className={cn(
'shrink-0 py-0',
isSelect ? 'h-3.5 w-3.5 rounded-full' : 'h-4 w-4 rounded-sm'
)}
delayIndex={index}
/>
)}
<ShimmeringLoader className={cn('h-3 py-0', width)} delayIndex={index} />
</div>
))}
</div>
)
}
export const GenericTableLoader = ({
headers = [],
numRows = 3,
@@ -61,11 +61,13 @@ describe('multi-select', () => {
it('renders selected values with a custom label', () => {
render(
<MultiSelector values={['101']} onValuesChange={() => undefined}>
<MultiSelectorTrigger renderValue={(value) => `public.table_${value}`} />
<MultiSelectorTrigger renderValue={(value) => `Public.MixedCase_${value}`} />
</MultiSelector>
)
expect(screen.getByRole('combobox')).toHaveTextContent('public.table_101')
const badge = screen.getByText('Public.MixedCase_101').closest('[class*=rounded]')
expect(screen.getByRole('combobox')).toHaveTextContent('Public.MixedCase_101')
expect(badge).toHaveClass('normal-case', 'tracking-normal')
})
it('opens the dropdown when the MultiSelectorTrigger is clicked', () => {
@@ -78,6 +80,45 @@ describe('multi-select', () => {
expect(screen.getByText('Apple')).toBeInTheDocument() // Apple should be visible in the dropdown
})
it('shows loading rows only inside the open dropdown', () => {
render(
<MultiSelector values={[]} onValuesChange={() => undefined}>
<MultiSelectorTrigger label="Select fruits" />
<MultiSelectorContent>
<MultiSelectorList loading>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
expect(document.querySelector('.shimmering-loader')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox'))
expect(document.querySelector('.shimmering-loader')).toBeInTheDocument()
expect(screen.queryByText('Apple')).not.toBeInTheDocument()
})
it('shows a custom loading error instead of an empty result', () => {
render(
<MultiSelector values={[]} onValuesChange={() => undefined}>
<MultiSelectorTrigger label="Select fruits" />
<MultiSelectorContent>
<MultiSelectorList error errorLabel="Unable to load fruits">
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByText('Unable to load fruits')).toBeInTheDocument()
expect(screen.queryByText('Apple')).not.toBeInTheDocument()
expect(screen.queryByText('No results found')).not.toBeInTheDocument()
})
it('adds and removes value when toggling MultiSelectorItem', () => {
render(<MultiSelectDemo />)
@@ -20,13 +20,15 @@ import {
} from 'ui'
import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from 'ui/src/lib/constants'
import { SelectionListState } from '../SelectionListState'
interface MultiSelectContextProps {
id: string
values: string[]
onValuesChange: (value: string[]) => void
toggleValue: (values: string) => void
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
setOpen: (open: boolean) => void
inputValue: string
setInputValue: React.Dispatch<React.SetStateAction<string>>
activeIndex: number
@@ -40,6 +42,7 @@ const MultiSelectContext = React.createContext<MultiSelectContextProps | null>(n
const DROPDOWN_MAX_HEIGHT = 300
const DROPDOWN_GAP = 8
const DROPDOWN_BORDER_HEIGHT = 2
const commandItemClass = cn(
'relative text-foreground-light text-left px-2 py-1.5 rounded-xs',
@@ -72,6 +75,7 @@ type MultiSelectorProps = {
mode?: MultiSelectorMode
values: string[]
onValuesChange: (value: string[]) => void
onOpenChange?: (open: boolean) => void
disabled?: boolean
} & React.ComponentPropsWithoutRef<typeof Command> &
VariantProps<typeof MultiSelectorVariants>
@@ -79,6 +83,7 @@ type MultiSelectorProps = {
function MultiSelector({
values = [],
onValuesChange,
onOpenChange,
disabled,
dir,
size,
@@ -88,13 +93,24 @@ function MultiSelector({
...props
}: MultiSelectorProps) {
const ref = React.useRef(null)
const [open, setOpen] = React.useState<boolean>(false)
const [open, setOpenState] = React.useState<boolean>(false)
const [inputValue, setInputValue] = React.useState<string>('')
const [activeIndex, setActiveIndex] = React.useState<number>(-1)
const [dropdownMaxHeight, setDropdownMaxHeight] = React.useState<number>(DROPDOWN_MAX_HEIGHT)
const openRef = React.useRef(false)
const generatedId = React.useId()
const id = idProp ?? generatedId
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (openRef.current === nextOpen) return
openRef.current = nextOpen
setOpenState(nextOpen)
onOpenChange?.(nextOpen)
},
[onOpenChange]
)
const toggleValue = React.useCallback(
(toggledValue: string) => {
if (values.includes(toggledValue)) {
@@ -103,7 +119,7 @@ function MultiSelector({
onValuesChange([...values, toggledValue])
}
},
[values]
[onValuesChange, values]
)
useEffect(() => {
@@ -153,18 +169,18 @@ function MultiSelector({
}
break
case 'Escape':
activeIndex !== -1 ? setActiveIndex(-1) : setOpen(false)
activeIndex !== -1 ? setActiveIndex(-1) : handleOpenChange(false)
if (ref.current) {
const button = (ref.current as HTMLDivElement).querySelector('button[role="combobox"]')
button && (button as HTMLButtonElement).focus()
}
break
case 'Enter':
setOpen(true)
handleOpenChange(true)
break
}
},
[values, inputValue, activeIndex]
[values, inputValue, activeIndex, handleOpenChange]
)
return (
@@ -175,7 +191,7 @@ function MultiSelector({
toggleValue,
onValuesChange,
open,
setOpen,
setOpen: handleOpenChange,
inputValue,
setInputValue,
activeIndex,
@@ -185,7 +201,7 @@ function MultiSelector({
dropdownMaxHeight,
}}
>
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<Command
id={id}
ref={ref}
@@ -273,7 +289,8 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
}
}, [values, badgeLimit])
const badgeClasses = 'rounded-sm shrink-0 px-1.5 bg-surface-75 dark:bg-white/5'
const badgeClasses =
'rounded-sm shrink-0 px-1.5 bg-surface-75 dark:bg-white/5 normal-case tracking-normal text-xs'
const handleTriggerClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(
(event) => {
@@ -373,7 +390,7 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
ref={inlineInputRef}
showSearchIcon={false}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
placeholder={label}
placeholder={values.length === 0 ? label : undefined}
autoFocus={false}
wrapperClassName={cn(
'px-0 flex-1 border-none truncate',
@@ -496,7 +513,9 @@ const MultiSelectorContent = React.forwardRef<HTMLDivElement, PopoverContentProp
return (
<PopoverContent
align="start"
collisionPadding={DROPDOWN_GAP}
ref={ref}
sideOffset={DROPDOWN_GAP}
className={cn(
'bg-overlay shadow-md z-50 border rounded-md p-0',
'w-(--radix-popper-anchor-width)',
@@ -524,53 +543,85 @@ const MultiSelectorList = React.forwardRef<
React.ElementRef<typeof CommandList>,
React.ComponentPropsWithoutRef<typeof CommandList> & {
creatable?: boolean
emptyLabel?: string
error?: boolean
errorLabel?: string
loading?: boolean
}
>(({ className, children, creatable = false, ...props }, ref) => {
const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect()
>(
(
{
className,
children,
creatable = false,
emptyLabel = 'No results found',
error = false,
errorLabel,
loading = false,
...props
},
ref
) => {
const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect()
const options = Children.toArray(children)
const availableOptions = options
.filter((x: any) => !!x.props.value)
.map((x: any) => x.props.value.toLowerCase())
const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase())
const options = Children.toArray(children)
const availableOptions = options
.filter((x: any) => !!x.props.value)
.map((x: any) => x.props.value.toLowerCase())
const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase())
return (
<CommandList
ref={ref}
className={cn(
'p-1 flex flex-col scrollbar-thin scrollbar-track-transparent transition-colors',
'scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted',
'scrollbar-thumb-rounded-lg w-full overflow-y-auto',
className
)}
style={{ maxHeight: dropdownMaxHeight }}
onWheel={(e) => e.stopPropagation()}
{...props}
>
{children}
{creatable && inputValue.length > 0 && !isOptionExists ? (
<CommandItem
role="option"
onSelect={() => {
open && toggleValue(inputValue)
setInputValue('')
}}
className={commandItemClass}
>
Create "{inputValue}"
</CommandItem>
) : creatable && options.length === 0 ? (
<div className="p-2 py-1.5 text-xs text-foreground-lighter font-italic">
Type to add a value
</div>
) : (
<CommandEmpty>
<span className="text-foreground-muted">No results found</span>
</CommandEmpty>
)}
</CommandList>
)
})
return (
<CommandList
ref={ref}
className={cn(
'p-1 flex flex-col scrollbar-thin scrollbar-track-transparent transition-colors',
'scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted',
'scrollbar-thumb-rounded-lg w-full overflow-y-auto',
className
)}
style={{
maxHeight: `min(${dropdownMaxHeight}px, calc(var(--radix-popover-content-available-height) - ${DROPDOWN_BORDER_HEIGHT}px))`,
}}
onWheel={(e) => e.stopPropagation()}
{...props}
>
<SelectionListState
isLoading={loading}
isError={error}
isEmpty={!loading && !error && options.length === 0 && !creatable}
emptyLabel={emptyLabel}
errorLabel={errorLabel}
skeletonVariant="multi-select"
/>
{!loading && !error && (options.length > 0 || creatable) && (
<>
{children}
{creatable && inputValue.length > 0 && !isOptionExists ? (
<CommandItem
role="option"
onSelect={() => {
open && toggleValue(inputValue)
setInputValue('')
}}
className={commandItemClass}
>
Create "{inputValue}"
</CommandItem>
) : creatable && options.length === 0 ? (
<div className="p-2 py-1.5 text-xs text-foreground-lighter font-italic">
Type to add a value
</div>
) : (
<CommandEmpty>
<span className="text-foreground-muted">{emptyLabel}</span>
</CommandEmpty>
)}
</>
)}
</CommandList>
)
}
)
MultiSelectorList.displayName = 'MultiSelectorList'
MultiSelector.List = MultiSelectorList