refactor(web): extract credential activation into hook and migrate credential-item overlays

Extract credential switching logic from dropdown-content into a dedicated
useActivateCredential hook with optimistic updates and proper data flow
separation. Credential items now stay visible in the popover after clicking
(no auto-close), show cursor-pointer, and disable during activation.

Migrate credential-item from legacy Tooltip and remixicon imports to
base-ui Tooltip and CSS icon classes, pruning stale ESLint suppressions.
This commit is contained in:
yyh
2026-03-05 14:22:39 +08:00
parent 4af6788ce0
commit 2d333bbbe5
8 changed files with 137 additions and 73 deletions

View File

@ -2,12 +2,6 @@ import type { Credential } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialItem from './credential-item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: () => <div data-testid="check-icon" />,
RiDeleteBinLine: () => <div data-testid="delete-icon" />,
RiEqualizer2Line: () => <div data-testid="edit-icon" />,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
@ -61,8 +55,12 @@ describe('CredentialItem', () => {
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
const buttons = screen.getAllByRole('button')
const editButton = buttons.find(b => b.querySelector('.i-ri-equalizer-2-line'))!
const deleteButton = buttons.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(editButton)
fireEvent.click(deleteButton)
expect(onEdit).toHaveBeenCalledWith(credential)
expect(onDelete).toHaveBeenCalledWith(credential)
@ -81,7 +79,10 @@ describe('CredentialItem', () => {
/>,
)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
const deleteButton = screen.getAllByRole('button')
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(deleteButton)
expect(onDelete).not.toHaveBeenCalled()
})

View File

@ -1,9 +1,4 @@
import type { Credential } from '../../declarations'
import {
RiCheckLine,
RiDeleteBinLine,
RiEqualizer2Line,
} from '@remixicon/react'
import {
memo,
useMemo,
@ -11,7 +6,7 @@ import {
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
@ -56,7 +51,7 @@ const CredentialItem = ({
key={credential.credential_id}
className={cn(
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
(disabled || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50',
(disabled || credential.not_allowed_to_use) ? 'cursor-not-allowed opacity-50' : onItemClick && 'cursor-pointer',
)}
onClick={() => {
if (disabled || credential.not_allowed_to_use)
@ -70,7 +65,7 @@ const CredentialItem = ({
<div className="h-4 w-4">
{
selectedCredentialId === credential.credential_id && (
<RiCheckLine className="h-4 w-4 text-text-accent" />
<span className="i-ri-check-line h-4 w-4 text-text-accent" />
)
}
</div>
@ -78,7 +73,7 @@ const CredentialItem = ({
}
<Indicator className="ml-2 mr-1.5 shrink-0" />
<div
className="system-md-regular truncate text-text-secondary"
className="truncate text-text-secondary system-md-regular"
title={credential.credential_name}
>
{credential.credential_name}
@ -96,38 +91,50 @@ const CredentialItem = ({
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
{
!disableEdit && !credential.not_allowed_to_use && (
<Tooltip popupContent={t('operation.edit', { ns: 'common' })}>
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(credential)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(credential)
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<TooltipContent>{t('operation.edit', { ns: 'common' })}</TooltipContent>
</Tooltip>
)
}
{
!disableDelete && (
<Tooltip popupContent={disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}>
<ActionButton
className="hover:bg-transparent"
onClick={(e) => {
if (disabled || disableDeleteWhenSelected)
return
e.stopPropagation()
onDelete?.(credential)
}}
>
<RiDeleteBinLine className={cn(
'h-4 w-4 text-text-tertiary',
!disableDeleteWhenSelected && 'hover:text-text-destructive',
disableDeleteWhenSelected && 'opacity-50',
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
className="hover:bg-transparent"
onClick={(e) => {
if (disabled || disableDeleteWhenSelected)
return
e.stopPropagation()
onDelete?.(credential)
}}
>
<span className={cn(
'i-ri-delete-bin-line h-4 w-4 text-text-tertiary',
!disableDeleteWhenSelected && 'hover:text-text-destructive',
disableDeleteWhenSelected && 'opacity-50',
)}
/>
</ActionButton>
)}
/>
</ActionButton>
/>
<TooltipContent>
{disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)
}
@ -139,8 +146,9 @@ const CredentialItem = ({
if (credential.not_allowed_to_use) {
return (
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
{Item}
<Tooltip>
<TooltipTrigger render={Item} />
<TooltipContent>{t('auth.customCredentialUnavailable', { ns: 'plugin' })}</TooltipContent>
</Tooltip>
)
}

View File

@ -8,6 +8,7 @@ type ApiKeySectionProps = {
provider: ModelProvider
credentials: Credential[]
selectedCredentialId: string | undefined
isActivating?: boolean
onItemClick: (credential: Credential, model?: CustomModel) => void
onEdit: (credential?: Credential) => void
onDelete: (credential?: Credential) => void
@ -18,6 +19,7 @@ function ApiKeySection({
provider,
credentials,
selectedCredentialId,
isActivating,
onItemClick,
onEdit,
onDelete,
@ -62,6 +64,7 @@ function ApiKeySection({
<CredentialItem
key={credential.credential_id}
credential={credential}
disabled={isActivating}
showSelectedIcon
selectedCredentialId={selectedCredentialId}
onItemClick={onItemClick}

View File

@ -5,7 +5,7 @@ import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../
import DropdownContent from './dropdown-content'
const mockHandleOpenModal = vi.fn()
const mockHandleActiveCredential = vi.fn()
const mockActivate = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
@ -15,12 +15,19 @@ vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: 'cred-1',
isActivating: false,
activate: mockActivate,
}),
}))
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleActiveCredential: mockHandleActiveCredential,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
handleOpenModal: mockHandleOpenModal,
@ -300,7 +307,7 @@ describe('DropdownContent', () => {
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should call handleActiveCredential and close on credential item click', () => {
it('should call activate without closing on credential item click', () => {
render(
<DropdownContent
provider={createProvider()}
@ -311,12 +318,12 @@ describe('DropdownContent', () => {
/>,
)
fireEvent.click(screen.getByTestId('click-cred-1'))
fireEvent.click(screen.getByTestId('click-cred-2'))
expect(mockHandleActiveCredential).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-1' }),
expect(mockActivate).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
expect(onClose).toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('should call handleOpenModal and close on edit credential', () => {

View File

@ -17,6 +17,7 @@ import ApiKeySection from './api-key-section'
import CreditsExhaustedAlert from './credits-exhausted-alert'
import CreditsFallbackAlert from './credits-fallback-alert'
import UsagePrioritySection from './usage-priority-section'
import { useActivateCredential } from './use-activate-credential'
const EMPTY_CREDENTIALS: Credential[] = []
@ -36,25 +37,18 @@ function DropdownContent({
onClose,
}: DropdownContentProps) {
const { t } = useTranslation()
const {
current_credential_id,
available_credentials,
} = provider.custom_configuration
const { available_credentials } = provider.custom_configuration
const {
openConfirmDelete,
closeConfirmDelete,
doingAction,
handleActiveCredential,
handleConfirmDelete,
deleteCredentialId,
handleOpenModal,
} = useAuth(provider, ConfigurationMethodEnum.predefinedModel)
const handleItemClick = useCallback((credential: Credential) => {
handleActiveCredential(credential)
onClose()
}, [handleActiveCredential, onClose])
const { selectedCredentialId, isActivating, activate } = useActivateCredential(provider)
const handleEdit = useCallback((credential?: Credential) => {
handleOpenModal(credential)
@ -98,8 +92,9 @@ function DropdownContent({
<ApiKeySection
provider={provider}
credentials={available_credentials ?? EMPTY_CREDENTIALS}
selectedCredentialId={current_credential_id}
onItemClick={handleItemClick}
selectedCredentialId={selectedCredentialId}
isActivating={isActivating}
onItemClick={activate}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}

View File

@ -9,13 +9,20 @@ vi.mock('../../model-auth/hooks', () => ({
openConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
doingAction: false,
handleActiveCredential: vi.fn(),
handleConfirmDelete: vi.fn(),
deleteCredentialId: null,
handleOpenModal: vi.fn(),
}),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: undefined,
isActivating: false,
activate: vi.fn(),
}),
}))
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))

View File

@ -0,0 +1,51 @@
import type { Credential, ModelProvider } from '../../declarations'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useActiveProviderCredential } from '@/service/use-models'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../../hooks'
export function useActivateCredential(provider: ModelProvider) {
const { t } = useTranslation()
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const { mutate, isPending } = useActiveProviderCredential(provider.provider)
const [optimisticId, setOptimisticId] = useState<string>()
const currentId = provider.custom_configuration.current_credential_id
const selectedCredentialId = optimisticId ?? currentId
const selectedIdRef = useRef(selectedCredentialId)
selectedIdRef.current = selectedCredentialId
const supportedModelTypes = provider.supported_model_types
const activate = useCallback((credential: Credential) => {
if (credential.credential_id === selectedIdRef.current)
return
setOptimisticId(credential.credential_id)
mutate(
{ credential_id: credential.credential_id },
{
onSuccess: () => {
Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
updateModelProviders()
supportedModelTypes.forEach(type => updateModelList(type))
},
onError: () => {
setOptimisticId(undefined)
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [mutate, t, updateModelProviders, updateModelList, supportedModelTypes])
return {
selectedCredentialId,
isActivating: isPending,
activate,
}
}

View File

@ -4705,14 +4705,6 @@
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 3