mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 21:05:48 +08:00
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:
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }),
|
||||
}))
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user