mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
feat: enhance model plugin workflow checks and model provider management UX (#33289)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: statxc <tyleradams93226@gmail.com>
This commit is contained in:
@ -79,6 +79,10 @@ vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateCheckInstalled: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({ data: [] }),
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
@ -218,23 +222,6 @@ vi.mock('../../plugin-auth', () => ({
|
||||
PluginAuth: () => <div data-testid="plugin-auth" />,
|
||||
}))
|
||||
|
||||
// Mock Confirm component
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onCancel, onConfirm, isLoading }: {
|
||||
isShow: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="delete-confirm">
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
@ -801,7 +788,7 @@ describe('DetailHeader', () => {
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -810,13 +797,13 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -825,10 +812,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('test-id')
|
||||
@ -840,10 +827,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||
@ -861,10 +848,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalled()
|
||||
@ -876,10 +863,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
|
||||
@ -891,10 +878,10 @@ describe('DetailHeader', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.any(Object))
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cloneElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../types'
|
||||
import OperationDropdown from '../operation-dropdown'
|
||||
@ -12,24 +14,22 @@ vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/action-button', () => ({
|
||||
default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
|
||||
<button data-testid="action-button" className={className} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children, open }: { children: ReactNode, open: boolean }) => (
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<button data-testid="dropdown-trigger" className={className}>{children}</button>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
|
||||
<div data-testid="portal-content" className={className}>{children}</div>
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children, onClick, render, destructive }: { children: ReactNode, onClick?: () => void, render?: ReactElement, destructive?: boolean }) => {
|
||||
if (render)
|
||||
return cloneElement(render, { onClick, 'data-destructive': destructive } as Record<string, unknown>, children)
|
||||
return <div data-testid="dropdown-item" data-destructive={destructive} onClick={onClick}>{children}</div>
|
||||
},
|
||||
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
|
||||
}))
|
||||
|
||||
describe('OperationDropdown', () => {
|
||||
@ -52,14 +52,13 @@ describe('OperationDropdown', () => {
|
||||
it('should render trigger button', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('action-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dropdown content', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render info option for github source', () => {
|
||||
@ -118,14 +117,10 @@ describe('OperationDropdown', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle dropdown when trigger is clicked', () => {
|
||||
it('should render dropdown menu root', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// The portal-elem should reflect the open state
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onInfo when info option is clicked', () => {
|
||||
@ -174,7 +169,7 @@ describe('OperationDropdown', () => {
|
||||
const { unmount } = render(
|
||||
<OperationDropdown {...defaultProps} source={source} />,
|
||||
)
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
@ -199,9 +194,7 @@ describe('OperationDropdown', () => {
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Verify the component is exported as a memo component
|
||||
expect(OperationDropdown).toBeDefined()
|
||||
// React.memo wraps the component, so it should have $$typeof
|
||||
expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,24 +9,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, title, onCancel, onConfirm, isLoading }: {
|
||||
isShow: boolean
|
||||
title: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="delete-confirm">
|
||||
<div data-testid="delete-title">{title}</div>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({
|
||||
default: ({ repository, release, packageName, onHide }: {
|
||||
repository: string
|
||||
@ -230,7 +212,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete confirm when isShowDeleteConfirm is true', () => {
|
||||
@ -247,7 +229,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correct delete title', () => {
|
||||
@ -264,7 +246,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-title')).toHaveTextContent('plugin.action.delete')
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('plugin.action.delete')
|
||||
})
|
||||
|
||||
it('should call hideDeleteConfirm when cancel is clicked', () => {
|
||||
@ -281,7 +263,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||
})
|
||||
@ -300,7 +282,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockOnDelete).toHaveBeenCalled()
|
||||
})
|
||||
@ -319,7 +301,7 @@ describe('HeaderModals', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.confirm/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -485,7 +467,7 @@ describe('HeaderModals', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,7 +4,15 @@ import type { FC } from 'react'
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from '../hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@ -50,7 +58,6 @@ const HeaderModals: FC<HeaderModalsProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Plugin Info Modal */}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
@ -60,27 +67,35 @@ const HeaderModals: FC<HeaderModalsProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm Modal */}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
content={(
|
||||
<div>
|
||||
<AlertDialog
|
||||
open={isShowDeleteConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
hideDeleteConfirm()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
|
||||
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
|
||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||
<span className="system-md-semibold">{label[locale]}</span>
|
||||
<span className="text-text-secondary system-md-semibold">{label[locale]}</span>
|
||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={onDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={deleting}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={onDelete}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Update from Marketplace Modal */}
|
||||
{isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={detail.plugin_id}
|
||||
|
||||
@ -15,6 +15,7 @@ type VersionPickerMock = {
|
||||
const {
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockRefreshModelProviders,
|
||||
mockInvalidateCheckInstalled,
|
||||
mockInvalidateAllToolProviders,
|
||||
mockUninstallPlugin,
|
||||
mockFetchReleases,
|
||||
@ -23,6 +24,7 @@ const {
|
||||
return {
|
||||
mockSetShowUpdatePluginModal: vi.fn(),
|
||||
mockRefreshModelProviders: vi.fn(),
|
||||
mockInvalidateCheckInstalled: vi.fn(),
|
||||
mockInvalidateAllToolProviders: vi.fn(),
|
||||
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
|
||||
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
|
||||
@ -46,6 +48,10 @@ vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateCheckInstalled: () => mockInvalidateCheckInstalled,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
}))
|
||||
@ -178,6 +184,7 @@ describe('usePluginOperations', () => {
|
||||
result.current.handleUpdatedFromMarketplace()
|
||||
})
|
||||
|
||||
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
@ -251,6 +258,32 @@ describe('usePluginOperations', () => {
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invalidate checkInstalled when GitHub update save callback fires', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
const firstCall = mockSetShowUpdatePluginModal.mock.calls.at(0)?.[0]
|
||||
firstCall?.onSaveCallback()
|
||||
|
||||
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when no releases found', async () => {
|
||||
mockFetchReleases.mockResolvedValueOnce([])
|
||||
const detail = createPluginDetail({
|
||||
@ -388,6 +421,7 @@ describe('usePluginOperations', () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useInvalidateCheckInstalled } from '@/service/use-plugins'
|
||||
import { useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import { useGitHubReleases } from '../../../install-plugin/hooks'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../../types'
|
||||
@ -36,13 +38,19 @@ export const usePluginOperations = ({
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
|
||||
const { t } = useTranslation()
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateCheckInstalled = useInvalidateCheckInstalled()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
|
||||
const { id, meta, plugin_id } = detail
|
||||
const { author, category, name } = detail.declaration || detail
|
||||
const handlePluginUpdated = useCallback((isDelete?: boolean) => {
|
||||
invalidateCheckInstalled()
|
||||
onUpdate?.(isDelete)
|
||||
}, [invalidateCheckInstalled, onUpdate])
|
||||
|
||||
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
@ -71,7 +79,7 @@ export const usePluginOperations = ({
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
handlePluginUpdated()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
@ -97,15 +105,15 @@ export const usePluginOperations = ({
|
||||
checkForUpdates,
|
||||
setShowUpdatePluginModal,
|
||||
detail,
|
||||
onUpdate,
|
||||
handlePluginUpdated,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
])
|
||||
|
||||
const handleUpdatedFromMarketplace = useCallback(() => {
|
||||
onUpdate?.()
|
||||
handlePluginUpdated()
|
||||
modalStates.hideUpdateModal()
|
||||
}, [onUpdate, modalStates])
|
||||
}, [handlePluginUpdated, modalStates])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
modalStates.showDeleting()
|
||||
@ -114,7 +122,11 @@ export const usePluginOperations = ({
|
||||
|
||||
if (res.success) {
|
||||
modalStates.hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('action.deleteSuccess', { ns: 'plugin' }),
|
||||
})
|
||||
handlePluginUpdated(true)
|
||||
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
@ -130,7 +142,7 @@ export const usePluginOperations = ({
|
||||
plugin_id,
|
||||
name,
|
||||
modalStates,
|
||||
onUpdate,
|
||||
handlePluginUpdated,
|
||||
refreshModelProviders,
|
||||
invalidateAllToolProviders,
|
||||
])
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../types'
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
@ -180,7 +176,7 @@ const DetailHeader = ({
|
||||
text={(
|
||||
<>
|
||||
<div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
{isFromMarketplace && !isReadmeView && <span aria-hidden className="i-ri-arrow-left-right-line ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
</>
|
||||
)}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
@ -191,25 +187,43 @@ const DetailHeader = ({
|
||||
|
||||
{/* Auto Update Badge */}
|
||||
{isAutoUpgradeEnabled && !isReadmeView && (
|
||||
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
||||
<div>
|
||||
<Badge className="mr-1 cursor-pointer px-1">
|
||||
<AutoUpdateLine className="size-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
render={(
|
||||
<div>
|
||||
<Badge className="mr-1 cursor-pointer px-1">
|
||||
<AutoUpdateLine className="size-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Update Button */}
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={handleTriggerLatestUpdate}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={300}
|
||||
render={(
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={handleTriggerLatestUpdate}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('detailPanel.operation.updateTooltip', { ns: 'plugin' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -237,7 +251,7 @@ const DetailHeader = ({
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -9,40 +9,6 @@ import ModelParameterModal from '../index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Mock shared state for portal
|
||||
let mockPortalOpenState = false
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
|
||||
mockPortalOpenState = open || false
|
||||
return (
|
||||
<div data-testid="portal-elem" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick: () => void, className?: string }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
if (!mockPortalOpenState)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="portal-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock provider context
|
||||
const mockProviderContextValue = {
|
||||
isAPIKeySet: true,
|
||||
@ -87,6 +53,8 @@ vi.mock('@/utils/completion-params', () => ({
|
||||
fetchAndMergeValidCompletionParams: (...args: unknown[]) => mockFetchAndMergeValidCompletionParams(...args),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.spyOn(Toast, 'notify')
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, modelList, scopeFeatures, onSelect }: {
|
||||
@ -108,30 +76,33 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger', () => ({
|
||||
default: ({ disabled, hasDeprecated, modelDisabled, currentProvider, currentModel, providerName, modelId, isInWorkflow }: {
|
||||
disabled?: boolean
|
||||
hasDeprecated?: boolean
|
||||
modelDisabled?: boolean
|
||||
default: ({ currentProvider, currentModel, providerName, modelId, isInWorkflow }: {
|
||||
currentProvider?: Model
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
isInWorkflow?: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid="trigger"
|
||||
data-disabled={disabled}
|
||||
data-has-deprecated={hasDeprecated}
|
||||
data-model-disabled={modelDisabled}
|
||||
data-provider={providerName}
|
||||
data-model={modelId}
|
||||
data-in-workflow={isInWorkflow}
|
||||
data-has-current-provider={!!currentProvider}
|
||||
data-has-current-model={!!currentModel}
|
||||
>
|
||||
Trigger
|
||||
</div>
|
||||
),
|
||||
}) => {
|
||||
const hasDeprecated = !currentProvider || !currentModel
|
||||
const modelDisabled = currentModel?.status !== ModelStatusEnum.active
|
||||
const disabled = !mockProviderContextValue.isAPIKeySet || hasDeprecated || modelDisabled
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="trigger"
|
||||
data-disabled={disabled}
|
||||
data-has-deprecated={hasDeprecated}
|
||||
data-model-disabled={modelDisabled}
|
||||
data-provider={providerName}
|
||||
data-model={modelId}
|
||||
data-in-workflow={isInWorkflow}
|
||||
data-has-current-provider={!!currentProvider}
|
||||
data-has-current-model={!!currentModel}
|
||||
>
|
||||
Trigger
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger', () => ({
|
||||
@ -273,7 +244,7 @@ const setupModelLists = (config: {
|
||||
describe('ModelParameterModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockToastNotify.mockReturnValue({})
|
||||
mockProviderContextValue.isAPIKeySet = true
|
||||
mockProviderContextValue.modelProviders = []
|
||||
setupModelLists()
|
||||
@ -356,7 +327,7 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model selector inside portal content when open', async () => {
|
||||
@ -365,13 +336,12 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -405,12 +375,11 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(content.querySelector('.custom-popup-class')).toBeInTheDocument()
|
||||
expect(document.querySelector('.custom-popup-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -422,7 +391,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
const selector = screen.getByTestId('model-selector')
|
||||
@ -438,13 +407,13 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -454,15 +423,15 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<ModelParameterModal {...props} />)
|
||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Force a re-render to ensure state is stable
|
||||
rerender(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert - open state should remain false due to readonly
|
||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -474,7 +443,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -489,7 +458,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -512,7 +481,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -530,7 +499,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -547,7 +516,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -564,7 +533,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -581,7 +550,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -598,7 +567,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -615,7 +584,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -632,7 +601,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -831,7 +800,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -856,7 +825,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -888,7 +857,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -915,7 +884,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId('model-selector'))
|
||||
@ -951,7 +920,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId('llm-params-panel')
|
||||
@ -988,7 +957,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId('tts-params-panel')
|
||||
@ -1025,7 +994,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1051,7 +1020,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1077,7 +1046,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1104,12 +1073,11 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(content.querySelector('.bg-divider-subtle')).toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-divider-subtle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1146,7 +1114,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1185,7 +1153,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1264,7 +1232,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
@ -1280,7 +1248,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert - defaultModel is created with undefined provider
|
||||
await waitFor(() => {
|
||||
@ -1297,7 +1265,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert - defaultModel is created with undefined model
|
||||
await waitFor(() => {
|
||||
@ -1314,7 +1282,7 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
// Assert - when defaultModel is undefined, attribute is not set (returns null)
|
||||
await waitFor(() => {
|
||||
@ -1350,14 +1318,13 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<ModelParameterModal {...props} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model-list-count', '1')
|
||||
})
|
||||
|
||||
// Rerender with different scope
|
||||
mockPortalOpenState = true
|
||||
rerender(<ModelParameterModal {...props} scope={ModelTypeEnum.textEmbedding} />)
|
||||
|
||||
// Assert
|
||||
@ -1398,7 +1365,7 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...props} />)
|
||||
|
||||
// Assert
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('trigger')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -10,12 +10,12 @@ import type {
|
||||
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
useModelList,
|
||||
@ -114,15 +114,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
}, [scopedModelList, value?.provider, value?.model])
|
||||
|
||||
const hasDeprecated = useMemo(() => {
|
||||
return !currentProvider || !currentModel
|
||||
}, [currentModel, currentProvider])
|
||||
const modelDisabled = useMemo(() => {
|
||||
return currentModel?.status !== ModelStatusEnum.active
|
||||
}, [currentModel?.status])
|
||||
const disabled = useMemo(() => {
|
||||
return !isAPIKeySet || hasDeprecated || modelDisabled
|
||||
}, [hasDeprecated, isAPIKeySet, modelDisabled])
|
||||
const hasDeprecated = !currentProvider || !currentModel
|
||||
const disabled = !isAPIKeySet || hasDeprecated || currentModel?.status !== ModelStatusEnum.active
|
||||
|
||||
const handleChangeModel = async ({ provider, model }: DefaultModel) => {
|
||||
const targetProvider = scopedModelList.find(modelItem => modelItem.provider === provider)
|
||||
@ -187,99 +180,95 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
offset={4}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(newOpen)
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className="block"
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-none bg-transparent p-0 text-left [color:inherit] [font:inherit]">
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: value?.provider,
|
||||
modelId: value?.model,
|
||||
})
|
||||
: (isAgentStrategy
|
||||
? (
|
||||
<AgentModelTrigger
|
||||
disabled={disabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={value?.provider}
|
||||
modelId={value?.model}
|
||||
scope={scope}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Trigger
|
||||
isInWorkflow={isInWorkflow}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={value?.provider}
|
||||
modelId={value?.model}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
sideOffset={4}
|
||||
className={portalToFollowElemContentClassName}
|
||||
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
|
||||
>
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
disabled,
|
||||
modelDisabled,
|
||||
hasDeprecated,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: value?.provider,
|
||||
modelId: value?.model,
|
||||
})
|
||||
: (isAgentStrategy
|
||||
? (
|
||||
<AgentModelTrigger
|
||||
disabled={disabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={value?.provider}
|
||||
modelId={value?.model}
|
||||
scope={scope}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Trigger
|
||||
disabled={disabled}
|
||||
isInWorkflow={isInWorkflow}
|
||||
modelDisabled={modelDisabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={value?.provider}
|
||||
modelId={value?.model}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn('z-50', portalToFollowElemContentClassName)}>
|
||||
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
|
||||
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
|
||||
<div className="relative">
|
||||
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
|
||||
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<ModelSelector
|
||||
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
|
||||
modelList={scopedModelList}
|
||||
scopeFeatures={scopeFeatures}
|
||||
onSelect={handleChangeModel}
|
||||
/>
|
||||
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
|
||||
<div className="relative">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">
|
||||
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
|
||||
<div className="my-3 h-px bg-divider-subtle" />
|
||||
)}
|
||||
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
|
||||
<LLMParamsPanel
|
||||
provider={value?.provider}
|
||||
modelId={value?.model}
|
||||
completionParams={value?.completion_params || {}}
|
||||
onCompletionParamsChange={handleLLMParamsChange}
|
||||
isAdvancedMode={isAdvancedMode}
|
||||
/>
|
||||
)}
|
||||
{currentModel?.model_type === ModelTypeEnum.tts && (
|
||||
<TTSParamsPanel
|
||||
currentModel={currentModel}
|
||||
language={value?.language}
|
||||
voice={value?.voice}
|
||||
onChange={handleTTSParamsChange}
|
||||
/>
|
||||
)}
|
||||
<ModelSelector
|
||||
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
|
||||
modelList={scopedModelList}
|
||||
scopeFeatures={scopeFeatures}
|
||||
onSelect={handleChangeModel}
|
||||
/>
|
||||
</div>
|
||||
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
|
||||
<div className="my-3 h-px bg-divider-subtle" />
|
||||
)}
|
||||
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
|
||||
<LLMParamsPanel
|
||||
provider={value?.provider}
|
||||
modelId={value?.model}
|
||||
completionParams={value?.completion_params || {}}
|
||||
onCompletionParamsChange={handleLLMParamsChange}
|
||||
isAdvancedMode={isAdvancedMode}
|
||||
/>
|
||||
)}
|
||||
{currentModel?.model_type === ModelTypeEnum.tts && (
|
||||
<TTSParamsPanel
|
||||
currentModel={currentModel}
|
||||
language={value?.language}
|
||||
voice={value?.voice}
|
||||
onChange={handleTTSParamsChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiArrowRightUpLine, RiMoreFill } from '@remixicon/react'
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
// import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PluginSource } from '../types'
|
||||
@ -21,6 +20,10 @@ type Props = {
|
||||
onCheckVersion: () => void
|
||||
onRemove: () => void
|
||||
detailUrl: string
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
popupClassName?: string
|
||||
}
|
||||
|
||||
const OperationDropdown: FC<Props> = ({
|
||||
@ -29,83 +32,52 @@ const OperationDropdown: FC<Props> = ({
|
||||
onInfo,
|
||||
onCheckVersion,
|
||||
onRemove,
|
||||
placement = 'bottom-end',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
popupClassName,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: -12,
|
||||
crossAxis: 36,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{source === PluginSource.github && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onInfo()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('detailPanel.operation.info', { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onCheckVersion()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<a href={detailUrl} target="_blank" className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">
|
||||
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
|
||||
<RiArrowRightUpLine className="h-3.5 w-3.5 shrink-0 text-text-tertiary" />
|
||||
</a>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<div className="my-1 h-px bg-divider-subtle"></div>
|
||||
)}
|
||||
<div
|
||||
onClick={() => {
|
||||
onRemove()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
>
|
||||
{t('detailPanel.operation.remove', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m', open && 'bg-state-base-hover')}
|
||||
>
|
||||
<span className="i-ri-more-fill h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName={cn('w-auto min-w-[160px]', popupClassName)}
|
||||
>
|
||||
{source === PluginSource.github && (
|
||||
<DropdownMenuItem onClick={onInfo}>
|
||||
{t('detailPanel.operation.info', { ns: 'plugin' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<DropdownMenuItem onClick={onCheckVersion}>
|
||||
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<DropdownMenuItem render={<a href={detailUrl} target="_blank" rel="noopener noreferrer" />}>
|
||||
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
|
||||
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
<DropdownMenuItem destructive onClick={onRemove}>
|
||||
{t('detailPanel.operation.remove', { ns: 'plugin' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
|
||||
Reference in New Issue
Block a user