mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
Re-work provider settings to use react-hook-form and dynamic model data (#69)
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com> Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
This commit is contained in:
parent
132bf8ae09
commit
9b6ad19db0
18 changed files with 1259 additions and 848 deletions
|
|
@ -39,6 +39,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
|
@ -58,6 +59,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"lucide-react": "^0.509.0",
|
||||
"next": "^15.3.3",
|
||||
"next-intl": "^4.1.0",
|
||||
|
|
|
|||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
|
|
@ -44,6 +44,9 @@ importers:
|
|||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.14
|
||||
version: 1.1.14(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -101,6 +104,9 @@ importers:
|
|||
drizzle-zod:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1(drizzle-orm@0.43.1(@electric-sql/pglite@0.3.0)(@libsql/client-wasm@0.15.5)(@opentelemetry/api@1.9.0)(@types/pg@8.15.2)(pg@8.15.6)(postgres@3.4.7))(zod@3.25.41)
|
||||
fuzzysort:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.509.0
|
||||
version: 0.509.0(react@19.1.0)
|
||||
|
|
@ -2464,6 +2470,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.14':
|
||||
resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.7':
|
||||
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -5466,6 +5485,9 @@ packages:
|
|||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
fuzzysort@3.1.0:
|
||||
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -11407,6 +11429,28 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.1.6
|
||||
|
||||
'@radix-ui/react-popover@1.1.14(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-popper': 1.2.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-presence': 1.1.4(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-remove-scroll: 2.7.0(@types/react@19.1.6)(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.6
|
||||
|
||||
'@radix-ui/react-popper@1.2.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -14761,6 +14805,8 @@ snapshots:
|
|||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
fuzzysort@3.1.0: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
|
|
|||
|
|
@ -1,220 +1,184 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { ProviderName } from '@roo-code/types';
|
||||
|
||||
import { type OrganizationSettings, ORGANIZATION_ALLOW_ALL } from '@/types';
|
||||
import { providers } from '@/lib/providers';
|
||||
import { type OrganizationAllowList, QueryKey } from '@/types';
|
||||
import { updateOrganization } from '@/actions/organizationSettings';
|
||||
import { useAvailableProviders } from '@/hooks/useAvailableProviders';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Switch, Button, Checkbox, Label } from '@/components/ui';
|
||||
import { MultipleSelector } from '@/components/ui/ecosystem';
|
||||
import {
|
||||
Switch,
|
||||
Button,
|
||||
Label,
|
||||
Table,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from '@/components/ui';
|
||||
import { MultiSelect } from '@/components/ui/ecosystem';
|
||||
import { Loading } from '@/components/layout';
|
||||
|
||||
type ProviderFormProps = {
|
||||
orgSettings: OrganizationSettings;
|
||||
};
|
||||
export const ProviderForm = () => {
|
||||
const { availableProviders, allowList } = useAvailableProviders();
|
||||
|
||||
const {
|
||||
watch,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { isDirty, isSubmitting },
|
||||
} = useForm<OrganizationAllowList>({
|
||||
defaultValues: allowList,
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const [allowAll, providers] = watch(['allowAll', 'providers']);
|
||||
|
||||
export const ProviderForm = ({ orgSettings }: ProviderFormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const fullProviderMetadata = useMemo(
|
||||
() =>
|
||||
Object.entries(providers).map(([id, provider]) => {
|
||||
const models =
|
||||
orgSettings.allowList.providers[id as ProviderName]?.models;
|
||||
|
||||
if (models) {
|
||||
const providerModels = Object.keys(provider.models || {});
|
||||
const set = new Set(providerModels);
|
||||
const difference = models.filter((model) => !set.has(model));
|
||||
|
||||
if (difference.length > 0) {
|
||||
return { ...provider, models: [...providerModels, ...difference] };
|
||||
}
|
||||
}
|
||||
|
||||
return provider;
|
||||
}),
|
||||
[orgSettings],
|
||||
);
|
||||
|
||||
const [allowAll, setAllowAll] = useState(orgSettings.allowList.allowAll);
|
||||
|
||||
const [providerAllowAll, setProviderAllowAll] = useState(
|
||||
Object.entries(orgSettings.allowList.providers).reduce(
|
||||
(acc, [provider, providerSettings]) => {
|
||||
if (providerSettings.allowAll) {
|
||||
acc.add(provider);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
new Set<string>(),
|
||||
),
|
||||
);
|
||||
|
||||
const [providerModels, setProviderModels] = useState(
|
||||
fullProviderMetadata.reduce((acc, meta) => {
|
||||
const models = Object.keys(
|
||||
orgSettings.allowList.providers[meta.id]?.models || {},
|
||||
);
|
||||
|
||||
acc.set(meta.id, models);
|
||||
|
||||
return acc;
|
||||
}, new Map<ProviderName, string[]>()),
|
||||
);
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isProviderAllowAll = (providerId: ProviderName) =>
|
||||
providerAllowAll.has(providerId);
|
||||
|
||||
const toggleProvider = (providerId: ProviderName) => {
|
||||
const newProviderAllowAll = new Set(providerAllowAll);
|
||||
|
||||
if (providerAllowAll.has(providerId)) {
|
||||
newProviderAllowAll.delete(providerId);
|
||||
} else {
|
||||
newProviderAllowAll.add(providerId);
|
||||
}
|
||||
|
||||
setProviderAllowAll(newProviderAllowAll);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const setModels = (providerId: ProviderName, models: string[]) => {
|
||||
const newProviderModels = new Map(providerModels);
|
||||
newProviderModels.set(providerId, models);
|
||||
setProviderModels(newProviderModels);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const saveChanges = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
const onSubmit = async (data: OrganizationAllowList) => {
|
||||
try {
|
||||
let allowList;
|
||||
|
||||
if (allowAll) {
|
||||
allowList = ORGANIZATION_ALLOW_ALL;
|
||||
} else {
|
||||
allowList = {
|
||||
allowAll: false,
|
||||
providers: fullProviderMetadata.reduce(
|
||||
(acc, meta) => {
|
||||
if (providerAllowAll.has(meta.id)) {
|
||||
acc[meta.id] = { allowAll: true };
|
||||
} else {
|
||||
const models = providerModels.get(meta.id);
|
||||
|
||||
if (models && models.length > 0) {
|
||||
acc[meta.id] = { allowAll: false, models: models };
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
ProviderName,
|
||||
{ allowAll: boolean; models?: string[] }
|
||||
>,
|
||||
// Filter out providers with no models selected (unless they have
|
||||
// `allowAll: true`).
|
||||
const allowList = {
|
||||
...data,
|
||||
providers: Object.fromEntries(
|
||||
Object.entries(data.providers).filter(
|
||||
([, provider]) =>
|
||||
provider.allowAll ||
|
||||
(provider.models && provider.models.length > 0),
|
||||
),
|
||||
};
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
const result = await updateOrganization({ allowList: allowList });
|
||||
const result = await updateOrganization({ allowList });
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update settings.');
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['organizationSettings'] });
|
||||
reset(allowList);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKey.GetOrganizationSettings],
|
||||
});
|
||||
|
||||
toast.success('Changes will be deployed within 30 seconds.');
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save provider settings.', error);
|
||||
toast.error('Failed to save provider settings.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setProvider = ({
|
||||
id,
|
||||
enabled = true,
|
||||
allowAll = true,
|
||||
models = [],
|
||||
}: {
|
||||
id: ProviderName;
|
||||
enabled?: boolean;
|
||||
allowAll?: boolean;
|
||||
models?: string[];
|
||||
}) => {
|
||||
const newProviders = { ...providers };
|
||||
|
||||
if (enabled) {
|
||||
newProviders[id] = {
|
||||
allowAll: models.length > 0 ? false : allowAll,
|
||||
models,
|
||||
};
|
||||
} else {
|
||||
delete newProviders[id];
|
||||
}
|
||||
|
||||
setValue('providers', newProviders, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-[80px]">
|
||||
<div className="mb-4 flex items-center space-x-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Restrict Providers
|
||||
<Switch
|
||||
checked={!allowAll}
|
||||
onCheckedChange={() => {
|
||||
setAllowAll(!allowAll);
|
||||
setHasChanges(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={cn({ hidden: allowAll })}>
|
||||
{fullProviderMetadata.map((provider) => (
|
||||
<div key={provider.id} className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{provider.label}</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor={`provider-${provider.id}`} className="text-sm">
|
||||
Allow all models
|
||||
</Label>
|
||||
<Checkbox
|
||||
id={`provider-${provider.id}`}
|
||||
checked={isProviderAllowAll(provider.id)}
|
||||
disabled={allowAll || isSaving}
|
||||
onCheckedChange={() => toggleProvider(provider.id)}
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-2 mb-[80px]"
|
||||
>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
<Label>
|
||||
<Switch
|
||||
checked={allowAll}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('allowAll', checked, { shouldDirty: true })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-full">
|
||||
<MultipleSelector
|
||||
defaultOptions={Object.keys(provider.models || {}).map(
|
||||
(model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
disable: providerModels.get(provider.id)?.includes(model),
|
||||
}),
|
||||
Enable All Providers
|
||||
</Label>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{availableProviders.map((provider) => (
|
||||
<TableRow key={provider.id} className={cn({ hidden: allowAll })}>
|
||||
<TableCell>
|
||||
<Label>
|
||||
<Switch
|
||||
checked={!!providers[provider.id]}
|
||||
onCheckedChange={(checked) =>
|
||||
setProvider({ id: provider.id, enabled: checked })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{provider.label}
|
||||
</Label>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{provider.models.length > 0 && (
|
||||
<Label className={cn({ hidden: !providers[provider.id] })}>
|
||||
<Switch
|
||||
checked={providers[provider.id]?.allowAll}
|
||||
onCheckedChange={(checked) =>
|
||||
setProvider({ id: provider.id, allowAll: checked })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Enable All Models
|
||||
</Label>
|
||||
)}
|
||||
value={(providerModels.get(provider.id) || []).map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}))}
|
||||
creatable
|
||||
disabled={allowAll || isSaving}
|
||||
placeholder="Pick models..."
|
||||
onChange={(options) =>
|
||||
setModels(
|
||||
provider.id,
|
||||
options.map((option) => option.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="min-w-1/2">
|
||||
{providers[provider.id]?.allowAll === false &&
|
||||
provider.models.length > 0 && (
|
||||
<MultiSelect
|
||||
options={provider.models.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}))}
|
||||
value={providers[provider.id]?.models || []}
|
||||
placeholder="Select"
|
||||
onValueChange={(options) =>
|
||||
setProvider({ id: provider.id, models: options })
|
||||
}
|
||||
maxCount={3}
|
||||
className={cn({ hidden: !providers[provider.id] })}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div
|
||||
className={cn('fixed bottom-0 left-0 right-0', {
|
||||
hidden: !hasChanges && !isSaving,
|
||||
hidden: !isDirty && !isSubmitting,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-end h-[80px] bg-card border-t px-6">
|
||||
<Button onClick={saveChanges} disabled={!hasChanges || isSaving}>
|
||||
{isSaving ? <Loading /> : 'Save Changes'}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loading /> : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getOrganizationSettings } from '@/actions/organizationSettings';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
|
|
@ -15,14 +13,11 @@ import {
|
|||
import { Loading } from '@/components/layout';
|
||||
|
||||
import { ProviderForm } from './ProviderForm';
|
||||
import { useOrganizationSettings } from '@/hooks/useOrganizationSettings';
|
||||
|
||||
export const ProviderSettings = () => {
|
||||
const t = useTranslations('ProviderSettings');
|
||||
|
||||
const { data: orgSettings } = useQuery({
|
||||
queryKey: ['getOrganizationSettings'],
|
||||
queryFn: getOrganizationSettings,
|
||||
});
|
||||
const { data, isPending } = useOrganizationSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -33,11 +28,11 @@ export const ProviderSettings = () => {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{`Policy v${orgSettings?.version || 1}`}
|
||||
{`Policy v${data?.version || 1}`}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{orgSettings ? <ProviderForm orgSettings={orgSettings} /> : <Loading />}
|
||||
{isPending ? <Loading /> : <ProviderForm />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
432
src/components/__tests__/ProviderForm.test.tsx
Normal file
432
src/components/__tests__/ProviderForm.test.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
// pnpm test src/components/__tests__/ProviderForm.test.tsx
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { type OrganizationSettings } from '@/types';
|
||||
import {
|
||||
updateOrganization,
|
||||
getOrganizationSettings,
|
||||
} from '@/actions/organizationSettings';
|
||||
import { ProviderForm } from '@/app/(authenticated)/providers/ProviderForm';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/actions/organizationSettings', () => ({
|
||||
updateOrganization: vi.fn(),
|
||||
getOrganizationSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providers', () => ({
|
||||
PROVIDERS: {
|
||||
anthropic: {
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
models: ['claude-3-5-sonnet-20241022', 'claude-3-haiku-20240307'],
|
||||
},
|
||||
'openai-native': {
|
||||
id: 'openai-native',
|
||||
label: 'OpenAI',
|
||||
models: ['gpt-4o', 'gpt-4o-mini'],
|
||||
},
|
||||
gemini: {
|
||||
id: 'gemini',
|
||||
label: 'Google Gemini',
|
||||
models: ['gemini-1.5-pro', 'gemini-1.5-flash'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDynamicRouterModels', () => ({
|
||||
useDynamicRouterModels: () => ({ data: undefined }),
|
||||
isDynamicRouter: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useAvailableProviders', () => ({
|
||||
useAvailableProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateOrganization = vi.mocked(updateOrganization);
|
||||
const mockGetOrganizationSettings = vi.mocked(getOrganizationSettings);
|
||||
const mockToast = vi.mocked(toast);
|
||||
const { useAvailableProviders } = await import('@/hooks/useAvailableProviders');
|
||||
const mockUseAvailableProviders = vi.mocked(useAvailableProviders);
|
||||
|
||||
const mockAvailableProviders = [
|
||||
{
|
||||
id: 'anthropic' as const,
|
||||
label: 'Anthropic',
|
||||
models: ['claude-3-5-sonnet-20241022', 'claude-3-haiku-20240307'],
|
||||
},
|
||||
{
|
||||
id: 'openai-native' as const,
|
||||
label: 'OpenAI',
|
||||
models: ['gpt-4o', 'gpt-4o-mini'],
|
||||
},
|
||||
{
|
||||
id: 'gemini' as const,
|
||||
label: 'Google Gemini',
|
||||
models: ['gemini-1.5-pro', 'gemini-1.5-flash'],
|
||||
},
|
||||
];
|
||||
|
||||
const createMockOrgSettings = (
|
||||
allowList: OrganizationSettings['allowList'],
|
||||
): OrganizationSettings => ({
|
||||
version: 1,
|
||||
cloudSettings: {},
|
||||
defaultSettings: {},
|
||||
allowList,
|
||||
});
|
||||
|
||||
const renderWithQueryClient = (component: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const setupMockOrganizationSettings = (
|
||||
allowList: OrganizationSettings['allowList'],
|
||||
) => {
|
||||
const orgSettings = createMockOrgSettings(allowList);
|
||||
mockGetOrganizationSettings.mockResolvedValue(orgSettings);
|
||||
|
||||
// Ensure providers object is always defined to avoid controlled/uncontrolled warnings
|
||||
const normalizedAllowList = {
|
||||
...allowList,
|
||||
providers: allowList.providers || {},
|
||||
};
|
||||
mockUseAvailableProviders.mockReturnValue({
|
||||
availableProviders: mockAvailableProviders,
|
||||
allowList: normalizedAllowList,
|
||||
});
|
||||
};
|
||||
|
||||
const getSwitch = (name: string | RegExp) =>
|
||||
screen.getByRole('switch', { name });
|
||||
const getSaveButton = () =>
|
||||
screen.getByRole('button', { name: /save changes/i });
|
||||
const querySaveButton = () =>
|
||||
screen.queryByRole('button', { name: /save changes/i });
|
||||
|
||||
const waitForSwitch = async (name: string | RegExp, checked: boolean) => {
|
||||
await waitFor(() => {
|
||||
const switchElement = getSwitch(name);
|
||||
if (checked) {
|
||||
expect(switchElement).toBeChecked();
|
||||
} else {
|
||||
expect(switchElement).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const expectSaveButtonVisible = () => {
|
||||
const saveButton = getSaveButton();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
};
|
||||
|
||||
describe('ProviderForm', () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateOrganization.mockResolvedValue({ success: true });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should render with allowAll enabled when allowList.allowAll is true', async () => {
|
||||
setupMockOrganizationSettings({ allowAll: true, providers: {} });
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/enable all providers/i, true);
|
||||
|
||||
const anthropicRow = screen.getByText('Anthropic').closest('tr');
|
||||
const openaiRow = screen.getByText('OpenAI').closest('tr');
|
||||
expect(anthropicRow).toHaveClass('hidden');
|
||||
expect(openaiRow).toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('should render with specific providers enabled when allowAll is false', async () => {
|
||||
setupMockOrganizationSettings({
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: {
|
||||
allowAll: false,
|
||||
models: ['claude-3-5-sonnet-20241022'],
|
||||
},
|
||||
'openai-native': { allowAll: false, models: ['gpt-4o'] },
|
||||
},
|
||||
});
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/enable all providers/i, false);
|
||||
|
||||
expect(screen.getByText('Anthropic')).toBeVisible();
|
||||
expect(screen.getByText('OpenAI')).toBeVisible();
|
||||
expect(screen.getByText('Google Gemini')).toBeVisible();
|
||||
|
||||
await waitForSwitch(/anthropic/i, true);
|
||||
await waitForSwitch(/openai/i, true);
|
||||
await waitForSwitch(/google gemini/i, false);
|
||||
});
|
||||
|
||||
it('should show save button when form has initial state', async () => {
|
||||
setupMockOrganizationSettings({ allowAll: false, providers: {} });
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/enable all providers/i, false);
|
||||
expectSaveButtonVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form interactions', () => {
|
||||
const setupBasicForm = () => {
|
||||
setupMockOrganizationSettings({ allowAll: false, providers: {} });
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
};
|
||||
|
||||
it('should show save button when allowAll is toggled', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupBasicForm();
|
||||
|
||||
await waitForSwitch(/enable all providers/i, false);
|
||||
await user.click(getSwitch(/enable all providers/i));
|
||||
expectSaveButtonVisible();
|
||||
});
|
||||
|
||||
it('should show save button when provider is toggled', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupBasicForm();
|
||||
|
||||
await waitForSwitch(/anthropic/i, false);
|
||||
await user.click(getSwitch(/anthropic/i));
|
||||
expectSaveButtonVisible();
|
||||
});
|
||||
|
||||
it('should hide provider controls when allowAll is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupMockOrganizationSettings({
|
||||
allowAll: false,
|
||||
providers: { anthropic: { allowAll: false, models: [] } },
|
||||
});
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Anthropic')).toBeVisible());
|
||||
await user.click(getSwitch(/enable all providers/i));
|
||||
|
||||
const anthropicRow = screen.getByText('Anthropic').closest('tr');
|
||||
expect(anthropicRow).toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('should show model selection when provider is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupBasicForm();
|
||||
|
||||
await waitForSwitch(/anthropic/i, false);
|
||||
await user.click(getSwitch(/anthropic/i));
|
||||
|
||||
const anthropicRow = screen.getByText('Anthropic').closest('tr');
|
||||
const enableAllModelsLabel = within(anthropicRow!).getByText(
|
||||
'Enable All Models',
|
||||
);
|
||||
expect(enableAllModelsLabel).toBeVisible();
|
||||
});
|
||||
|
||||
it('should handle All button click for models', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupMockOrganizationSettings({
|
||||
allowAll: false,
|
||||
providers: { anthropic: { allowAll: false, models: [] } },
|
||||
});
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/anthropic/i, true);
|
||||
|
||||
const enableAllModelsSwitches = screen.getAllByRole('switch', {
|
||||
name: /enable all models/i,
|
||||
});
|
||||
await user.click(enableAllModelsSwitches[0]!);
|
||||
expectSaveButtonVisible();
|
||||
});
|
||||
|
||||
it('should handle None button click for models', async () => {
|
||||
setupMockOrganizationSettings({
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: {
|
||||
allowAll: false,
|
||||
models: ['claude-3-5-sonnet-20241022'],
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/anthropic/i, true);
|
||||
expectSaveButtonVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
const setupFormAndSubmit = async (
|
||||
switchName: string | RegExp,
|
||||
expectedPayload: OrganizationSettings['allowList'],
|
||||
) => {
|
||||
const user = userEvent.setup();
|
||||
setupMockOrganizationSettings({ allowAll: false, providers: {} });
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(switchName, false);
|
||||
await user.click(getSwitch(switchName));
|
||||
await user.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateOrganization).toHaveBeenCalledWith({
|
||||
allowList: expectedPayload,
|
||||
});
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Changes will be deployed within 30 seconds.',
|
||||
);
|
||||
};
|
||||
|
||||
it('should save allowAll configuration successfully', async () => {
|
||||
await setupFormAndSubmit(/enable all providers/i, {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should save specific provider configuration successfully', async () => {
|
||||
await setupFormAndSubmit(/anthropic/i, {
|
||||
allowAll: false,
|
||||
providers: { anthropic: { allowAll: true, models: [] } },
|
||||
});
|
||||
});
|
||||
|
||||
const testErrorHandling = async (mockSetup: () => void) => {
|
||||
const user = userEvent.setup();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
setupMockOrganizationSettings({ allowAll: false, providers: {} });
|
||||
mockSetup();
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/enable all providers/i, false);
|
||||
await user.click(getSwitch(/enable all providers/i));
|
||||
await user.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to save provider settings.',
|
||||
);
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
};
|
||||
|
||||
it('should handle save error gracefully', async () => {
|
||||
await testErrorHandling(() => {
|
||||
mockUpdateOrganization.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Network error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle save exception gracefully', async () => {
|
||||
await testErrorHandling(() => {
|
||||
mockUpdateOrganization.mockRejectedValue(new Error('Network error'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable form controls while saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupMockOrganizationSettings({ allowAll: false, providers: {} });
|
||||
|
||||
// Make the update function hang to test loading state
|
||||
mockUpdateOrganization.mockImplementation(() => new Promise(() => {}));
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/enable all providers/i, false);
|
||||
const allowAllSwitch = getSwitch(/enable all providers/i);
|
||||
await user.click(allowAllSwitch);
|
||||
|
||||
const saveButton = getSaveButton();
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(allowAllSwitch).toBeDisabled();
|
||||
// Save button might be hidden during submission, so check if it exists first
|
||||
const currentSaveButton = querySaveButton();
|
||||
if (currentSaveButton) {
|
||||
expect(currentSaveButton).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form state management', () => {
|
||||
it('should track form dirty state correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupMockOrganizationSettings({
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: {
|
||||
allowAll: false,
|
||||
models: ['claude-3-5-sonnet-20241022'],
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/enable all providers/i, false);
|
||||
expectSaveButtonVisible();
|
||||
|
||||
const allowAllSwitch = getSwitch(/enable all providers/i);
|
||||
await user.click(allowAllSwitch);
|
||||
expect(getSaveButton()).toBeVisible();
|
||||
|
||||
await user.click(allowAllSwitch);
|
||||
// After reverting, save button should still be visible due to initialization state
|
||||
expectSaveButtonVisible();
|
||||
});
|
||||
|
||||
it('should preserve provider allowAll settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupMockOrganizationSettings({
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: { allowAll: true },
|
||||
'openai-native': { allowAll: false, models: ['gpt-4o'] },
|
||||
},
|
||||
});
|
||||
renderWithQueryClient(<ProviderForm />);
|
||||
|
||||
await waitForSwitch(/anthropic/i, true);
|
||||
|
||||
await user.click(getSwitch(/google gemini/i));
|
||||
await user.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateOrganization).toHaveBeenCalledWith({
|
||||
allowList: {
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: { allowAll: true },
|
||||
'openai-native': { allowAll: false, models: ['gpt-4o'] },
|
||||
gemini: { allowAll: true, models: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export * from './button';
|
||||
export * from './multiple-selector';
|
||||
export * from './multi-select';
|
||||
export * from './tabs';
|
||||
|
|
|
|||
344
src/components/ui/ecosystem/multi-select.tsx
Normal file
344
src/components/ui/ecosystem/multi-select.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import fuzzysort from 'fuzzysort';
|
||||
import { Check, X, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Badge } from '../badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '../command';
|
||||
|
||||
/**
|
||||
* Variants for the multi-select component to handle different styles.
|
||||
* Uses class-variance-authority (cva) to define different styles based on
|
||||
* "variant" prop.
|
||||
*/
|
||||
const multiSelectVariants = cva('px-2 py-1', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-foreground/10 text-foreground bg-card hover:bg-card/80',
|
||||
secondary:
|
||||
'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
inverted: 'bg-background',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
interface MultiSelectProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof multiSelectVariants> {
|
||||
/**
|
||||
* An array of option objects to be displayed in the multi-select component.
|
||||
* Each option object has a label and value.
|
||||
*/
|
||||
options: {
|
||||
/** The text to display for the option. */
|
||||
label: string;
|
||||
/** The unique value associated with the option. */
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* Callback function triggered when the selected values change.
|
||||
* Receives an array of the new selected values.
|
||||
*/
|
||||
onValueChange: (value: string[]) => void;
|
||||
|
||||
/** The default selected values when the component mounts. */
|
||||
defaultValue?: string[];
|
||||
|
||||
/** The controlled selected values. When provided, the component becomes controlled. */
|
||||
value?: string[];
|
||||
|
||||
/**
|
||||
* Placeholder text to be displayed when no values are selected.
|
||||
* Optional, defaults to "Select options".
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* Maximum number of items to display. Extra selected items will be summarized.
|
||||
* Optional, defaults to 3.
|
||||
*/
|
||||
maxCount?: number;
|
||||
|
||||
/**
|
||||
* The modality of the popover. When set to true, interaction with outside elements
|
||||
* will be disabled and only popover content will be visible to screen readers.
|
||||
* Optional, defaults to false.
|
||||
*/
|
||||
modalPopover?: boolean;
|
||||
|
||||
/**
|
||||
* If true, renders the multi-select component as a child of another component.
|
||||
* Optional, defaults to false.
|
||||
*/
|
||||
asChild?: boolean;
|
||||
|
||||
/**
|
||||
* Additional class names to apply custom styles to the multi-select component.
|
||||
* Optional, can be used to add custom styles.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
onValueChange,
|
||||
variant,
|
||||
defaultValue = [],
|
||||
value,
|
||||
placeholder = 'Select options',
|
||||
maxCount = 3,
|
||||
modalPopover = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [internalSelectedValues, setInternalSelectedValues] =
|
||||
React.useState<string[]>(defaultValue);
|
||||
|
||||
// Use controlled value if provided, otherwise use internal state.
|
||||
const selectedValues = value !== undefined ? value : internalSelectedValues;
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
|
||||
const handleInputKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter') {
|
||||
setIsPopoverOpen(true);
|
||||
} else if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||
const newSelectedValues = [...selectedValues];
|
||||
newSelectedValues.pop();
|
||||
|
||||
if (value !== undefined) {
|
||||
// Controlled mode - only call `onValueChange`.
|
||||
onValueChange(newSelectedValues);
|
||||
} else {
|
||||
// Uncontrolled mode - update internal state and call `onValueChange`.
|
||||
setInternalSelectedValues(newSelectedValues);
|
||||
onValueChange(newSelectedValues);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOption = (option: string) => {
|
||||
const newSelectedValues = selectedValues.includes(option)
|
||||
? selectedValues.filter((value) => value !== option)
|
||||
: [...selectedValues, option];
|
||||
|
||||
if (value !== undefined) {
|
||||
// Controlled mode - only call `onValueChange`.
|
||||
onValueChange(newSelectedValues);
|
||||
} else {
|
||||
// Uncontrolled mode - update internal state and call `onValueChange`.
|
||||
setInternalSelectedValues(newSelectedValues);
|
||||
onValueChange(newSelectedValues);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePopover = () => setIsPopoverOpen((prev) => !prev);
|
||||
|
||||
const clearExtraOptions = () => {
|
||||
const newSelectedValues = selectedValues.slice(0, maxCount);
|
||||
|
||||
if (value !== undefined) {
|
||||
// Controlled mode - only call `onValueChange`.
|
||||
onValueChange(newSelectedValues);
|
||||
} else {
|
||||
// Uncontrolled mode - update internal state and call `onValueChange`.
|
||||
setInternalSelectedValues(newSelectedValues);
|
||||
onValueChange(newSelectedValues);
|
||||
}
|
||||
};
|
||||
|
||||
const searchResultsRef = React.useRef<Map<string, number>>(new Map());
|
||||
const searchValueRef = React.useRef('');
|
||||
|
||||
const onSelectAll = () => {
|
||||
// If there's no search term, use all options. Otherwise, use filtered results.
|
||||
const values =
|
||||
searchValueRef.current.trim() === ''
|
||||
? options.map((option) => option.value)
|
||||
: Array.from(searchResultsRef.current.keys());
|
||||
|
||||
let newSelectedValues: string[];
|
||||
|
||||
if (
|
||||
selectedValues.length === values.length &&
|
||||
selectedValues.sort().join(',') === values.sort().join(',')
|
||||
) {
|
||||
newSelectedValues = [];
|
||||
} else {
|
||||
newSelectedValues = values;
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
// Controlled mode - only call `onValueChange`.
|
||||
onValueChange(newSelectedValues);
|
||||
} else {
|
||||
// Uncontrolled mode - update internal state and call `onValueChange`.
|
||||
setInternalSelectedValues(newSelectedValues);
|
||||
onValueChange(newSelectedValues);
|
||||
}
|
||||
};
|
||||
|
||||
const onFilter = React.useCallback(
|
||||
(value: string, search: string) => {
|
||||
if (searchValueRef.current !== search) {
|
||||
searchValueRef.current = search;
|
||||
searchResultsRef.current.clear();
|
||||
|
||||
for (const {
|
||||
obj: { value },
|
||||
score,
|
||||
} of fuzzysort.go(search, options, {
|
||||
key: 'label',
|
||||
})) {
|
||||
searchResultsRef.current.set(value, score);
|
||||
}
|
||||
}
|
||||
|
||||
if (value === 'all') {
|
||||
return searchResultsRef.current.size > 1 ? 0.01 : 0;
|
||||
}
|
||||
|
||||
return searchResultsRef.current.get(value) ?? 0;
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
modal={modalPopover}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={handleTogglePopover}
|
||||
className={cn(
|
||||
'flex w-full rounded-sm min-h-9 h-auto items-center justify-between [&_svg]:pointer-events-auto',
|
||||
'font-medium border border-input bg-input hover:opacity-80 cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex flex-wrap items-center gap-1 p-1">
|
||||
{selectedValues.slice(0, maxCount).map((value) => (
|
||||
<Badge
|
||||
key={value}
|
||||
className={cn(multiSelectVariants({ variant }))}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div>
|
||||
{options.find((o) => o.value === value)?.label}
|
||||
</div>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleOption(value);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<X className="size-4 rounded-full p-0.5 bg-accent/5" />
|
||||
</div>
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedValues.length > maxCount && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-ring',
|
||||
multiSelectVariants({ variant }),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div>{`+ ${selectedValues.length - maxCount} more`}</div>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearExtraOptions();
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<X className="size-4 rounded-full p-0.5 bg-ring/5" />
|
||||
</div>
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between w-full mx-auto">
|
||||
<span className="text-muted-foreground mx-3">
|
||||
{placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="opacity-50 size-4 mx-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
align="start"
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
>
|
||||
<Command filter={onFilter}>
|
||||
<CommandInput placeholder="Search" onKeyDown={handleInputKeyDown} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => toggleOption(option.value)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'text-accent group-data-[selected=true]:text-accent-foreground size-4',
|
||||
{ 'opacity-0': !selectedValues.includes(option.value) },
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
<CommandItem
|
||||
key="all"
|
||||
value="all"
|
||||
onSelect={onSelectAll}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Select All</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultiSelect.displayName = 'MultiSelect';
|
||||
|
|
@ -1,636 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Badge,
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui';
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disable?: boolean;
|
||||
/** fixed option that can't be removed. */
|
||||
fixed?: boolean;
|
||||
/** Group the options by providing key. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
interface GroupOption {
|
||||
[key: string]: Option[];
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
defaultOptions?: Option[];
|
||||
/** manually controlled options */
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
/** Loading component. */
|
||||
loadingIndicator?: React.ReactNode;
|
||||
/** Empty component. */
|
||||
emptyIndicator?: React.ReactNode;
|
||||
/** Debounce time for async search. Only work with `onSearch`. */
|
||||
delay?: number;
|
||||
/**
|
||||
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
||||
* For example, when user click on the input, it will trigger the search to get initial options.
|
||||
**/
|
||||
triggerSearchOnFocus?: boolean;
|
||||
/** async search */
|
||||
onSearch?: (value: string) => Promise<Option[]>;
|
||||
/**
|
||||
* sync search. This search will not showing loadingIndicator.
|
||||
* The rest props are the same as async search.
|
||||
* i.e.: creatable, groupBy, delay.
|
||||
**/
|
||||
onSearchSync?: (value: string) => Option[];
|
||||
onChange?: (options: Option[]) => void;
|
||||
/** Limit the maximum number of selected options. */
|
||||
maxSelected?: number;
|
||||
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
|
||||
onMaxSelected?: (maxLimit: number) => void;
|
||||
/** Hide the placeholder when there are options selected. */
|
||||
hidePlaceholderWhenSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Group the options base on provided key. */
|
||||
groupBy?: string;
|
||||
className?: string;
|
||||
badgeClassName?: string;
|
||||
/**
|
||||
* First item selected is a default behavior by cmdk. That is why the default is true.
|
||||
* This is a workaround solution by add a dummy item.
|
||||
*
|
||||
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
||||
*/
|
||||
selectFirstItem?: boolean;
|
||||
/** Allow user to create option when there is no option matched. */
|
||||
creatable?: boolean;
|
||||
/** Props of `Command` */
|
||||
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
|
||||
/** Props of `CommandInput` */
|
||||
inputProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
'value' | 'placeholder' | 'disabled'
|
||||
>;
|
||||
/** hide the clear all button. */
|
||||
hideClearAllButton?: boolean;
|
||||
}
|
||||
|
||||
export interface MultipleSelectorRef {
|
||||
selectedValue: Option[];
|
||||
input: HTMLInputElement;
|
||||
focus: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function transToGroupOption(options: Option[], groupBy?: string) {
|
||||
if (options.length === 0) {
|
||||
return {};
|
||||
}
|
||||
if (!groupBy) {
|
||||
return {
|
||||
'': options,
|
||||
};
|
||||
}
|
||||
|
||||
const groupOption: GroupOption = {};
|
||||
options.forEach((option) => {
|
||||
const key = (option[groupBy] as string) || '';
|
||||
if (!groupOption[key]) {
|
||||
groupOption[key] = [];
|
||||
}
|
||||
groupOption[key].push(option);
|
||||
});
|
||||
return groupOption;
|
||||
}
|
||||
|
||||
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
|
||||
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
|
||||
|
||||
for (const [key, value] of Object.entries(cloneOption)) {
|
||||
cloneOption[key] = value.filter(
|
||||
(val) => !picked.find((p) => p.value === val.value),
|
||||
);
|
||||
}
|
||||
return cloneOption;
|
||||
}
|
||||
|
||||
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
||||
for (const [, value] of Object.entries(groupOption)) {
|
||||
if (
|
||||
value.some((option) => targetOption.find((p) => p.value === option.value))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
||||
* So we create one and copy the `Empty` implementation from `cmdk`.
|
||||
*
|
||||
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
|
||||
**/
|
||||
const CommandEmpty = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const render = useCommandState((state) => state.filtered.count === 0);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn('py-6 text-center text-sm', className)}
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CommandEmpty.displayName = 'CommandEmpty';
|
||||
|
||||
export const MultipleSelector = React.forwardRef<
|
||||
MultipleSelectorRef,
|
||||
MultipleSelectorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
defaultOptions: arrayDefaultOptions = [],
|
||||
options: arrayOptions,
|
||||
delay,
|
||||
onSearch,
|
||||
onSearchSync,
|
||||
loadingIndicator,
|
||||
emptyIndicator,
|
||||
maxSelected = Number.MAX_SAFE_INTEGER,
|
||||
onMaxSelected,
|
||||
hidePlaceholderWhenSelected,
|
||||
disabled,
|
||||
groupBy,
|
||||
className,
|
||||
badgeClassName,
|
||||
selectFirstItem = true,
|
||||
creatable = false,
|
||||
triggerSearchOnFocus = false,
|
||||
commandProps,
|
||||
inputProps,
|
||||
hideClearAllButton = false,
|
||||
}: MultipleSelectorProps,
|
||||
ref: React.Ref<MultipleSelectorRef>,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [onScrollbar, setOnScrollbar] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
selectedValue: [...selected],
|
||||
input: inputRef.current as HTMLInputElement,
|
||||
focus: () => inputRef?.current?.focus(),
|
||||
reset: () => setSelected([]),
|
||||
}),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
inputRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnselect = React.useCallback(
|
||||
(option: Option) => {
|
||||
const newOptions = selected.filter((s) => s.value !== option.value);
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
},
|
||||
[onChange, selected],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (input.value === '' && selected.length > 0) {
|
||||
const lastSelectOption = selected[selected.length - 1];
|
||||
// If last item is fixed, we should not remove it.
|
||||
if (lastSelectOption && !lastSelectOption.fixed) {
|
||||
handleUnselect(lastSelectOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is not a default behavior of the <input /> field
|
||||
if (e.key === 'Escape') {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleUnselect, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
/** If `onSearch` is provided, do not trigger options updated. */
|
||||
if (!arrayOptions || onSearch) {
|
||||
return;
|
||||
}
|
||||
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
||||
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
||||
setOptions(newOption);
|
||||
}
|
||||
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
|
||||
|
||||
useEffect(() => {
|
||||
/** sync search */
|
||||
|
||||
const doSearchSync = () => {
|
||||
const res = onSearchSync?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearchSync || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
doSearchSync();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
doSearchSync();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
/** async search */
|
||||
|
||||
const doSearch = async () => {
|
||||
setIsLoading(true);
|
||||
const res = await onSearch?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearch || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
await doSearch();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
await doSearch();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
const CreatableItem = () => {
|
||||
if (!creatable) return undefined;
|
||||
if (
|
||||
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
||||
selected.find((s) => s.value === inputValue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Item = (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(value: string) => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, { value, label: value }];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
>
|
||||
{`Create "${inputValue}"`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
// For normal creatable
|
||||
if (!onSearch && inputValue.length > 0) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
// For async search creatable. avoid showing creatable item before loading at first.
|
||||
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const EmptyItem = React.useCallback(() => {
|
||||
if (!emptyIndicator) return undefined;
|
||||
|
||||
// For async search that showing emptyIndicator
|
||||
if (onSearch && !creatable && Object.keys(options).length === 0) {
|
||||
return (
|
||||
<CommandItem value="-" disabled>
|
||||
{emptyIndicator}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||
}, [creatable, emptyIndicator, onSearch, options]);
|
||||
|
||||
const selectables = React.useMemo<GroupOption>(
|
||||
() => removePickedOption(options, selected),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
return commandProps.filter;
|
||||
}
|
||||
|
||||
if (creatable) {
|
||||
return (value: string, search: string) => {
|
||||
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
|
||||
};
|
||||
}
|
||||
// Using default filter in `cmdk`. We don't have to provide it.
|
||||
return undefined;
|
||||
}, [creatable, commandProps?.filter]);
|
||||
|
||||
return (
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
{...commandProps}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e);
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
'h-auto overflow-visible bg-transparent',
|
||||
commandProps?.className,
|
||||
)}
|
||||
shouldFilter={
|
||||
commandProps?.shouldFilter !== undefined
|
||||
? commandProps.shouldFilter
|
||||
: !onSearch
|
||||
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
||||
filter={commandFilter()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
|
||||
{
|
||||
'px-3 py-2': selected.length !== 0,
|
||||
'cursor-text': !disabled && selected.length !== 0,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-wrap gap-1">
|
||||
{selected.map((option) => {
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
|
||||
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
(disabled || option.fixed) && 'hidden',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUnselect(option);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!onScrollbar) {
|
||||
setOpen(false);
|
||||
}
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setOpen(true);
|
||||
inputProps?.onFocus?.(event);
|
||||
}}
|
||||
placeholder={
|
||||
hidePlaceholderWhenSelected && selected.length !== 0
|
||||
? ''
|
||||
: placeholder
|
||||
}
|
||||
className={cn(
|
||||
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
|
||||
{
|
||||
'w-full': hidePlaceholderWhenSelected,
|
||||
'px-3 py-2': selected.length === 0,
|
||||
'ml-1': selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(selected.filter((s) => s.fixed));
|
||||
onChange?.(selected.filter((s) => s.fixed));
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-0 h-6 w-6 p-0',
|
||||
(hideClearAllButton ||
|
||||
disabled ||
|
||||
selected.length < 1 ||
|
||||
selected.filter((s) => s.fixed).length === selected.length) &&
|
||||
'hidden',
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
<CommandList
|
||||
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
|
||||
onMouseLeave={() => {
|
||||
setOnScrollbar(false);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setOnScrollbar(true);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>{loadingIndicator}</>
|
||||
) : (
|
||||
<>
|
||||
{EmptyItem()}
|
||||
{CreatableItem()}
|
||||
{!selectFirstItem && (
|
||||
<CommandItem value="-" className="hidden" />
|
||||
)}
|
||||
{Object.entries(selectables).map(([key, dropdowns]) => (
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="h-full overflow-auto"
|
||||
>
|
||||
<>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disable}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, option];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
option.disable &&
|
||||
'cursor-default text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultipleSelector.displayName = 'MultipleSelector';
|
||||
|
|
@ -8,6 +8,7 @@ export * from './dropdown-menu';
|
|||
export * from './form';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './popover';
|
||||
export * from './separator';
|
||||
export * from './skeleton';
|
||||
export * from './slider';
|
||||
|
|
|
|||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
53
src/hooks/useAvailableProviders.ts
Normal file
53
src/hooks/useAvailableProviders.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import type { ProviderName } from '@roo-code/types';
|
||||
|
||||
import { PROVIDERS } from '@/lib/providers';
|
||||
|
||||
import { useOrganizationSettings } from './useOrganizationSettings';
|
||||
import {
|
||||
useDynamicRouterModels,
|
||||
isDynamicRouter,
|
||||
} from './useDynamicRouterModels';
|
||||
|
||||
export const useAvailableProviders = () => {
|
||||
const { data: organizationSettings } = useOrganizationSettings();
|
||||
const { data: dynamicRouterModels } = useDynamicRouterModels();
|
||||
|
||||
const allowList = organizationSettings?.allowList;
|
||||
|
||||
const availableProviders = useMemo(
|
||||
() =>
|
||||
allowList
|
||||
? Object.entries(PROVIDERS)
|
||||
.map(([id, { models, ...provider }]) => {
|
||||
const providerId = id as ProviderName;
|
||||
|
||||
if (
|
||||
isDynamicRouter(providerId) &&
|
||||
dynamicRouterModels?.[providerId]
|
||||
) {
|
||||
models = dynamicRouterModels[providerId];
|
||||
}
|
||||
|
||||
const set = new Set(models);
|
||||
|
||||
return {
|
||||
...provider,
|
||||
models: [
|
||||
...models,
|
||||
// Make sure we include models that are already persisted in the
|
||||
// database even if they are no longer available on the provider.
|
||||
...(allowList.providers[providerId]?.models ?? []).filter(
|
||||
(model) => !set.has(model),
|
||||
),
|
||||
],
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
: [],
|
||||
[allowList, dynamicRouterModels],
|
||||
);
|
||||
|
||||
return { availableProviders, allowList };
|
||||
};
|
||||
23
src/hooks/useDynamicRouterModels.ts
Normal file
23
src/hooks/useDynamicRouterModels.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKey } from '@/types';
|
||||
|
||||
import { getDynamicRouterModels } from '@/lib/server/models';
|
||||
|
||||
export const dynamicRouters = [
|
||||
'openrouter',
|
||||
'requesty',
|
||||
'unbound',
|
||||
'glama',
|
||||
] as const;
|
||||
|
||||
export type DynamicRouter = (typeof dynamicRouters)[number];
|
||||
|
||||
export const useDynamicRouterModels = () =>
|
||||
useQuery({
|
||||
queryKey: [QueryKey.GetDynamicRouterModels],
|
||||
queryFn: () => getDynamicRouterModels(),
|
||||
});
|
||||
|
||||
export const isDynamicRouter = (key: string): key is DynamicRouter =>
|
||||
dynamicRouters.includes(key as DynamicRouter);
|
||||
10
src/hooks/useOrganizationSettings.ts
Normal file
10
src/hooks/useOrganizationSettings.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKey } from '@/types';
|
||||
import { getOrganizationSettings } from '@/actions/organizationSettings';
|
||||
|
||||
export const useOrganizationSettings = () =>
|
||||
useQuery({
|
||||
queryKey: [QueryKey.GetOrganizationSettings],
|
||||
queryFn: getOrganizationSettings,
|
||||
});
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
type ProviderName,
|
||||
type ModelInfo,
|
||||
anthropicModels,
|
||||
bedrockModels,
|
||||
chutesModels,
|
||||
|
|
@ -14,38 +13,66 @@ import {
|
|||
xaiModels,
|
||||
} from '@roo-code/types';
|
||||
|
||||
export const providers: Record<
|
||||
export const PROVIDERS: Record<
|
||||
Exclude<ProviderName, 'fake-ai' | 'human-relay'>,
|
||||
{ id: ProviderName; label: string; models?: Record<string, ModelInfo> }
|
||||
{ id: ProviderName; label: string; models: string[] }
|
||||
> = {
|
||||
anthropic: { id: 'anthropic', label: 'Anthropic', models: anthropicModels },
|
||||
bedrock: { id: 'bedrock', label: 'Amazon Bedrock', models: bedrockModels },
|
||||
chutes: { id: 'chutes', label: 'Chutes AI', models: chutesModels },
|
||||
deepseek: { id: 'deepseek', label: 'DeepSeek', models: deepSeekModels },
|
||||
gemini: { id: 'gemini', label: 'Google Gemini', models: geminiModels },
|
||||
anthropic: {
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
models: Object.keys(anthropicModels),
|
||||
},
|
||||
bedrock: {
|
||||
id: 'bedrock',
|
||||
label: 'Amazon Bedrock',
|
||||
models: Object.keys(bedrockModels),
|
||||
},
|
||||
chutes: {
|
||||
id: 'chutes',
|
||||
label: 'Chutes AI',
|
||||
models: Object.keys(chutesModels),
|
||||
},
|
||||
deepseek: {
|
||||
id: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
models: Object.keys(deepSeekModels),
|
||||
},
|
||||
gemini: {
|
||||
id: 'gemini',
|
||||
label: 'Google Gemini',
|
||||
models: Object.keys(geminiModels),
|
||||
},
|
||||
'openai-native': {
|
||||
id: 'openai-native',
|
||||
label: 'OpenAI',
|
||||
models: openAiNativeModels,
|
||||
models: Object.keys(openAiNativeModels),
|
||||
},
|
||||
vertex: {
|
||||
id: 'vertex',
|
||||
label: 'GCP Vertex AI',
|
||||
models: Object.keys(vertexModels),
|
||||
},
|
||||
vertex: { id: 'vertex', label: 'GCP Vertex AI', models: vertexModels },
|
||||
'vscode-lm': {
|
||||
id: 'vscode-lm',
|
||||
label: 'VS Code LM API',
|
||||
models: vscodeLlmModels,
|
||||
models: Object.keys(vscodeLlmModels),
|
||||
},
|
||||
mistral: { id: 'mistral', label: 'Mistral', models: mistralModels },
|
||||
xai: { id: 'xai', label: 'xAI (Grok)', models: xaiModels },
|
||||
groq: { id: 'groq', label: 'Groq', models: groqModels },
|
||||
mistral: {
|
||||
id: 'mistral',
|
||||
label: 'Mistral',
|
||||
models: Object.keys(mistralModels),
|
||||
},
|
||||
xai: { id: 'xai', label: 'xAI (Grok)', models: Object.keys(xaiModels) },
|
||||
groq: { id: 'groq', label: 'Groq', models: Object.keys(groqModels) },
|
||||
|
||||
openai: { id: 'openai', label: 'OpenAI Compatible' }, // Models are manually added.
|
||||
ollama: { id: 'ollama', label: 'Ollama' }, // Models pulled locally from the Ollama server.
|
||||
lmstudio: { id: 'lmstudio', label: 'LM Studio' }, // Not sure...
|
||||
openai: { id: 'openai', label: 'OpenAI Compatible', models: [] }, // Models are manually added.
|
||||
ollama: { id: 'ollama', label: 'Ollama', models: [] }, // Models pulled locally from the Ollama server.
|
||||
lmstudio: { id: 'lmstudio', label: 'LM Studio', models: [] }, // Not sure...
|
||||
|
||||
// Models pulled from the respective APIs.
|
||||
openrouter: { id: 'openrouter', label: 'OpenRouter' },
|
||||
requesty: { id: 'requesty', label: 'Requesty' },
|
||||
litellm: { id: 'litellm', label: 'LiteLLM' },
|
||||
unbound: { id: 'unbound', label: 'Unbound' },
|
||||
glama: { id: 'glama', label: 'Glama' },
|
||||
openrouter: { id: 'openrouter', label: 'OpenRouter', models: [] },
|
||||
requesty: { id: 'requesty', label: 'Requesty', models: [] },
|
||||
litellm: { id: 'litellm', label: 'LiteLLM', models: [] },
|
||||
unbound: { id: 'unbound', label: 'Unbound', models: [] },
|
||||
glama: { id: 'glama', label: 'Glama', models: [] },
|
||||
};
|
||||
|
|
|
|||
85
src/lib/server/models.ts
Normal file
85
src/lib/server/models.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
'use server';
|
||||
|
||||
import { unstable_cache } from 'next/cache';
|
||||
import z from 'zod';
|
||||
|
||||
const fetchProviderModels = async <T>({
|
||||
url,
|
||||
schema,
|
||||
}: {
|
||||
url: string;
|
||||
schema: z.ZodSchema<T>;
|
||||
}): Promise<T> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
throw new Error('Failed to fetch provider models');
|
||||
}
|
||||
|
||||
return schema.parse(await response.json());
|
||||
};
|
||||
|
||||
const fetchOpenRouterModels = async () => {
|
||||
const result = await fetchProviderModels({
|
||||
url: 'https://openrouter.ai/api/v1/models',
|
||||
schema: z.object({
|
||||
data: z.array(z.object({ id: z.string() })),
|
||||
}),
|
||||
});
|
||||
|
||||
return result.data.map(({ id }) => id);
|
||||
};
|
||||
|
||||
const fetchRequestyModels = async () => {
|
||||
const result = await fetchProviderModels({
|
||||
url: 'https://router.requesty.ai/v1/models',
|
||||
schema: z.object({
|
||||
data: z.array(z.object({ id: z.string() })),
|
||||
}),
|
||||
});
|
||||
|
||||
return result.data.map(({ id }) => id);
|
||||
};
|
||||
|
||||
const fetchUnboundModels = async () => {
|
||||
const result = await fetchProviderModels({
|
||||
url: 'https://api.getunbound.ai/models',
|
||||
schema: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
return Object.keys(result.data);
|
||||
};
|
||||
|
||||
const fetchGlamaModels = async () => {
|
||||
const result = await fetchProviderModels({
|
||||
url: 'https://glama.ai/api/gateway/v1/models',
|
||||
schema: z.array(z.object({ id: z.string() })),
|
||||
});
|
||||
|
||||
return result.map(({ id }) => id);
|
||||
};
|
||||
|
||||
export const getDynamicRouterModels = unstable_cache(
|
||||
async () => {
|
||||
const [openRouterModels, requestyModels, unboundModels, glamaModels] =
|
||||
await Promise.all([
|
||||
fetchOpenRouterModels().catch(() => []),
|
||||
fetchRequestyModels().catch(() => []),
|
||||
fetchUnboundModels().catch(() => []),
|
||||
fetchGlamaModels().catch(() => []),
|
||||
]);
|
||||
|
||||
return {
|
||||
openrouter: openRouterModels,
|
||||
requesty: requestyModels,
|
||||
unbound: unboundModels,
|
||||
glama: glamaModels,
|
||||
};
|
||||
},
|
||||
['getDynamicRouterModels'],
|
||||
{
|
||||
revalidate: 60 * 60,
|
||||
tags: ['getDynamicRouterModels'],
|
||||
},
|
||||
);
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './api';
|
||||
export * from './auth';
|
||||
export * from './org';
|
||||
export * from './react-query';
|
||||
export * from './time-period';
|
||||
|
|
|
|||
4
src/types/react-query.ts
Normal file
4
src/types/react-query.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum QueryKey {
|
||||
GetOrganizationSettings = 'GetOrganizationSettings',
|
||||
GetDynamicRouterModels = 'GetDynamicRouterModels',
|
||||
}
|
||||
|
|
@ -9,4 +9,16 @@ failOnConsole({
|
|||
shouldFailOnWarn: true,
|
||||
});
|
||||
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue