mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 23:59:08 +03:00
feat(ui-patterns): add async selection feedback
This commit is contained in:
1 parent
6e39b17d3c
commit
68c1e8ba59
13 files changed
+556
-296
No files matched your search
+153
-154
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
+66
-85
@@ -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>
|
||||
|
||||
+27
@@ -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)
|
||||
})
|
||||
})
|
||||
+32
@@ -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 }
|
||||
}
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user