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:
yyh
2026-03-18 10:16:15 +08:00
committed by GitHub
parent aa4a9877f5
commit bbe975c6bc
319 changed files with 19582 additions and 5541 deletions

View File

@ -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))

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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}

View File

@ -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)
})

View File

@ -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,
])

View File

@ -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>
)}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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)