mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
chore: migrate MultiSelectDeprecated to Shadcn multi-select (#45377)
## Problem We want to reduce the code we ship and maintain. ## Solution - Migrate old `MultiSelectDeprecated` usage to the new Shadcn `multi-select` - Fix `multi-select` background color to align it with other inputs - Fix `multi-select` popover content alignment (now align to its input start) ## Screenshots ### RLS policies Before: <img width="618" height="705" alt="image" src="https://github.com/user-attachments/assets/098504fc-21a9-4386-9390-e69f929189c1" /> After: <img width="549" height="704" alt="image" src="https://github.com/user-attachments/assets/06842e31-90bf-4d24-8c19-78f74941cd65" /> ### Storage policies Before: <img width="1177" height="664" alt="image" src="https://github.com/user-attachments/assets/3cf1afb4-9604-4ee9-b7b6-8371f94bcfcc" /> After: <img width="1170" height="653" alt="image" src="https://github.com/user-attachments/assets/e3b235d3-5890-45ff-9658-82c6612ac82a" /> ### Database indexes Before: <img width="675" height="496" alt="image" src="https://github.com/user-attachments/assets/84c0d3b6-45af-49dc-b4f4-274abed4cea7" /> After: <img width="674" height="498" alt="image" src="https://github.com/user-attachments/assets/697ceafc-256f-4106-9193-8697bc3d9d8e" /> ### Contact support Before: <img width="643" height="534" alt="image" src="https://github.com/user-attachments/assets/ee7fc790-622d-4c09-afab-269271a31af4" /> After: <img width="645" height="457" alt="image" src="https://github.com/user-attachments/assets/db0b9a32-95e0-4864-a12a-88828c431aab" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Replaced legacy multi-select controls with a unified selector UI: dynamic trigger labels, per-item disable support, explicit item rendering, deletable badges, and improved search/selection behavior. * **Chores** * Removed deprecated multi-select badge and legacy picker implementations; adjusted exports/types to align with the new selector components. * **Style** * Minor UI text and inline code styling improvements and modal spacing tweaks. * **Tests** * Updated end-to-end flows to wait and interact with the new pickers. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { sortBy } from 'lodash'
|
||||
import MultiSelect from 'ui-patterns/MultiSelectDeprecated'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { SYSTEM_ROLES } from '@/components/interfaces/Database/Roles/Roles.constants'
|
||||
@@ -51,13 +57,28 @@ export const PolicyRoles = ({ selectedRoles, onUpdateSelectedRoles }: PolicyRole
|
||||
{isLoading && <ShimmeringLoader className="py-4" />}
|
||||
{isError && <AlertError error={error as any} subject="Failed to retrieve database roles" />}
|
||||
{isSuccess && (
|
||||
<MultiSelect
|
||||
options={formattedRoles}
|
||||
value={selectedRoles}
|
||||
placeholder="Defaults to all (public) roles if none selected"
|
||||
searchPlaceholder="Search for a role"
|
||||
onChange={onUpdateSelectedRoles}
|
||||
/>
|
||||
<MultiSelector values={selectedRoles} onValuesChange={onUpdateSelectedRoles}>
|
||||
<MultiSelectorTrigger
|
||||
mode="inline-combobox"
|
||||
label={
|
||||
selectedRoles.length === 0
|
||||
? 'Defaults to all (public) roles if none selected'
|
||||
: 'Search for a role'
|
||||
}
|
||||
deletableBadge
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{formattedRoles.map((role) => (
|
||||
<MultiSelectorItem key={role.id} value={role.value} disabled={role.disabled}>
|
||||
{role.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+40
-9
@@ -29,7 +29,13 @@ import {
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
|
||||
import { useTablesQuery } from '@/data/tables/tables-query'
|
||||
@@ -298,18 +304,43 @@ export const PolicyDetailsV2 = ({
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-12 flex flex-col gap-y-1">
|
||||
<FormLabel>
|
||||
<FormLabel htmlFor="roles">
|
||||
Target Roles <code className="text-code-inline">to</code> clause
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectV2
|
||||
<MultiSelector
|
||||
onValuesChange={(roles) => field.onChange(roles.join(', '))}
|
||||
disabled={!canUpdatePolicies}
|
||||
options={formattedRoles}
|
||||
value={field.value.length === 0 ? [] : field.value?.split(', ')}
|
||||
placeholder="Defaults to all (public) roles if none selected"
|
||||
searchPlaceholder="Search for a role"
|
||||
onChange={(roles) => form.setValue('roles', roles.join(', '))}
|
||||
/>
|
||||
values={field.value.length === 0 ? [] : field.value?.split(', ')}
|
||||
size="small"
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
id="roles"
|
||||
mode="inline-combobox"
|
||||
label={
|
||||
field.value.length === 0
|
||||
? 'Defaults to all (public) roles if none selected'
|
||||
: 'Search for a role'
|
||||
}
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
deletableBadge
|
||||
className="w-full"
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{formattedRoles.map((role) => (
|
||||
<MultiSelectorItem
|
||||
key={role.id}
|
||||
value={role.value}
|
||||
disabled={role.disabled}
|
||||
>
|
||||
{role.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -79,14 +79,14 @@ export const PolicyRow = ({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[20%] truncate">
|
||||
<code className="text-foreground-light text-xs">{policy.command}</code>
|
||||
<code className="text-code-inline">{policy.command}</code>
|
||||
</TableCell>
|
||||
<TableCell className="w-[30%] truncate">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="text-foreground-lighter text-sm truncate">
|
||||
{displayedRoles.slice(0, 2).map((role, i) => (
|
||||
<span key={`policy-${role}-${i}`}>
|
||||
<code className="text-foreground-light text-xs">{role}</code>
|
||||
<code className="text-code-inline">{role}</code>
|
||||
{i < Math.min(displayedRoles.length, 2) - 1 ? ', ' : ' '}
|
||||
</span>
|
||||
))}
|
||||
@@ -95,13 +95,24 @@ export const PolicyRow = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<code key="policy-etc" className="text-foreground-light text-xs">
|
||||
<span key="policy-etc" className="text-foreground-light text-xs">
|
||||
+ {displayedRoles.length - 2} more
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
{displayedRoles.join(', ')}
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="center"
|
||||
className="max-w-80 font-mono flex flex-wrap justify-center gap-y-1"
|
||||
>
|
||||
{displayedRoles.slice(2).map((role, i, arr) => (
|
||||
<>
|
||||
<code key={role} className="text-code-inline !break-keep">
|
||||
{role}
|
||||
</code>
|
||||
{i < arr.length - 1 && ', '}
|
||||
</>
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -24,8 +24,13 @@ import {
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { MultiSelectOption } from 'ui-patterns/MultiSelectDeprecated'
|
||||
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { INDEX_TYPES } from './Indexes.constants'
|
||||
@@ -101,7 +106,7 @@ export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelP
|
||||
}
|
||||
|
||||
const columns = tableColumns?.[0]?.columns ?? []
|
||||
const columnOptions: MultiSelectOption[] = columns
|
||||
const columnOptions = columns
|
||||
.filter((column): column is NonNullable<typeof column> => column !== null)
|
||||
.map((column) => ({
|
||||
id: column.attname,
|
||||
@@ -328,16 +333,36 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
|
||||
</FormItemLayout>
|
||||
|
||||
{selectedEntity && (
|
||||
<FormItemLayout label="Select up to 32 columns" isReactForm={false}>
|
||||
<FormItemLayout id="columns" label="Select up to 32 columns" isReactForm={false}>
|
||||
{isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
|
||||
{isSuccessTableColumns && (
|
||||
<MultiSelectV2
|
||||
options={columnOptions}
|
||||
placeholder="Choose which columns to create an index on"
|
||||
searchPlaceholder="Search for a column"
|
||||
value={selectedColumns}
|
||||
onChange={setSelectedColumns}
|
||||
/>
|
||||
<MultiSelector values={selectedColumns} onValuesChange={setSelectedColumns}>
|
||||
<MultiSelectorTrigger
|
||||
id="columns"
|
||||
mode="inline-combobox"
|
||||
label={
|
||||
selectedColumns.length === 0
|
||||
? 'Choose which columns to create an index on'
|
||||
: 'Search for a column'
|
||||
}
|
||||
deletableBadge
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{columnOptions.map((option) => (
|
||||
<MultiSelectorItem
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
const Indexes = () => {
|
||||
export const Indexes = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { schema: urlSchema, table } = useParams()
|
||||
|
||||
@@ -278,7 +278,8 @@ const Indexes = () => {
|
||||
visible={!!selectedIndexToDelete}
|
||||
title={
|
||||
<>
|
||||
Confirm to delete index <code className="text-sm">{selectedIndexToDelete?.name}</code>
|
||||
Confirm to delete index{' '}
|
||||
<code className="text-code-inline">{selectedIndexToDelete?.name}</code>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Confirm delete"
|
||||
@@ -292,6 +293,7 @@ const Indexes = () => {
|
||||
description:
|
||||
'Deleting an index that is still in use will cause queries to slow down, and in some cases causing significant performance issues.',
|
||||
}}
|
||||
className="pt-0"
|
||||
>
|
||||
<ul className="mt-4 space-y-5">
|
||||
<li className="flex gap-3">
|
||||
@@ -311,5 +313,3 @@ const Indexes = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Indexes
|
||||
|
||||
@@ -4,7 +4,13 @@ import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { FormControl, FormField } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import { SERVICE_OPTIONS, type ExtendedSupportCategories } from './Support.constants'
|
||||
import type { SupportFormValues } from './SupportForm.schema'
|
||||
@@ -29,13 +35,31 @@ export function AffectedServicesSelector({ form, category }: AffectedServicesSel
|
||||
render={({ field }) => (
|
||||
<FormItemLayout hideMessage layout="vertical" label="Which services are affected?">
|
||||
<FormControl>
|
||||
<MultiSelectV2
|
||||
options={SERVICE_OPTIONS}
|
||||
value={field.value.length === 0 ? [] : field.value?.split(', ')}
|
||||
placeholder="No particular service"
|
||||
searchPlaceholder="Search for a service"
|
||||
onChange={(services) => form.setValue('affectedServices', services.join(', '))}
|
||||
/>
|
||||
<MultiSelector
|
||||
values={field.value.length === 0 ? [] : field.value?.split(', ')}
|
||||
onValuesChange={(services) => field.onChange(services.join(', '))}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
mode="inline-combobox"
|
||||
label={field.value.length === 0 ? 'No particular service' : 'Search for a service'}
|
||||
deletableBadge
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{SERVICE_OPTIONS.map((service) => (
|
||||
<MultiSelectorItem
|
||||
key={service.id}
|
||||
value={service.value}
|
||||
disabled={service.disabled}
|
||||
>
|
||||
{service.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from 'ui-patterns/PageHeader'
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
|
||||
|
||||
import Indexes from '@/components/interfaces/Database/Indexes/Indexes'
|
||||
import { Indexes } from '@/components/interfaces/Database/Indexes/Indexes'
|
||||
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
@@ -651,16 +651,26 @@ test.describe('Database', () => {
|
||||
await dropTable(databaseTableName)
|
||||
}
|
||||
)
|
||||
const indexWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=indexes-public')
|
||||
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/indexes?schema=public`))
|
||||
|
||||
// Wait for database indexes to be populated
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=indexes-public')
|
||||
await indexWait
|
||||
|
||||
// create new index
|
||||
await page.getByRole('button', { name: 'Create index' }).click()
|
||||
await page.getByRole('button', { name: 'Choose a table' }).click()
|
||||
|
||||
const columnsWait = waitForApiResponse(
|
||||
page,
|
||||
'pg-meta',
|
||||
ref,
|
||||
`query?key=table-columns-public-${databaseTableName}`
|
||||
)
|
||||
await page.getByRole('option', { name: databaseTableName, exact: true }).click()
|
||||
await page.getByText('Choose which columns to create an index on').click()
|
||||
await columnsWait
|
||||
|
||||
await page.getByRole('combobox', { name: 'Select up to 32 columns' }).click()
|
||||
await page.getByRole('option', { name: databaseColumnName }).click()
|
||||
await page.getByRole('button', { name: 'Create index' }).click()
|
||||
await expect(
|
||||
@@ -1136,9 +1146,7 @@ test.describe('Database Enumerated Types', () => {
|
||||
await quotedEnumCreateWait
|
||||
const quotedEnumRow = page.getByRole('row', { name: `${quotedEnumName}` })
|
||||
await expect(quotedEnumRow).toContainText(quotedEnumName)
|
||||
await expect(quotedEnumRow).toContainText(
|
||||
`${quotedEnumValue1Name}, ${quotedEnumValue2Name}`
|
||||
)
|
||||
await expect(quotedEnumRow).toContainText(`${quotedEnumValue1Name}, ${quotedEnumValue2Name}`)
|
||||
|
||||
await quotedEnumRow.getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: 'Update type' }).click()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expect, Page } from '@playwright/test'
|
||||
|
||||
import { createTableWithRLS, dropTable } from '../utils/db/queries.js'
|
||||
import { test, withSetupCleanup } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
import { createApiResponseWaiter, waitForApiResponse } from '../utils/wait-for-response.js'
|
||||
import { createTableWithRLS, dropTable } from '../utils/db/queries.js'
|
||||
|
||||
/**
|
||||
* Helper function to navigate to policies page and wait for it to load
|
||||
@@ -283,7 +283,7 @@ test.describe('RLS Policies', () => {
|
||||
await page.getByRole('radio', { name: 'INSERT' }).click()
|
||||
|
||||
// Select target role - authenticated
|
||||
await page.getByText('Defaults to all (public) roles if none selected').click()
|
||||
await page.getByRole('combobox', { name: 'Target Roles' }).click()
|
||||
await page.getByRole('option', { name: 'authenticated' }).click()
|
||||
|
||||
// Close the dropdown
|
||||
@@ -338,7 +338,7 @@ test.describe('RLS Policies', () => {
|
||||
await page.getByRole('radio', { name: 'UPDATE' }).click()
|
||||
|
||||
// Select authenticated role
|
||||
await page.getByText('Defaults to all (public) roles if none selected').click()
|
||||
await page.getByRole('combobox', { name: 'Target Roles' }).click()
|
||||
await page.getByRole('option', { name: 'authenticated' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
@@ -390,7 +390,7 @@ test.describe('RLS Policies', () => {
|
||||
await page.getByRole('radio', { name: 'DELETE' }).click()
|
||||
|
||||
// Select authenticated role
|
||||
await page.getByText('Defaults to all (public) roles if none selected').click()
|
||||
await page.getByRole('combobox', { name: 'Target Roles' }).click()
|
||||
await page.getByRole('option', { name: 'authenticated' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* @deprecated Use ./multi-select instead
|
||||
*/
|
||||
export const BadgeDisabled = ({ name }: { name: string }) => (
|
||||
<div
|
||||
className={[
|
||||
'text-typography-body-light [[data-theme*=dark]_&]:text-typography-body-dark',
|
||||
'flex cursor-not-allowed items-center space-x-2 rounded bg-gray-600',
|
||||
'py-0.5 px-2 text-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="opacity-50">{name}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* @deprecated Use ./multi-select instead
|
||||
*/
|
||||
export const BadgeSelected = ({
|
||||
name,
|
||||
handleRemove,
|
||||
}: {
|
||||
name: string
|
||||
handleRemove: () => void
|
||||
}) => (
|
||||
<div
|
||||
className={[
|
||||
'text-typography-body-light [[data-theme*=dark]_&]:text-typography-body-dark',
|
||||
'flex items-center space-x-2 rounded bg-surface-300',
|
||||
'py-0.5 px-2 text-sm',
|
||||
].join(' ')}
|
||||
onClick={(e: any) => e.preventDefault()}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<X
|
||||
size={12}
|
||||
className="cursor-pointer opacity-50 transition hover:opacity-100"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleRemove()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1,159 +0,0 @@
|
||||
import { orderBy, without } from 'lodash'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import {
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
|
||||
import { BadgeDisabled, BadgeSelected } from './Badges'
|
||||
|
||||
interface MultiSelectOption {
|
||||
id: string | number
|
||||
value: string
|
||||
name: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
value: string[]
|
||||
options: MultiSelectOption[]
|
||||
placeholder?: string | ReactNode
|
||||
searchPlaceholder?: string
|
||||
disabled?: boolean
|
||||
onChange?(x: string[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use ./multi-select instead
|
||||
*/
|
||||
export const MultiSelectV2 = ({
|
||||
options,
|
||||
value,
|
||||
placeholder,
|
||||
searchPlaceholder = 'Search for option',
|
||||
disabled = false,
|
||||
onChange = () => {},
|
||||
}: MultiSelectProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>(value || [])
|
||||
|
||||
// Selected is `value` if defined, otherwise use local useState
|
||||
const selectedOptions = value || selected
|
||||
|
||||
// Order the options so disabled items are at the beginning
|
||||
const formattedOptions = orderBy(options, ['disabled'], ['desc'])
|
||||
|
||||
const checkIfActive = (option: MultiSelectOption) => {
|
||||
const isOptionSelected = (selectedOptions || []).find((x) => x === option.value)
|
||||
return isOptionSelected !== undefined
|
||||
}
|
||||
|
||||
const handleChange = (option: MultiSelectOption) => {
|
||||
const _selected = selectedOptions
|
||||
const isActive = checkIfActive(option)
|
||||
|
||||
const updatedPayload = isActive
|
||||
? [...without(_selected, option.value)]
|
||||
: [..._selected.concat([option.value])]
|
||||
|
||||
// Payload must always include disabled options
|
||||
const compulsoryOptions = options
|
||||
.filter((option) => option.disabled)
|
||||
.map((option) => option.name)
|
||||
|
||||
const formattedPayload = [...new Set(updatedPayload.concat(compulsoryOptions))]
|
||||
|
||||
setSelected(formattedPayload)
|
||||
onChange(formattedPayload)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'pointer-events-none opacity-50' : ''}>
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'relative border border-strong bg-control rounded',
|
||||
'flex w-full flex-wrap items-start gap-1.5 p-1.5 cursor-pointer',
|
||||
`${selectedOptions.length === 0 ? 'h-9' : ''}`
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{selectedOptions.length === 0 && placeholder && (
|
||||
<div className="px-2 text-sm text-foreground-light h-full flex items-center">
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
{selectedOptions.map((value, idx) => {
|
||||
const id = `${value}-${idx}`
|
||||
const option = formattedOptions.find((x) => x.value === value)
|
||||
const isDisabled = option?.disabled ?? false
|
||||
if (!option) {
|
||||
return <></>
|
||||
} else if (isDisabled) {
|
||||
return <BadgeDisabled key={id} name={option.name} />
|
||||
} else {
|
||||
return (
|
||||
<BadgeSelected
|
||||
key={id}
|
||||
name={option.name}
|
||||
handleRemove={() => handleChange(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
<div className="absolute inset-y-0 right-0 pl-3 pr-2 flex space-x-1 items-center cursor-pointer ">
|
||||
<ChevronDown size={16} strokeWidth={2} className="text-foreground-lighter" />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-96 border-strong" side="bottom" align="start">
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder={searchPlaceholder} />
|
||||
<CommandList_Shadcn_ onWheel={(event) => event.stopPropagation()}>
|
||||
<CommandEmpty_Shadcn_>No options found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className={cn(formattedOptions.length > 7 ? 'h-[210px]' : '')}>
|
||||
{formattedOptions?.map((option) => {
|
||||
const active =
|
||||
selectedOptions &&
|
||||
selectedOptions.find((selected) => {
|
||||
return selected === option.value
|
||||
})
|
||||
? true
|
||||
: false
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
className="cursor-pointer w-full"
|
||||
onClick={() => handleChange(option)}
|
||||
onSelect={() => handleChange(option)}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
{option.name}
|
||||
{active && <Check size={14} />}
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
import { filter, orderBy, without } from 'lodash'
|
||||
import { AlertCircle, Check, ChevronDown, Plus, Search } from 'lucide-react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Input,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
|
||||
import { BadgeDisabled, BadgeSelected } from './Badges'
|
||||
|
||||
export interface MultiSelectOption {
|
||||
id: string | number
|
||||
value: string
|
||||
name: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string[]
|
||||
options: MultiSelectOption[]
|
||||
label?: string
|
||||
error?: string
|
||||
placeholder?: string | ReactNode
|
||||
searchPlaceholder?: string
|
||||
descriptionText?: string | ReactNode
|
||||
emptyMessage?: string | ReactNode
|
||||
disabled?: boolean
|
||||
allowDuplicateSelection?: boolean
|
||||
onChange?(x: string[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy styling from supabase/ui default.theme
|
||||
* input base + standard
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use ./multi-select instead
|
||||
*/
|
||||
export default function MultiSelect({
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
error,
|
||||
descriptionText,
|
||||
placeholder,
|
||||
searchPlaceholder = 'Search for option',
|
||||
emptyMessage,
|
||||
disabled,
|
||||
allowDuplicateSelection = false,
|
||||
onChange = () => {},
|
||||
}: Props) {
|
||||
const ref = useRef(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>(value || [])
|
||||
const [searchString, setSearchString] = useState<string>('')
|
||||
const [inputWidth, setInputWidth] = useState<number>(128)
|
||||
|
||||
// Selected is `value` if defined, otherwise use local useState
|
||||
const selectedOptions = value || selected
|
||||
|
||||
// Calculate width of the Popover
|
||||
useEffect(() => {
|
||||
setInputWidth(ref.current ? (ref.current as any).offsetWidth : inputWidth)
|
||||
}, [ref.current])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearchString('')
|
||||
}, [open])
|
||||
|
||||
const width = `${inputWidth}px`
|
||||
|
||||
// Order the options so disabled items are at the beginning
|
||||
const formattedOptions = orderBy(options, ['disabled'], ['desc'])
|
||||
|
||||
// Options to show in Popover menu
|
||||
const filteredOptions =
|
||||
searchString.length > 0
|
||||
? filter(formattedOptions, (option) => !option.disabled && option.name.includes(searchString))
|
||||
: filter(formattedOptions, { disabled: false })
|
||||
|
||||
const checkIfActive = (option: MultiSelectOption) => {
|
||||
const isOptionSelected = (selectedOptions || []).find((x) => x === option.value)
|
||||
return isOptionSelected !== undefined
|
||||
}
|
||||
|
||||
const handleRemove = (idx: number) => {
|
||||
const updatedSelected = selected.filter((_x, index) => index !== idx)
|
||||
setSelected(updatedSelected)
|
||||
onChange(updatedSelected)
|
||||
}
|
||||
|
||||
const handleChange = (option: MultiSelectOption) => {
|
||||
const _selected = selectedOptions
|
||||
const isActive = checkIfActive(option)
|
||||
|
||||
const updatedPayload = allowDuplicateSelection
|
||||
? [..._selected.concat([option.value])]
|
||||
: isActive
|
||||
? [...without(_selected, option.value)]
|
||||
: [..._selected.concat([option.value])]
|
||||
|
||||
// Payload must always include disabled options
|
||||
const compulsoryOptions = options
|
||||
.filter((option) => option.disabled)
|
||||
.map((option) => option.name)
|
||||
|
||||
const formattedPayload = allowDuplicateSelection
|
||||
? updatedPayload.concat(compulsoryOptions)
|
||||
: [...new Set(updatedPayload.concat(compulsoryOptions))]
|
||||
|
||||
setSelected(formattedPayload)
|
||||
onChange(formattedPayload)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`form-group ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
||||
{label && <label className="!w-full">{label}</label>}
|
||||
<div
|
||||
className={[
|
||||
'form-control form-control--multi-select',
|
||||
'border border-strong bg-control',
|
||||
'multi-select relative block w-full space-x-1 overflow-auto rounded',
|
||||
`${error !== undefined ? 'border-red-800 bg-red-100' : ''}`,
|
||||
].join(' ')}
|
||||
ref={ref}
|
||||
>
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<div
|
||||
className={[
|
||||
'flex w-full flex-wrap items-start gap-1.5 p-1.5 cursor-pointer',
|
||||
`${selectedOptions.length === 0 ? 'h-9' : ''}`,
|
||||
].join(' ')}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{selectedOptions.length === 0 && placeholder && (
|
||||
<div className="px-2 text-sm text-foreground-light h-full flex items-center">
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
{selectedOptions.map((value, idx) => {
|
||||
const id = `${value}-${idx}`
|
||||
const option = formattedOptions.find((x) => x.value === value)
|
||||
const isDisabled = option?.disabled ?? false
|
||||
if (!option) {
|
||||
return <></>
|
||||
} else if (isDisabled) {
|
||||
return <BadgeDisabled key={id} name={value} />
|
||||
} else {
|
||||
return (
|
||||
<BadgeSelected
|
||||
key={id}
|
||||
name={value}
|
||||
handleRemove={() =>
|
||||
allowDuplicateSelection ? handleRemove(idx) : handleChange(option)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
<div className="absolute inset-y-0 right-0 pl-3 pr-2 flex space-x-1 items-center cursor-pointer ">
|
||||
<ChevronDown size={16} strokeWidth={2} className="text-foreground-lighter" />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
style={{ width, marginLeft: '-5px' }}
|
||||
>
|
||||
<Input
|
||||
className="[&>div>div>div>input]:!rounded-b-none [&>div>div>div>input]:!pl-9"
|
||||
icon={<Search size={16} />}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchString}
|
||||
onChange={(e) => setSearchString(e.target.value)}
|
||||
/>
|
||||
<ScrollArea className={clsx('p-1', filteredOptions.length > 5 ? 'h-[225px]' : '')}>
|
||||
{filteredOptions.length >= 1 ? (
|
||||
filteredOptions.map((option) => {
|
||||
const active =
|
||||
!allowDuplicateSelection &&
|
||||
selectedOptions &&
|
||||
selectedOptions.find((selected) => {
|
||||
return selected === option.value
|
||||
})
|
||||
? true
|
||||
: false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`multiselect-option-${option.id}`}
|
||||
onClick={() => handleChange(option)}
|
||||
className={[
|
||||
'text-typography-body-light [[data-theme*=dark]_&]:text-typography-body-dark',
|
||||
'group flex cursor-pointer items-center justify-between transition',
|
||||
'space-x-1 rounded bg-transparent p-2 px-4 text-sm hover:bg-overlay-hover',
|
||||
`${active ? ' [[data-theme*=dark]_&]:bg-green-600/25' : ''}`,
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>{option.name}</p>
|
||||
{option.description !== undefined && (
|
||||
<p className="opacity-50">{option.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{active && (
|
||||
<Check
|
||||
size={16}
|
||||
strokeWidth={3}
|
||||
className={`cursor-pointer transition ${active ? 'text-brand' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{allowDuplicateSelection && (
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition space-x-1">
|
||||
<Plus size={14} />
|
||||
<p className="text-sm">Add value</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : options.length === 0 ? (
|
||||
<div
|
||||
className={[
|
||||
'flex h-full w-full flex-col border-default',
|
||||
'items-center justify-center border border-dashed p-3',
|
||||
].join(' ')}
|
||||
>
|
||||
{emptyMessage ? (
|
||||
emptyMessage
|
||||
) : (
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<AlertCircle strokeWidth={1.5} size={18} className="text-foreground-light" />
|
||||
<p className="text-sm text-foreground-light">No options available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={[
|
||||
'flex h-full w-full flex-col border-default',
|
||||
'items-center justify-center border border-dashed p-3',
|
||||
].join(' ')}
|
||||
>
|
||||
{emptyMessage ? (
|
||||
emptyMessage
|
||||
) : (
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<p className="text-sm text-foreground-light">No options found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</div>
|
||||
|
||||
{descriptionText && (
|
||||
<span className="form-text text-muted mt-2 text-sm">{descriptionText}</span>
|
||||
)}
|
||||
{error && <span className="text-red-900 text-sm mt-2">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,17 @@
|
||||
|
||||
import { cva, VariantProps } from 'class-variance-authority'
|
||||
import { Check, ChevronsUpDown, X as RemoveIcon } from 'lucide-react'
|
||||
// @ts-ignore Required to avoid TS error: The inferred type of MultiSelectorContent cannot be named without a reference to @radix-ui
|
||||
import type { Popover as PopoverPrimitive } from 'radix-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Badge, cn, Popover_Shadcn_ as Popover, PopoverAnchor_Shadcn_ as PopoverAnchor } from 'ui'
|
||||
import {
|
||||
Badge,
|
||||
cn,
|
||||
Popover_Shadcn_ as Popover,
|
||||
PopoverAnchor_Shadcn_ as PopoverAnchor,
|
||||
PopoverContent_Shadcn_ as PopoverContent,
|
||||
PopoverContentProps_Shadcn_ as PopoverContentProps,
|
||||
} from 'ui'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -11,7 +20,6 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from 'ui/src/components/shadcn/ui/command'
|
||||
import { PopoverContent } from 'ui/src/components/shadcn/ui/popover'
|
||||
import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from 'ui/src/lib/constants'
|
||||
|
||||
interface MultiSelectContextProps {
|
||||
@@ -286,7 +294,7 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'flex w-full min-w-[200px] min-h-[40px] items-center justify-between rounded-md border',
|
||||
'border-alternative bg-foreground/[.026] px-3 py-2 text-sm',
|
||||
'border-alternative bg-control px-3 py-2 text-sm',
|
||||
'ring-offset-background placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
@@ -462,11 +470,12 @@ const MultiSelectorInput = React.forwardRef<
|
||||
MultiSelectorInput.displayName = 'MultiSelectorInput'
|
||||
MultiSelector.Input = MultiSelectorInput
|
||||
|
||||
const MultiSelectorContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children }, ref) => {
|
||||
const MultiSelectorContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { id } = useMultiSelect()
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-overlay shadow-md z-50 border border-overlay rounded-md p-0',
|
||||
@@ -479,6 +488,8 @@ const MultiSelectorContent = React.forwardRef<HTMLDivElement, React.HTMLAttribut
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
sameWidthAsTrigger
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -133,6 +133,7 @@ export {
|
||||
PopoverContent as PopoverContent_Shadcn_,
|
||||
PopoverAnchor as PopoverAnchor_Shadcn_,
|
||||
PopoverSeparator as PopoverSeparator_Shadcn_,
|
||||
type PopoverContentProps as PopoverContentProps_Shadcn_,
|
||||
} from './src/components/shadcn/ui/popover'
|
||||
|
||||
export {
|
||||
|
||||
@@ -10,7 +10,7 @@ const Popover = PopoverPrimitive.Root
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
type PopoverContentProps = {
|
||||
export type PopoverContentProps = {
|
||||
align?: 'center' | 'start' | 'end'
|
||||
sideOffset?: number
|
||||
sameWidthAsTrigger?: boolean
|
||||
|
||||
Reference in New Issue
Block a user