From 9b6ad19db00a47df56a9a6d56aeb1287c518542a Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 2 Jun 2025 16:00:14 -0700 Subject: [PATCH] Re-work provider settings to use `react-hook-form` and dynamic model data (#69) Co-authored-by: Matt Rubens Co-authored-by: John Richmond <5629+jr@users.noreply.github.com> --- package.json | 2 + pnpm-lock.yaml | 46 ++ .../providers/ProviderForm.tsx | 324 ++++----- .../providers/ProviderSettings.tsx | 13 +- .../__tests__/ProviderForm.test.tsx | 432 ++++++++++++ src/components/ui/ecosystem/index.ts | 2 +- src/components/ui/ecosystem/multi-select.tsx | 344 ++++++++++ .../ui/ecosystem/multiple-selector.tsx | 636 ------------------ src/components/ui/index.ts | 1 + src/components/ui/popover.tsx | 48 ++ src/hooks/useAvailableProviders.ts | 53 ++ src/hooks/useDynamicRouterModels.ts | 23 + src/hooks/useOrganizationSettings.ts | 10 + src/lib/providers.ts | 71 +- src/lib/server/models.ts | 85 +++ src/types/index.ts | 1 + src/types/react-query.ts | 4 + vitest-setup.ts | 12 + 18 files changed, 1259 insertions(+), 848 deletions(-) create mode 100644 src/components/__tests__/ProviderForm.test.tsx create mode 100644 src/components/ui/ecosystem/multi-select.tsx delete mode 100644 src/components/ui/ecosystem/multiple-selector.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/hooks/useAvailableProviders.ts create mode 100644 src/hooks/useDynamicRouterModels.ts create mode 100644 src/hooks/useOrganizationSettings.ts create mode 100644 src/lib/server/models.ts create mode 100644 src/types/react-query.ts diff --git a/package.json b/package.json index 12c8790fd9..12b629549f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d616229c5..bbcaac1794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/(authenticated)/providers/ProviderForm.tsx b/src/app/(authenticated)/providers/ProviderForm.tsx index 09d651ac05..b96c35ff09 100644 --- a/src/app/(authenticated)/providers/ProviderForm.tsx +++ b/src/app/(authenticated)/providers/ProviderForm.tsx @@ -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({ + 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(), - ), - ); - - 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()), - ); - - 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 ( -
-
- -
- -
- {fullProviderMetadata.map((provider) => ( -
-
-
{provider.label}
-
- - toggleProvider(provider.id)} +
+ + + + + + + {providers[provider.id]?.allowAll === false && + provider.models.length > 0 && ( + ({ + 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] })} + /> + )} + + + ))} + +
-
-
+ ); }; diff --git a/src/app/(authenticated)/providers/ProviderSettings.tsx b/src/app/(authenticated)/providers/ProviderSettings.tsx index a42a166cc8..a6bcba3d70 100644 --- a/src/app/(authenticated)/providers/ProviderSettings.tsx +++ b/src/app/(authenticated)/providers/ProviderSettings.tsx @@ -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 = () => { - {`Policy v${orgSettings?.version || 1}`} + {`Policy v${data?.version || 1}`} - {orgSettings ? : } + {isPending ? : } ); }; diff --git a/src/components/__tests__/ProviderForm.test.tsx b/src/components/__tests__/ProviderForm.test.tsx new file mode 100644 index 0000000000..cc59d44f27 --- /dev/null +++ b/src/components/__tests__/ProviderForm.test.tsx @@ -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( + {component}, + ); +}; + +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(); + + 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(); + + 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(); + + await waitForSwitch(/enable all providers/i, false); + expectSaveButtonVisible(); + }); + }); + + describe('form interactions', () => { + const setupBasicForm = () => { + setupMockOrganizationSettings({ allowAll: false, providers: {} }); + renderWithQueryClient(); + }; + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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: [] }, + }, + }, + }); + }); + }); + }); +}); diff --git a/src/components/ui/ecosystem/index.ts b/src/components/ui/ecosystem/index.ts index a8d117b634..0a3efae149 100644 --- a/src/components/ui/ecosystem/index.ts +++ b/src/components/ui/ecosystem/index.ts @@ -1,3 +1,3 @@ export * from './button'; -export * from './multiple-selector'; +export * from './multi-select'; export * from './tabs'; diff --git a/src/components/ui/ecosystem/multi-select.tsx b/src/components/ui/ecosystem/multi-select.tsx new file mode 100644 index 0000000000..e6ab8d0101 --- /dev/null +++ b/src/components/ui/ecosystem/multi-select.tsx @@ -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, + VariantProps { + /** + * 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( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + value, + placeholder = 'Select options', + maxCount = 3, + modalPopover = false, + className, + ...props + }, + ref, + ) => { + const [internalSelectedValues, setInternalSelectedValues] = + React.useState(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, + ) => { + 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>(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 ( + + +
+ {selectedValues.length > 0 ? ( +
+
+ {selectedValues.slice(0, maxCount).map((value) => ( + +
+
+ {options.find((o) => o.value === value)?.label} +
+
{ + event.stopPropagation(); + toggleOption(value); + }} + className="cursor-pointer" + > + +
+
+
+ ))} + {selectedValues.length > maxCount && ( + +
+
{`+ ${selectedValues.length - maxCount} more`}
+
{ + event.stopPropagation(); + clearExtraOptions(); + }} + className="cursor-pointer" + > + +
+
+
+ )} +
+
+ ) : ( +
+ + {placeholder} + + +
+ )} +
+
+ setIsPopoverOpen(false)} + > + + + + No results found. + + {options.map((option) => ( + toggleOption(option.value)} + className="flex items-center justify-between" + > + {option.label} + + + ))} + + Select All + + + + + +
+ ); + }, +); + +MultiSelect.displayName = 'MultiSelect'; diff --git a/src/components/ui/ecosystem/multiple-selector.tsx b/src/components/ui/ecosystem/multiple-selector.tsx deleted file mode 100644 index d5194b2970..0000000000 --- a/src/components/ui/ecosystem/multiple-selector.tsx +++ /dev/null @@ -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; - /** - * 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; - /** Props of `CommandInput` */ - inputProps?: Omit< - React.ComponentPropsWithoutRef, - 'value' | 'placeholder' | 'disabled' - >; - /** hide the clear all button. */ - hideClearAllButton?: boolean; -} - -export interface MultipleSelectorRef { - selectedValue: Option[]; - input: HTMLInputElement; - focus: () => void; - reset: () => void; -} - -export function useDebounce(value: T, delay?: number): T { - const [debouncedValue, setDebouncedValue] = React.useState(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 ->(({ className, ...props }, forwardedRef) => { - const render = useCommandState((state) => state.filtered.count === 0); - - if (!render) return null; - - return ( -
- ); -}); - -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, - ) => { - const inputRef = React.useRef(null); - const [open, setOpen] = React.useState(false); - const [onScrollbar, setOnScrollbar] = React.useState(false); - const [isLoading, setIsLoading] = React.useState(false); - const dropdownRef = React.useRef(null); // Added this - - const [selected, setSelected] = React.useState(value || []); - const [options, setOptions] = React.useState( - 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) => { - 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 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 = ( - { - 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}"`} - - ); - - // 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 ( - - {emptyIndicator} - - ); - } - - return {emptyIndicator}; - }, [creatable, emptyIndicator, onSearch, options]); - - const selectables = React.useMemo( - () => 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 ( - { - 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()} - > -
{ - if (disabled) return; - inputRef?.current?.focus(); - }} - > -
- {selected.map((option) => { - return ( - - {option.label} - - - ); - })} - {/* Avoid having the "Search" Icon */} - { - 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, - )} - /> - -
-
-
- {open && ( - { - setOnScrollbar(false); - }} - onMouseEnter={() => { - setOnScrollbar(true); - }} - onMouseUp={() => { - inputRef?.current?.focus(); - }} - > - {isLoading ? ( - <>{loadingIndicator} - ) : ( - <> - {EmptyItem()} - {CreatableItem()} - {!selectFirstItem && ( - - )} - {Object.entries(selectables).map(([key, dropdowns]) => ( - - <> - {dropdowns.map((option) => { - return ( - { - 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} - - ); - })} - - - ))} - - )} - - )} -
-
- ); - }, -); - -MultipleSelector.displayName = 'MultipleSelector'; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 98d21513be..0017550143 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -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'; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000000..0e285c1de4 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -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) { + return ; +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/hooks/useAvailableProviders.ts b/src/hooks/useAvailableProviders.ts new file mode 100644 index 0000000000..8b68cd9de3 --- /dev/null +++ b/src/hooks/useAvailableProviders.ts @@ -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 }; +}; diff --git a/src/hooks/useDynamicRouterModels.ts b/src/hooks/useDynamicRouterModels.ts new file mode 100644 index 0000000000..0e1c5d5329 --- /dev/null +++ b/src/hooks/useDynamicRouterModels.ts @@ -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); diff --git a/src/hooks/useOrganizationSettings.ts b/src/hooks/useOrganizationSettings.ts new file mode 100644 index 0000000000..3c0e260968 --- /dev/null +++ b/src/hooks/useOrganizationSettings.ts @@ -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, + }); diff --git a/src/lib/providers.ts b/src/lib/providers.ts index c12d5230f7..91172b351c 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -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, - { id: ProviderName; label: string; models?: Record } + { 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: [] }, }; diff --git a/src/lib/server/models.ts b/src/lib/server/models.ts new file mode 100644 index 0000000000..e200d426f3 --- /dev/null +++ b/src/lib/server/models.ts @@ -0,0 +1,85 @@ +'use server'; + +import { unstable_cache } from 'next/cache'; +import z from 'zod'; + +const fetchProviderModels = async ({ + url, + schema, +}: { + url: string; + schema: z.ZodSchema; +}): Promise => { + 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'], + }, +); diff --git a/src/types/index.ts b/src/types/index.ts index 956c3b1c6f..75c0e186f7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export * from './api'; export * from './auth'; export * from './org'; +export * from './react-query'; export * from './time-period'; diff --git a/src/types/react-query.ts b/src/types/react-query.ts new file mode 100644 index 0000000000..8f9d9c4d0f --- /dev/null +++ b/src/types/react-query.ts @@ -0,0 +1,4 @@ +export enum QueryKey { + GetOrganizationSettings = 'GetOrganizationSettings', + GetDynamicRouterModels = 'GetDynamicRouterModels', +} diff --git a/vitest-setup.ts b/vitest-setup.ts index 3f0fa19c07..40fd9518ba 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -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());