From 2d333bbbe5975fd6a10796d56cb8f05db9a1a293 Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 5 Mar 2026 14:22:39 +0800 Subject: [PATCH] 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. --- .../authorized/credential-item.spec.tsx | 19 +++-- .../model-auth/authorized/credential-item.tsx | 82 ++++++++++--------- .../model-auth-dropdown/api-key-section.tsx | 3 + .../dropdown-content.spec.tsx | 21 +++-- .../model-auth-dropdown/dropdown-content.tsx | 17 ++-- .../model-auth-dropdown/index.spec.tsx | 9 +- .../use-activate-credential.ts | 51 ++++++++++++ web/eslint-suppressions.json | 8 -- 8 files changed, 137 insertions(+), 73 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx index d60c985b99..3e6804bf5a 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx @@ -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: () =>
, - RiDeleteBinLine: () =>
, - RiEqualizer2Line: () =>
, -})) - vi.mock('@/app/components/header/indicator', () => ({ default: () =>
, })) @@ -61,8 +55,12 @@ describe('CredentialItem', () => { render() - 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() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx index 11fb65c3e5..95f84e5908 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx @@ -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 = ({
{ selectedCredentialId === credential.credential_id && ( - + ) }
@@ -78,7 +73,7 @@ const CredentialItem = ({ }
{credential.credential_name} @@ -96,38 +91,50 @@ const CredentialItem = ({
{ !disableEdit && !credential.not_allowed_to_use && ( - - { - e.stopPropagation() - onEdit?.(credential) - }} - > - - + + { + e.stopPropagation() + onEdit?.(credential) + }} + > + + + )} + /> + {t('operation.edit', { ns: 'common' })} ) } { !disableDelete && ( - - { - if (disabled || disableDeleteWhenSelected) - return - e.stopPropagation() - onDelete?.(credential) - }} - > - + { + if (disabled || disableDeleteWhenSelected) + return + e.stopPropagation() + onDelete?.(credential) + }} + > + + )} - /> - + /> + + {disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })} + ) } @@ -139,8 +146,9 @@ const CredentialItem = ({ if (credential.not_allowed_to_use) { return ( - - {Item} + + + {t('auth.customCredentialUnavailable', { ns: 'plugin' })} ) } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx index 1b235c1e2b..79b53b2eb9 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx @@ -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({ ({ 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( { />, ) - 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', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx index c6588d1f81..526cdf1dc9 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx @@ -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({ ({ 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 }), })) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts new file mode 100644 index 0000000000..63705f799e --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts @@ -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() + + 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, + } +} diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 3ef6ce9ff6..d51661e3ce 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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