refactor(web): migrate priority change to oRPC contract with useMutation

- Add changePreferredProviderType contract in model-providers.ts
- Register in consoleRouterContract
- Replace raw async changeModelProviderPriority with useMutation
- Use Toast.notify (static API) instead of useToastContext hook
- Pass isPending as isChangingPriority to disable buttons during switch
- Add disabled prop to UsagePrioritySection
- Fix pre-existing test assertions for api-unavailable variant
- Update all specs with isChangingPriority prop and oRPC mock pattern
This commit is contained in:
yyh
2026-03-05 09:30:38 +08:00
parent dd119eb44f
commit 223b9d89c1
10 changed files with 127 additions and 79 deletions

View File

@ -1,7 +1,6 @@
import type { ModelProvider } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { changeModelProviderPriority } from '@/service/common'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
@ -9,11 +8,21 @@ import {
} from '../declarations'
import CredentialPanel from './credential-panel'
const mockEventEmitter = { emit: vi.fn() }
const mockNotify = vi.fn()
const mockUpdateModelList = vi.fn()
const mockUpdateModelProviders = vi.fn()
const mockTrialCredits = { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
const {
mockEventEmitter,
mockToastNotify,
mockUpdateModelList,
mockUpdateModelProviders,
mockTrialCredits,
mockChangePriorityFn,
} = vi.hoisted(() => ({
mockEventEmitter: { emit: vi.fn() },
mockToastNotify: vi.fn(),
mockUpdateModelList: vi.fn(),
mockUpdateModelProviders: vi.fn(),
mockTrialCredits: { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined },
mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
@ -21,15 +30,28 @@ vi.mock('@/config', async (importOriginal) => {
})
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
default: { notify: mockToastNotify },
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/common', () => ({
changeModelProviderPriority: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: { key: () => ['console', 'modelProviders', 'models'] },
changePreferredProviderType: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {
mockChangePriorityFn(...args)
return Promise.resolve({ result: 'success' })
},
...opts,
}),
},
},
},
}))
vi.mock('../hooks', () => ({
@ -58,6 +80,7 @@ vi.mock('@/app/components/header/indicator', () => ({
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
@ -92,31 +115,26 @@ describe('CredentialPanel', () => {
Object.assign(mockTrialCredits, { credits: 100, isExhausted: false, isLoading: false })
})
// Text label variants
describe('Text label variants', () => {
it('should show "AI credits in use" for credits-active variant', () => {
renderWithQueryClient(createProvider())
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show "Credits exhausted" for credits-exhausted variant', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument()
})
it('should show "No available usage" for no-usage variant', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
@ -125,7 +143,6 @@ describe('CredentialPanel', () => {
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument()
})
@ -137,18 +154,14 @@ describe('CredentialPanel', () => {
available_credentials: [],
},
}))
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
})
})
// Status label variants (dot + credential name)
describe('Status label variants', () => {
it('should show green indicator and credential name for api-fallback', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
@ -157,7 +170,6 @@ describe('CredentialPanel', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
})
@ -167,53 +179,52 @@ describe('CredentialPanel', () => {
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red')
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
})
// Destructive styling
describe('Destructive styling', () => {
it('should apply destructive container for credits-exhausted', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
const card = container.querySelector('[class*="border-state-destructive"]')
expect(card).toBeTruthy()
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply default container for credits-active', () => {
const { container } = renderWithQueryClient(createProvider())
const card = container.querySelector('[class*="bg-white"]')
expect(card).toBeTruthy()
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
})
// Priority change
describe('Priority change', () => {
it('should change priority and refresh data after success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
it('should call mutation and trigger side effects on success', async () => {
renderWithQueryClient(createProvider())
fireEvent.click(screen.getByTestId('change-priority-btn'))
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
expect(mockChangePriorityFn.mock.calls[0]?.[0]).toEqual({
params: { provider: 'test-provider' },
body: { preferred_provider_type: 'custom' },
})
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('llm')
expect(mockEventEmitter.emit).toHaveBeenCalled()
@ -221,11 +232,9 @@ describe('CredentialPanel', () => {
})
})
// ModelAuthDropdown integration
describe('ModelAuthDropdown integration', () => {
it('should pass state variant to ModelAuthDropdown', () => {
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active')
})
})

View File

@ -3,14 +3,12 @@ import type {
PreferredProviderTypeEnum,
} from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { consoleQuery } from '@/service/client'
import { changeModelProviderPriority } from '@/service/common'
import {
ConfigurationMethodEnum,
} from '../declarations'
@ -39,35 +37,42 @@ const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const state = useCredentialPanelState(provider)
const handleChangePriority = useCallback(async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
onSuccess: () => {
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: consoleQuery.modelProviders.models.key(),
refetchType: 'none',
})
updateModelProviders()
provider.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as { type: string, payload: string })
},
onError: () => {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
}),
)
const handleChangePriority = (key: PreferredProviderTypeEnum) => {
changePriority({
params: { provider: provider.provider },
body: { preferred_provider_type: key },
})
if (res.result === 'success') {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: consoleQuery.modelProviders.models.key(),
refetchType: 'none',
})
updateModelProviders()
provider.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as { type: string, payload: string })
}
}, [provider, notify, t, queryClient, updateModelProviders, updateModelList, eventEmitter])
}
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)
@ -84,6 +89,7 @@ const CredentialPanel = ({
<ModelAuthDropdown
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
/>
</SystemQuotaCard.Actions>

View File

@ -1,5 +1,5 @@
import type { CredentialPanelState } from '../use-credential-panel-state'
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { fireEvent, render, screen } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import DropdownContent from './dropdown-content'
@ -73,6 +73,7 @@ describe('DropdownContent', () => {
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -86,6 +87,7 @@ describe('DropdownContent', () => {
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -99,6 +101,7 @@ describe('DropdownContent', () => {
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -112,6 +115,7 @@ describe('DropdownContent', () => {
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -128,6 +132,7 @@ describe('DropdownContent', () => {
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -147,6 +152,7 @@ describe('DropdownContent', () => {
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -168,6 +174,7 @@ describe('DropdownContent', () => {
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
@ -187,6 +194,7 @@ describe('DropdownContent', () => {
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,

View File

@ -21,6 +21,7 @@ import UsagePrioritySection from './usage-priority-section'
type DropdownContentProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
onClose: () => void
}
@ -28,6 +29,7 @@ type DropdownContentProps = {
function DropdownContent({
provider,
state,
isChangingPriority,
onChangePriority,
onClose,
}: DropdownContentProps) {
@ -81,6 +83,7 @@ function DropdownContent({
{state.showPrioritySwitcher && (
<UsagePrioritySection
value={state.priority}
disabled={isChangingPriority}
onSelect={onChangePriority}
/>
)}

View File

@ -52,6 +52,7 @@ describe('ModelAuthDropdown', () => {
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: false, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
@ -64,6 +65,7 @@ describe('ModelAuthDropdown', () => {
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
@ -76,6 +78,7 @@ describe('ModelAuthDropdown', () => {
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
@ -89,6 +92,7 @@ describe('ModelAuthDropdown', () => {
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)

View File

@ -13,6 +13,7 @@ import DropdownContent from './dropdown-content'
type ModelAuthDropdownProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
}
@ -38,7 +39,7 @@ function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key:
return { text, variant: 'secondary' as const }
}
function ModelAuthDropdown({ provider, state, onChangePriority }: ModelAuthDropdownProps) {
function ModelAuthDropdown({ provider, state, isChangingPriority, onChangePriority }: ModelAuthDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -67,6 +68,7 @@ function ModelAuthDropdown({ provider, state, onChangePriority }: ModelAuthDropd
<DropdownContent
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={onChangePriority}
onClose={handleClose}
/>

View File

@ -5,6 +5,7 @@ import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
value: UsagePriority
disabled?: boolean
onSelect: (key: PreferredProviderTypeEnum) => void
}
@ -13,7 +14,7 @@ const options = [
{ key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' },
] as const
export default function UsagePrioritySection({ value, onSelect }: UsagePrioritySectionProps) {
export default function UsagePrioritySection({ value, disabled, onSelect }: UsagePrioritySectionProps) {
const { t } = useTranslation()
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
@ -37,11 +38,12 @@ export default function UsagePrioritySection({ value, onSelect }: UsagePriorityS
key={option.key}
type="button"
className={cn(
'shrink-0 whitespace-nowrap rounded-md px-2 py-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-button-primary-border',
'shrink-0 whitespace-nowrap rounded-md px-2 py-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-button-primary-border disabled:opacity-50',
selectedKey === option.key
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg text-text-primary shadow-xs system-xs-medium'
: 'border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary system-xs-regular hover:bg-components-option-card-option-bg-hover',
)}
disabled={disabled}
onClick={() => onSelect(option.key)}
>
{t(option.labelKey, { ns: 'common' })}

View File

@ -117,7 +117,7 @@ describe('useCredentialPanelState', () => {
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-unavailable')
expect(result.current.variant).toBe('api-required-configure')
})
it('should return api-required-add when no credentials exist', () => {
@ -159,12 +159,9 @@ describe('useCredentialPanelState', () => {
expect(result.current.showPrioritySwitcher).toBe(true)
})
it('should hide priority switcher when custom config not active', () => {
it('should hide priority switcher when system config disabled', () => {
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))

View File

@ -1,4 +1,5 @@
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelItem, PreferredProviderTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonResponse } from '@/models/common'
import { type } from '@orpc/contract'
import { base } from '../base'
@ -15,3 +16,18 @@ export const modelProvidersModelsContract = base
.output(type<{
data: ModelItem[]
}>())
export const changePreferredProviderTypeContract = base
.route({
path: '/workspaces/current/model-providers/{provider}/preferred-provider-type',
method: 'POST',
})
.input(type<{
params: {
provider: string
}
body: {
preferred_provider_type: PreferredProviderTypeEnum
}
}>())
.output(type<CommonResponse>())

View File

@ -12,7 +12,7 @@ import {
exploreInstalledAppsContract,
exploreInstalledAppUninstallContract,
} from './console/explore'
import { modelProvidersModelsContract } from './console/model-providers'
import { changePreferredProviderTypeContract, modelProvidersModelsContract } from './console/model-providers'
import { systemFeaturesContract } from './console/system'
import {
triggerOAuthConfigContract,
@ -66,6 +66,7 @@ export const consoleRouterContract = {
},
modelProviders: {
models: modelProvidersModelsContract,
changePreferredProviderType: changePreferredProviderTypeContract,
},
billing: {
invoices: invoicesContract,