mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 12:55:49 +08:00
refactor: restructure DetailHeader component and enhance plugin management
- Split the DetailHeader component into multiple files for better organization and maintainability. - Introduced new hooks for managing modal states and plugin operations. - Added unit tests for the new components and hooks to ensure functionality. - Improved code readability by removing unused imports and optimizing component structure. - Enhanced the handling of plugin version updates and source badges.
This commit is contained in:
@ -1,416 +1,2 @@
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiBugLine,
|
||||
RiCloseLine,
|
||||
RiHardDrive3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { Github } from '@/app/components/base/icons/src/public/common'
|
||||
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage, useLocale } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
|
||||
import Verified from '../base/badges/verified'
|
||||
import DeprecationNotice from '../base/deprecation-notice'
|
||||
import Icon from '../card/base/card-icon'
|
||||
import Description from '../card/base/description'
|
||||
import OrgInfo from '../card/base/org-info'
|
||||
import Title from '../card/base/title'
|
||||
import { useGitHubReleases } from '../install-plugin/hooks'
|
||||
import useReferenceSetting from '../plugin-page/use-reference-setting'
|
||||
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
|
||||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
|
||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
||||
|
||||
const i18nPrefix = 'action'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
isReadmeView?: boolean
|
||||
onHide?: () => void
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
const DetailHeader = ({
|
||||
detail,
|
||||
isReadmeView = false,
|
||||
onHide,
|
||||
onUpdate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
|
||||
const { theme } = useTheme()
|
||||
const locale = useGetLanguage()
|
||||
const currentLocale = useLocale()
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const {
|
||||
id,
|
||||
source,
|
||||
tenant_id,
|
||||
version,
|
||||
latest_unique_identifier,
|
||||
latest_version,
|
||||
meta,
|
||||
plugin_id,
|
||||
status,
|
||||
deprecated_reason,
|
||||
alternative_plugin_id,
|
||||
} = detail
|
||||
|
||||
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
||||
const isTool = category === PluginCategoryEnum.tool
|
||||
const providerBriefInfo = tool?.identity
|
||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
||||
const provider = useMemo(() => {
|
||||
return collectionList.find(collection => collection.name === providerKey)
|
||||
}, [collectionList, providerKey])
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
const isFromMarketplace = source === PluginSource.marketplace
|
||||
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [targetVersion, setTargetVersion] = useState({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
const hasNewVersion = useMemo(() => {
|
||||
if (isFromMarketplace)
|
||||
return !!latest_version && latest_version !== version
|
||||
|
||||
return false
|
||||
}, [isFromMarketplace, latest_version, version])
|
||||
|
||||
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
|
||||
const iconSrc = iconFileName
|
||||
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
|
||||
: ''
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (isFromGitHub)
|
||||
return `https://github.com/${meta!.repo}`
|
||||
if (isFromMarketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
|
||||
return ''
|
||||
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
|
||||
|
||||
const [isShowUpdateModal, {
|
||||
setTrue: showUpdateModal,
|
||||
setFalse: hideUpdateModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
const isAutoUpgradeEnabled = useMemo(() => {
|
||||
if (!enable_marketplace)
|
||||
return false
|
||||
if (!autoUpgradeInfo || !isFromMarketplace)
|
||||
return false
|
||||
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
||||
return false
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
||||
return true
|
||||
return false
|
||||
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
|
||||
|
||||
const [isDowngrade, setIsDowngrade] = useState(false)
|
||||
const handleUpdate = async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
setIsDowngrade(!!isDowngrade)
|
||||
showUpdateModal()
|
||||
return
|
||||
}
|
||||
|
||||
const owner = meta!.repo.split('/')[0] || author
|
||||
const repo = meta!.repo.split('/')[1] || name
|
||||
const fetchedReleases = await fetchReleases(owner, repo)
|
||||
if (fetchedReleases.length === 0)
|
||||
return
|
||||
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version)
|
||||
Toast.notify(toastProps)
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
category: detail.declaration.category,
|
||||
github: {
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
repo: meta!.repo,
|
||||
version: meta!.version,
|
||||
package: meta!.package,
|
||||
releases: fetchedReleases,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatedFromMarketplace = () => {
|
||||
onUpdate?.()
|
||||
hideUpdateModal()
|
||||
}
|
||||
|
||||
const [isShowPluginInfo, {
|
||||
setTrue: showPluginInfo,
|
||||
setFalse: hidePluginInfo,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const [isShowDeleteConfirm, {
|
||||
setTrue: showDeleteConfirm,
|
||||
setFalse: hideDeleteConfirm,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const [deleting, {
|
||||
setTrue: showDeleting,
|
||||
setFalse: hideDeleting,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
showDeleting()
|
||||
const res = await uninstallPlugin(id)
|
||||
hideDeleting()
|
||||
if (res.success) {
|
||||
hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
if (PluginCategoryEnum.tool.includes(category))
|
||||
invalidateAllToolProviders()
|
||||
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
||||
}
|
||||
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders, plugin_id, name])
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
<div className="flex">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||
<Icon src={iconSrc} />
|
||||
</div>
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={label[locale]} />
|
||||
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
||||
{!!version && (
|
||||
<PluginVersionPicker
|
||||
disabled={!isFromMarketplace || isReadmeView}
|
||||
isShow={isShow}
|
||||
onShowChange={setIsShow}
|
||||
pluginID={plugin_id}
|
||||
currentVersion={version}
|
||||
onSelect={(state) => {
|
||||
setTargetVersion(state)
|
||||
handleUpdate(state.isDowngrade)
|
||||
}}
|
||||
trigger={(
|
||||
<Badge
|
||||
className={cn(
|
||||
'mx-1',
|
||||
isShow && 'bg-state-base-hover',
|
||||
(isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
uppercase={false}
|
||||
text={(
|
||||
<>
|
||||
<div>{isFromGitHub ? meta!.version : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
</>
|
||||
)}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Auto update info */}
|
||||
{isAutoUpgradeEnabled && !isReadmeView && (
|
||||
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
||||
{/* add a a div to fix tooltip hover not show problem */}
|
||||
<div>
|
||||
<Badge className="mr-1 cursor-pointer px-1">
|
||||
<AutoUpdateLine className="size-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={() => {
|
||||
if (isFromMarketplace) {
|
||||
setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 flex h-4 items-center justify-between">
|
||||
<div className="mt-0.5 flex items-center">
|
||||
<OrgInfo
|
||||
packageNameClassName="w-auto"
|
||||
orgName={author}
|
||||
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
||||
/>
|
||||
{!!source && (
|
||||
<>
|
||||
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
|
||||
{source === PluginSource.marketplace && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.marketplace', { ns: 'plugin' })}>
|
||||
<div><BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.github', { ns: 'plugin' })}>
|
||||
<div><Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.local && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.local', { ns: 'plugin' })}>
|
||||
<div><RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.debugging && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.debugging', { ns: 'plugin' })}>
|
||||
<div><RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isReadmeView && (
|
||||
<div className="flex gap-1">
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={showPluginInfo}
|
||||
onCheckVersion={handleUpdate}
|
||||
onRemove={showDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFromMarketplace && (
|
||||
<DeprecationNotice
|
||||
status={status}
|
||||
deprecatedReason={deprecated_reason}
|
||||
alternativePluginId={alternative_plugin_id}
|
||||
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2}></Description>}
|
||||
{
|
||||
category === PluginCategoryEnum.tool && !isReadmeView && (
|
||||
<PluginAuth
|
||||
pluginPayload={{
|
||||
provider: provider?.name || '',
|
||||
category: AuthCategory.tool,
|
||||
providerType: provider?.type || '',
|
||||
detail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
release={version}
|
||||
packageName={meta?.package || ''}
|
||||
onHide={hidePluginInfo}
|
||||
/>
|
||||
)}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
content={(
|
||||
<div>
|
||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||
<span className="system-md-semibold">{label[locale]}</span>
|
||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={handleDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={plugin_id}
|
||||
payload={{
|
||||
category: detail.declaration.category,
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
payload: detail.declaration,
|
||||
},
|
||||
targetPackageInfo: {
|
||||
id: targetVersion.unique_identifier,
|
||||
version: targetVersion.version,
|
||||
},
|
||||
}}
|
||||
onCancel={hideUpdateModal}
|
||||
onSave={handleUpdatedFromMarketplace}
|
||||
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailHeader
|
||||
// Re-export from refactored module for backward compatibility
|
||||
export { default } from './detail-header/index'
|
||||
|
||||
@ -0,0 +1,547 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from '../hooks'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../../types'
|
||||
import HeaderModals from './header-modals'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
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
|
||||
release: string
|
||||
packageName: string
|
||||
onHide: () => void
|
||||
}) => (
|
||||
<div data-testid="plugin-info">
|
||||
<div data-testid="plugin-info-repo">{repository}</div>
|
||||
<div data-testid="plugin-info-release">{release}</div>
|
||||
<div data-testid="plugin-info-package">{packageName}</div>
|
||||
<button data-testid="plugin-info-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({
|
||||
default: ({ pluginId, onSave, onCancel, isShowDowngradeWarningModal }: {
|
||||
pluginId: string
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
isShowDowngradeWarningModal: boolean
|
||||
}) => (
|
||||
<div data-testid="update-modal">
|
||||
<div data-testid="update-plugin-id">{pluginId}</div>
|
||||
<div data-testid="update-downgrade-warning">{String(isShowDowngradeWarningModal)}</div>
|
||||
<button data-testid="update-modal-save" onClick={onSave}>Save</button>
|
||||
<button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Helper to create plugin detail
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test Plugin Label' },
|
||||
description: { en_US: 'Test description' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
latest_unique_identifier: 'new-uid',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to create modal states mock
|
||||
const createModalStatesMock = (overrides: Partial<ModalStates> = {}): ModalStates => ({
|
||||
isShowUpdateModal: false,
|
||||
showUpdateModal: vi.fn<() => void>(),
|
||||
hideUpdateModal: vi.fn<() => void>(),
|
||||
isShowPluginInfo: false,
|
||||
showPluginInfo: vi.fn<() => void>(),
|
||||
hidePluginInfo: vi.fn<() => void>(),
|
||||
isShowDeleteConfirm: false,
|
||||
showDeleteConfirm: vi.fn<() => void>(),
|
||||
hideDeleteConfirm: vi.fn<() => void>(),
|
||||
deleting: false,
|
||||
showDeleting: vi.fn<() => void>(),
|
||||
hideDeleting: vi.fn<() => void>(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to create target version
|
||||
const createTargetVersion = (overrides: Partial<VersionTarget> = {}): VersionTarget => ({
|
||||
version: '2.0.0',
|
||||
unique_identifier: 'new-uid',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('HeaderModals', () => {
|
||||
let mockOnUpdatedFromMarketplace: () => void
|
||||
let mockOnDelete: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOnUpdatedFromMarketplace = vi.fn<() => void>()
|
||||
mockOnDelete = vi.fn<() => void>()
|
||||
})
|
||||
|
||||
// Tests for Plugin Info Modal
|
||||
describe('Plugin Info Modal', () => {
|
||||
it('should not render plugin info modal when isShowPluginInfo is false', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: false })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin info modal when isShowPluginInfo is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass GitHub repo to plugin info for GitHub source', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' },
|
||||
})
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('owner/repo')
|
||||
})
|
||||
|
||||
it('should pass empty string for repo for non-GitHub source', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail({ source: PluginSource.marketplace })}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should call hidePluginInfo when close button is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('plugin-info-close'))
|
||||
|
||||
expect(modalStates.hidePluginInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for Delete Confirm Modal
|
||||
describe('Delete Confirm Modal', () => {
|
||||
it('should not render delete confirm when isShowDeleteConfirm is false', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: false })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete confirm when isShowDeleteConfirm is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correct delete title', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete')
|
||||
})
|
||||
|
||||
it('should call hideDeleteConfirm when cancel is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
|
||||
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDelete when confirm is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
expect(mockOnDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable confirm button when deleting', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true, deleting: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for Update Modal
|
||||
describe('Update Modal', () => {
|
||||
it('should not render update modal when isShowUpdateModal is false', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: false })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render update modal when isShowUpdateModal is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass plugin id to update modal', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail({ plugin_id: 'my-plugin-id' })}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-plugin-id')).toHaveTextContent('my-plugin-id')
|
||||
})
|
||||
|
||||
it('should call onUpdatedFromMarketplace when save is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-modal-save'))
|
||||
|
||||
expect(mockOnUpdatedFromMarketplace).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call hideUpdateModal when cancel is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-modal-cancel'))
|
||||
|
||||
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show downgrade warning when isDowngrade and isAutoUpgradeEnabled are true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={true}
|
||||
isAutoUpgradeEnabled={true}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should not show downgrade warning when only isDowngrade is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={true}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should not show downgrade warning when only isAutoUpgradeEnabled is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={true}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for multiple modals
|
||||
describe('Multiple Modals', () => {
|
||||
it('should render multiple modals when multiple are open', () => {
|
||||
const modalStates = createModalStatesMock({
|
||||
isShowPluginInfo: true,
|
||||
isShowDeleteConfirm: true,
|
||||
isShowUpdateModal: true,
|
||||
})
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined target version values', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={{ version: undefined, unique_identifier: undefined }}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty meta for GitHub source', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: undefined,
|
||||
})
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
|
||||
expect(screen.getByTestId('plugin-info-package')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
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 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'
|
||||
import { PluginSource } from '../../../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
const i18nPrefix = 'action'
|
||||
|
||||
type HeaderModalsProps = {
|
||||
detail: PluginDetail
|
||||
modalStates: ModalStates
|
||||
targetVersion: VersionTarget
|
||||
isDowngrade: boolean
|
||||
isAutoUpgradeEnabled: boolean
|
||||
onUpdatedFromMarketplace: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
const HeaderModals: FC<HeaderModalsProps> = ({
|
||||
detail,
|
||||
modalStates,
|
||||
targetVersion,
|
||||
isDowngrade,
|
||||
isAutoUpgradeEnabled,
|
||||
onUpdatedFromMarketplace,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useGetLanguage()
|
||||
|
||||
const { source, version, meta } = detail
|
||||
const { label } = detail.declaration || detail
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
|
||||
const {
|
||||
isShowUpdateModal,
|
||||
hideUpdateModal,
|
||||
isShowPluginInfo,
|
||||
hidePluginInfo,
|
||||
isShowDeleteConfirm,
|
||||
hideDeleteConfirm,
|
||||
deleting,
|
||||
} = modalStates
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Plugin Info Modal */}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
release={version}
|
||||
packageName={meta?.package || ''}
|
||||
onHide={hidePluginInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm Modal */}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
content={(
|
||||
<div>
|
||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||
<span className="system-md-semibold">{label[locale]}</span>
|
||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={onDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Update from Marketplace Modal */}
|
||||
{isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={detail.plugin_id}
|
||||
payload={{
|
||||
category: detail.declaration.category,
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
payload: detail.declaration,
|
||||
},
|
||||
targetPackageInfo: {
|
||||
id: targetVersion.unique_identifier || '',
|
||||
version: targetVersion.version || '',
|
||||
},
|
||||
}}
|
||||
onCancel={hideUpdateModal}
|
||||
onSave={onUpdatedFromMarketplace}
|
||||
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderModals
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as HeaderModals } from './header-modals'
|
||||
export { default as PluginSourceBadge } from './plugin-source-badge'
|
||||
@ -0,0 +1,206 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../../types'
|
||||
import PluginSourceBadge from './plugin-source-badge'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('PluginSourceBadge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for rendering different source types
|
||||
describe('Source Icon Rendering', () => {
|
||||
it('should render marketplace source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace')
|
||||
})
|
||||
|
||||
it('should render github source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github')
|
||||
})
|
||||
|
||||
it('should render local source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local')
|
||||
})
|
||||
|
||||
it('should render debugging source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for separator rendering
|
||||
describe('Separator Rendering', () => {
|
||||
it('should render separator dot before marketplace badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
expect(separator?.textContent).toBe('·')
|
||||
})
|
||||
|
||||
it('should render separator dot before github badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
expect(separator?.textContent).toBe('·')
|
||||
})
|
||||
|
||||
it('should render separator dot before local badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator dot before debugging badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for tooltip content
|
||||
describe('Tooltip Content', () => {
|
||||
it('should show marketplace tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.marketplace',
|
||||
)
|
||||
})
|
||||
|
||||
it('should show github tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.github',
|
||||
)
|
||||
})
|
||||
|
||||
it('should show local tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.local',
|
||||
)
|
||||
})
|
||||
|
||||
it('should show debugging tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.debugging',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for icon element structure
|
||||
describe('Icon Element Structure', () => {
|
||||
it('should render icon inside tooltip for marketplace', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon inside tooltip for github', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon inside tooltip for local', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon inside tooltip for debugging', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for lookup table completeness
|
||||
describe('Lookup Table Coverage', () => {
|
||||
it('should handle all PluginSource enum values', () => {
|
||||
const allSources = Object.values(PluginSource)
|
||||
|
||||
allSources.forEach((source) => {
|
||||
const { container } = render(<PluginSourceBadge source={source} />)
|
||||
// Should render either tooltip or nothing
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for null config (invalid source)
|
||||
describe('Invalid Source Handling', () => {
|
||||
it('should return null for unknown source type', () => {
|
||||
// Use type assertion to test invalid source value
|
||||
const invalidSource = 'unknown_source' as PluginSource
|
||||
const { container } = render(<PluginSourceBadge source={invalidSource} />)
|
||||
|
||||
// Should render nothing (empty container)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should not render separator for invalid source', () => {
|
||||
const invalidSource = 'invalid' as PluginSource
|
||||
const { container } = render(<PluginSourceBadge source={invalidSource} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip for invalid source', () => {
|
||||
const invalidSource = '' as PluginSource
|
||||
render(<PluginSourceBadge source={invalidSource} />)
|
||||
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import {
|
||||
RiBugLine,
|
||||
RiHardDrive3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Github } from '@/app/components/base/icons/src/public/common'
|
||||
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { PluginSource } from '../../../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SourceConfig = {
|
||||
icon: ReactNode
|
||||
tipKey: string
|
||||
}
|
||||
|
||||
type PluginSourceBadgeProps = {
|
||||
source: PluginSource
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants - Lookup table for source icons
|
||||
// ============================================================================
|
||||
|
||||
const SOURCE_CONFIG_MAP: Record<PluginSource, SourceConfig | null> = {
|
||||
[PluginSource.marketplace]: {
|
||||
icon: <BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" />,
|
||||
tipKey: 'detailPanel.categoryTip.marketplace',
|
||||
},
|
||||
[PluginSource.github]: {
|
||||
icon: <Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" />,
|
||||
tipKey: 'detailPanel.categoryTip.github',
|
||||
},
|
||||
[PluginSource.local]: {
|
||||
icon: <RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" />,
|
||||
tipKey: 'detailPanel.categoryTip.local',
|
||||
},
|
||||
[PluginSource.debugging]: {
|
||||
icon: <RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" />,
|
||||
tipKey: 'detailPanel.categoryTip.debugging',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const config = SOURCE_CONFIG_MAP[source]
|
||||
if (!config)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
|
||||
<Tooltip popupContent={t(config.tipKey as never, { ns: 'plugin' })}>
|
||||
<div>{config.icon}</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginSourceBadge
|
||||
@ -0,0 +1,3 @@
|
||||
export { useDetailHeaderState } from './use-detail-header-state'
|
||||
export type { ModalStates, UseDetailHeaderStateReturn, VersionPickerState, VersionTarget } from './use-detail-header-state'
|
||||
export { usePluginOperations } from './use-plugin-operations'
|
||||
@ -0,0 +1,416 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../../types'
|
||||
import { useDetailHeaderState } from './use-detail-header-state'
|
||||
|
||||
// Mock dependencies
|
||||
let mockEnableMarketplace = true
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
|
||||
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
|
||||
}))
|
||||
|
||||
let mockAutoUpgradeInfo: {
|
||||
strategy_setting: string
|
||||
upgrade_mode: string
|
||||
include_plugins: string[]
|
||||
exclude_plugins: string[]
|
||||
upgrade_time_of_day: number
|
||||
} | null = null
|
||||
|
||||
vi.mock('../../../plugin-page/use-reference-setting', () => ({
|
||||
default: () => ({
|
||||
referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({
|
||||
AUTO_UPDATE_MODE: {
|
||||
update_all: 'update_all',
|
||||
partial: 'partial',
|
||||
exclude: 'exclude',
|
||||
},
|
||||
}))
|
||||
|
||||
// Helper to create plugin detail
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test Plugin Label' },
|
||||
description: { en_US: 'Test description' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'test-uid',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useDetailHeaderState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAutoUpgradeInfo = null
|
||||
mockEnableMarketplace = true
|
||||
})
|
||||
|
||||
// Tests for source type detection
|
||||
describe('Source Type Detection', () => {
|
||||
it('should detect marketplace source', () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isFromMarketplace).toBe(true)
|
||||
expect(result.current.isFromGitHub).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect GitHub source', () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isFromGitHub).toBe(true)
|
||||
expect(result.current.isFromMarketplace).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect local source', () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.local })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isFromGitHub).toBe(false)
|
||||
expect(result.current.isFromMarketplace).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for version state
|
||||
describe('Version State', () => {
|
||||
it('should detect new version available for marketplace plugin', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect new version when versions match', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect new version for non-marketplace source', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect new version when latest_version is empty', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '',
|
||||
source: PluginSource.marketplace,
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for version picker state
|
||||
describe('Version Picker State', () => {
|
||||
it('should initialize version picker as hidden', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.versionPicker.isShow).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle version picker visibility', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setIsShow(true)
|
||||
})
|
||||
expect(result.current.versionPicker.isShow).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setIsShow(false)
|
||||
})
|
||||
expect(result.current.versionPicker.isShow).toBe(false)
|
||||
})
|
||||
|
||||
it('should update target version', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setTargetVersion({
|
||||
version: '2.0.0',
|
||||
unique_identifier: 'new-uid',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.versionPicker.targetVersion.version).toBe('2.0.0')
|
||||
expect(result.current.versionPicker.targetVersion.unique_identifier).toBe('new-uid')
|
||||
})
|
||||
|
||||
it('should set isDowngrade when provided in target version', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setTargetVersion({
|
||||
version: '0.5.0',
|
||||
unique_identifier: 'old-uid',
|
||||
isDowngrade: true,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.versionPicker.isDowngrade).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for modal states
|
||||
describe('Modal States', () => {
|
||||
it('should initialize all modals as hidden', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
|
||||
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
|
||||
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
|
||||
expect(result.current.modalStates.deleting).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle update modal', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showUpdateModal()
|
||||
})
|
||||
expect(result.current.modalStates.isShowUpdateModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hideUpdateModal()
|
||||
})
|
||||
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle plugin info modal', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showPluginInfo()
|
||||
})
|
||||
expect(result.current.modalStates.isShowPluginInfo).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hidePluginInfo()
|
||||
})
|
||||
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle delete confirm modal', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showDeleteConfirm()
|
||||
})
|
||||
expect(result.current.modalStates.isShowDeleteConfirm).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hideDeleteConfirm()
|
||||
})
|
||||
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle deleting state', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showDeleting()
|
||||
})
|
||||
expect(result.current.modalStates.deleting).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hideDeleting()
|
||||
})
|
||||
expect(result.current.modalStates.deleting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for auto upgrade feature
|
||||
describe('Auto Upgrade Detection', () => {
|
||||
it('should disable auto upgrade when marketplace is disabled', () => {
|
||||
mockEnableMarketplace = false
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade when strategy is disabled', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'disabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable auto upgrade for update_all mode', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should enable auto upgrade for partial mode when plugin is included', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'partial',
|
||||
include_plugins: ['test-plugin'],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade for partial mode when plugin is not included', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'partial',
|
||||
include_plugins: ['other-plugin'],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable auto upgrade for exclude mode when plugin is not excluded', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'exclude',
|
||||
include_plugins: [],
|
||||
exclude_plugins: ['other-plugin'],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade for exclude mode when plugin is excluded', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'exclude',
|
||||
include_plugins: [],
|
||||
exclude_plugins: ['test-plugin'],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade for non-marketplace source', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade when no auto upgrade info', () => {
|
||||
mockAutoUpgradeInfo = null
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useReferenceSetting from '../../../plugin-page/use-reference-setting'
|
||||
import { AUTO_UPDATE_MODE } from '../../../reference-setting-modal/auto-update-setting/types'
|
||||
import { PluginSource } from '../../../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type VersionTarget = {
|
||||
version: string | undefined
|
||||
unique_identifier: string | undefined
|
||||
isDowngrade?: boolean
|
||||
}
|
||||
|
||||
export type ModalStates = {
|
||||
isShowUpdateModal: boolean
|
||||
showUpdateModal: () => void
|
||||
hideUpdateModal: () => void
|
||||
isShowPluginInfo: boolean
|
||||
showPluginInfo: () => void
|
||||
hidePluginInfo: () => void
|
||||
isShowDeleteConfirm: boolean
|
||||
showDeleteConfirm: () => void
|
||||
hideDeleteConfirm: () => void
|
||||
deleting: boolean
|
||||
showDeleting: () => void
|
||||
hideDeleting: () => void
|
||||
}
|
||||
|
||||
export type VersionPickerState = {
|
||||
isShow: boolean
|
||||
setIsShow: (show: boolean) => void
|
||||
targetVersion: VersionTarget
|
||||
setTargetVersion: (version: VersionTarget) => void
|
||||
isDowngrade: boolean
|
||||
setIsDowngrade: (downgrade: boolean) => void
|
||||
}
|
||||
|
||||
export type UseDetailHeaderStateReturn = {
|
||||
modalStates: ModalStates
|
||||
versionPicker: VersionPickerState
|
||||
hasNewVersion: boolean
|
||||
isAutoUpgradeEnabled: boolean
|
||||
isFromGitHub: boolean
|
||||
isFromMarketplace: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => {
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
|
||||
const {
|
||||
source,
|
||||
version,
|
||||
latest_version,
|
||||
latest_unique_identifier,
|
||||
plugin_id,
|
||||
} = detail
|
||||
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
const isFromMarketplace = source === PluginSource.marketplace
|
||||
|
||||
// Version picker state
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [targetVersion, setTargetVersion] = useState<VersionTarget>({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
const [isDowngrade, setIsDowngrade] = useState(false)
|
||||
|
||||
// Modal states
|
||||
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false)
|
||||
const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = useBoolean(false)
|
||||
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false)
|
||||
const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false)
|
||||
|
||||
// Computed values
|
||||
const hasNewVersion = useMemo(() => {
|
||||
if (isFromMarketplace)
|
||||
return !!latest_version && latest_version !== version
|
||||
return false
|
||||
}, [isFromMarketplace, latest_version, version])
|
||||
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
|
||||
const isAutoUpgradeEnabled = useMemo(() => {
|
||||
if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace)
|
||||
return false
|
||||
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
||||
return false
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
||||
return true
|
||||
return false
|
||||
}, [autoUpgradeInfo, plugin_id, isFromMarketplace, enable_marketplace])
|
||||
|
||||
// Update target version when latest version changes
|
||||
const handleSetTargetVersion = useCallback((version: VersionTarget) => {
|
||||
setTargetVersion(version)
|
||||
if (version.isDowngrade !== undefined)
|
||||
setIsDowngrade(version.isDowngrade)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
modalStates: {
|
||||
isShowUpdateModal,
|
||||
showUpdateModal,
|
||||
hideUpdateModal,
|
||||
isShowPluginInfo,
|
||||
showPluginInfo,
|
||||
hidePluginInfo,
|
||||
isShowDeleteConfirm,
|
||||
showDeleteConfirm,
|
||||
hideDeleteConfirm,
|
||||
deleting,
|
||||
showDeleting,
|
||||
hideDeleting,
|
||||
},
|
||||
versionPicker: {
|
||||
isShow,
|
||||
setIsShow,
|
||||
targetVersion,
|
||||
setTargetVersion: handleSetTargetVersion,
|
||||
isDowngrade,
|
||||
setIsDowngrade,
|
||||
},
|
||||
hasNewVersion,
|
||||
isAutoUpgradeEnabled,
|
||||
isFromGitHub,
|
||||
isFromMarketplace,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,558 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as amplitude from '@/app/components/base/amplitude'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { PluginSource } from '../../../types'
|
||||
import { usePluginOperations } from './use-plugin-operations'
|
||||
|
||||
// Type for version picker mock
|
||||
type VersionPickerMock = {
|
||||
setTargetVersion: (version: VersionTarget) => void
|
||||
setIsDowngrade: (downgrade: boolean) => void
|
||||
}
|
||||
|
||||
// Mock functions hoisted for use in vi.mock factories
|
||||
const {
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockRefreshModelProviders,
|
||||
mockInvalidateAllToolProviders,
|
||||
mockUninstallPlugin,
|
||||
mockFetchReleases,
|
||||
mockCheckForUpdates,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockSetShowUpdatePluginModal: vi.fn(),
|
||||
mockRefreshModelProviders: vi.fn(),
|
||||
mockInvalidateAllToolProviders: vi.fn(),
|
||||
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
|
||||
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
|
||||
mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
refreshModelProviders: mockRefreshModelProviders,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
}))
|
||||
|
||||
vi.mock('../../../install-plugin/hooks', () => ({
|
||||
useGitHubReleases: () => ({
|
||||
checkForUpdates: mockCheckForUpdates,
|
||||
fetchReleases: mockFetchReleases,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Helper to create plugin detail
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test Plugin Label' },
|
||||
description: { en_US: 'Test description' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
latest_unique_identifier: 'new-uid',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to create modal states mock
|
||||
const createModalStatesMock = (): ModalStates => ({
|
||||
isShowUpdateModal: false,
|
||||
showUpdateModal: vi.fn(),
|
||||
hideUpdateModal: vi.fn(),
|
||||
isShowPluginInfo: false,
|
||||
showPluginInfo: vi.fn(),
|
||||
hidePluginInfo: vi.fn(),
|
||||
isShowDeleteConfirm: false,
|
||||
showDeleteConfirm: vi.fn(),
|
||||
hideDeleteConfirm: vi.fn(),
|
||||
deleting: false,
|
||||
showDeleting: vi.fn(),
|
||||
hideDeleting: vi.fn(),
|
||||
})
|
||||
|
||||
// Helper to create version picker mock
|
||||
const createVersionPickerMock = (): VersionPickerMock => ({
|
||||
setTargetVersion: vi.fn<(version: VersionTarget) => void>(),
|
||||
setIsDowngrade: vi.fn<(downgrade: boolean) => void>(),
|
||||
})
|
||||
|
||||
describe('usePluginOperations', () => {
|
||||
let modalStates: ModalStates
|
||||
let versionPicker: VersionPickerMock
|
||||
let mockOnUpdate: (isDelete?: boolean) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modalStates = createModalStatesMock()
|
||||
versionPicker = createVersionPickerMock()
|
||||
mockOnUpdate = vi.fn()
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
// Tests for marketplace update flow
|
||||
describe('Marketplace Update Flow', () => {
|
||||
it('should show update modal for marketplace plugin', async () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(modalStates.showUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isDowngrade when downgrading', async () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate(true)
|
||||
})
|
||||
|
||||
expect(versionPicker.setIsDowngrade).toHaveBeenCalledWith(true)
|
||||
expect(modalStates.showUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate and hide modal on successful marketplace update', () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdatedFromMarketplace()
|
||||
})
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for GitHub update flow
|
||||
describe('GitHub Update Flow', () => {
|
||||
it('should fetch releases from GitHub', 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()
|
||||
})
|
||||
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
|
||||
})
|
||||
|
||||
it('should check for updates after fetching releases', 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()
|
||||
})
|
||||
|
||||
expect(mockCheckForUpdates).toHaveBeenCalled()
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show update plugin modal when update is needed', 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()
|
||||
})
|
||||
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when no releases found', async () => {
|
||||
mockFetchReleases.mockResolvedValueOnce([])
|
||||
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()
|
||||
})
|
||||
|
||||
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when no update needed', async () => {
|
||||
mockCheckForUpdates.mockReturnValueOnce({
|
||||
needUpdate: false,
|
||||
toastProps: { type: 'info', message: 'Already up to date' },
|
||||
})
|
||||
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()
|
||||
})
|
||||
|
||||
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use author and name as fallback for repo parsing', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: '/', version: 'v1.0.0', package: 'pkg' },
|
||||
declaration: {
|
||||
author: 'fallback-author',
|
||||
name: 'fallback-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test' },
|
||||
description: { en_US: 'Test' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-name')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for delete flow
|
||||
describe('Delete Flow', () => {
|
||||
it('should call uninstallPlugin with correct id', async () => {
|
||||
const detail = createPluginDetail({ id: 'plugin-to-delete' })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('plugin-to-delete')
|
||||
})
|
||||
|
||||
it('should show and hide deleting state during delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(modalStates.showDeleting).toHaveBeenCalled()
|
||||
expect(modalStates.hideDeleting).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate with true after successful delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should hide delete confirm after successful delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh model providers when deleting model plugin', async () => {
|
||||
const detail = createPluginDetail({
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'model',
|
||||
label: { en_US: 'Test' },
|
||||
description: { en_US: 'Test' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invalidate tool providers when deleting tool plugin', async () => {
|
||||
const detail = createPluginDetail({
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test' },
|
||||
description: { en_US: 'Test' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should track plugin uninstalled event', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.objectContaining({
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_name: 'test-plugin-name',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not call onUpdate when delete fails', async () => {
|
||||
mockUninstallPlugin.mockResolvedValueOnce({ success: false })
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockOnUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for optional onUpdate callback
|
||||
describe('Optional onUpdate Callback', () => {
|
||||
it('should not throw when onUpdate is not provided for marketplace update', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
result.current.handleUpdatedFromMarketplace()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when onUpdate is not provided for delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleDelete()
|
||||
}),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||
import { useCallback } from 'react'
|
||||
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 { useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import { useGitHubReleases } from '../../../install-plugin/hooks'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type UsePluginOperationsParams = {
|
||||
detail: PluginDetail
|
||||
modalStates: ModalStates
|
||||
versionPicker: {
|
||||
setTargetVersion: (version: VersionTarget) => void
|
||||
setIsDowngrade: (downgrade: boolean) => void
|
||||
}
|
||||
isFromMarketplace: boolean
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
type UsePluginOperationsReturn = {
|
||||
handleUpdate: (isDowngrade?: boolean) => Promise<void>
|
||||
handleUpdatedFromMarketplace: () => void
|
||||
handleDelete: () => Promise<void>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
export const usePluginOperations = ({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
|
||||
const { id, meta, plugin_id } = detail
|
||||
const { author, category, name } = detail.declaration || detail
|
||||
|
||||
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
versionPicker.setIsDowngrade(!!isDowngrade)
|
||||
modalStates.showUpdateModal()
|
||||
return
|
||||
}
|
||||
|
||||
// GitHub update flow
|
||||
const owner = meta!.repo.split('/')[0] || author
|
||||
const repo = meta!.repo.split('/')[1] || name
|
||||
const fetchedReleases = await fetchReleases(owner, repo)
|
||||
if (fetchedReleases.length === 0)
|
||||
return
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version)
|
||||
Toast.notify(toastProps)
|
||||
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
category: detail.declaration.category,
|
||||
github: {
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
repo: meta!.repo,
|
||||
version: meta!.version,
|
||||
package: meta!.package,
|
||||
releases: fetchedReleases,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [
|
||||
isFromMarketplace,
|
||||
meta,
|
||||
author,
|
||||
name,
|
||||
fetchReleases,
|
||||
checkForUpdates,
|
||||
setShowUpdatePluginModal,
|
||||
detail,
|
||||
onUpdate,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
])
|
||||
|
||||
const handleUpdatedFromMarketplace = useCallback(() => {
|
||||
onUpdate?.()
|
||||
modalStates.hideUpdateModal()
|
||||
}, [onUpdate, modalStates])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
modalStates.showDeleting()
|
||||
const res = await uninstallPlugin(id)
|
||||
modalStates.hideDeleting()
|
||||
|
||||
if (res.success) {
|
||||
modalStates.hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
|
||||
if (PluginCategoryEnum.tool.includes(category))
|
||||
invalidateAllToolProviders()
|
||||
|
||||
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
category,
|
||||
plugin_id,
|
||||
name,
|
||||
modalStates,
|
||||
onUpdate,
|
||||
refreshModelProviders,
|
||||
invalidateAllToolProviders,
|
||||
])
|
||||
|
||||
return {
|
||||
handleUpdate,
|
||||
handleUpdatedFromMarketplace,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,299 @@
|
||||
'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 { 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'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetLanguage, useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { AutoUpdateLine } from '../../../base/icons/src/vender/system'
|
||||
import Verified from '../../base/badges/verified'
|
||||
import DeprecationNotice from '../../base/deprecation-notice'
|
||||
import Icon from '../../card/base/card-icon'
|
||||
import Description from '../../card/base/description'
|
||||
import OrgInfo from '../../card/base/org-info'
|
||||
import Title from '../../card/base/title'
|
||||
import useReferenceSetting from '../../plugin-page/use-reference-setting'
|
||||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../../reference-setting-modal/auto-update-setting/utils'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
import { HeaderModals, PluginSourceBadge } from './components'
|
||||
import { useDetailHeaderState, usePluginOperations } from './hooks'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
isReadmeView?: boolean
|
||||
onHide?: () => void
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
const getIconSrc = (icon: string | undefined, iconDark: string | undefined, theme: string, tenantId: string): string => {
|
||||
const iconFileName = theme === 'dark' && iconDark ? iconDark : icon
|
||||
if (!iconFileName)
|
||||
return ''
|
||||
return iconFileName.startsWith('http')
|
||||
? iconFileName
|
||||
: `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${iconFileName}`
|
||||
}
|
||||
|
||||
const getDetailUrl = (
|
||||
source: PluginSource,
|
||||
meta: PluginDetail['meta'],
|
||||
author: string,
|
||||
name: string,
|
||||
locale: string,
|
||||
theme: string,
|
||||
): string => {
|
||||
if (source === PluginSource.github)
|
||||
return `https://github.com/${meta!.repo}`
|
||||
if (source === PluginSource.marketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
|
||||
return ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
const DetailHeader = ({
|
||||
detail,
|
||||
isReadmeView = false,
|
||||
onHide,
|
||||
onUpdate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
const { theme } = useTheme()
|
||||
const locale = useGetLanguage()
|
||||
const currentLocale = useLocale()
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
|
||||
// Extract detail properties
|
||||
const {
|
||||
source,
|
||||
tenant_id,
|
||||
version,
|
||||
latest_version,
|
||||
latest_unique_identifier,
|
||||
meta,
|
||||
plugin_id,
|
||||
status,
|
||||
deprecated_reason,
|
||||
alternative_plugin_id,
|
||||
} = detail
|
||||
|
||||
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
||||
|
||||
// State and operations hooks
|
||||
const {
|
||||
modalStates,
|
||||
versionPicker,
|
||||
hasNewVersion,
|
||||
isAutoUpgradeEnabled,
|
||||
isFromGitHub,
|
||||
isFromMarketplace,
|
||||
} = useDetailHeaderState(detail)
|
||||
|
||||
const {
|
||||
handleUpdate,
|
||||
handleUpdatedFromMarketplace,
|
||||
handleDelete,
|
||||
} = usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
})
|
||||
|
||||
// Tool provider data
|
||||
const isTool = category === PluginCategoryEnum.tool
|
||||
const providerBriefInfo = tool?.identity
|
||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
||||
const provider = useMemo(() => {
|
||||
return collectionList.find(collection => collection.name === providerKey)
|
||||
}, [collectionList, providerKey])
|
||||
|
||||
// Computed values
|
||||
const iconSrc = getIconSrc(icon, icon_dark, theme, tenant_id)
|
||||
const detailUrl = getDetailUrl(source, meta, author, name, currentLocale, theme)
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
|
||||
// Event handlers
|
||||
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
|
||||
versionPicker.setTargetVersion(state)
|
||||
handleUpdate(state.isDowngrade)
|
||||
}
|
||||
|
||||
const handleTriggerLatestUpdate = () => {
|
||||
if (isFromMarketplace) {
|
||||
versionPicker.setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
<div className="flex">
|
||||
{/* Plugin Icon */}
|
||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||
<Icon src={iconSrc} />
|
||||
</div>
|
||||
|
||||
{/* Plugin Info */}
|
||||
<div className="ml-3 w-0 grow">
|
||||
{/* Title Row */}
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={label[locale]} />
|
||||
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
||||
|
||||
{/* Version Picker */}
|
||||
{!!version && (
|
||||
<PluginVersionPicker
|
||||
disabled={!isFromMarketplace || isReadmeView}
|
||||
isShow={versionPicker.isShow}
|
||||
onShowChange={versionPicker.setIsShow}
|
||||
pluginID={plugin_id}
|
||||
currentVersion={version}
|
||||
onSelect={handleVersionSelect}
|
||||
trigger={(
|
||||
<Badge
|
||||
className={cn(
|
||||
'mx-1',
|
||||
versionPicker.isShow && 'bg-state-base-hover',
|
||||
(versionPicker.isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
uppercase={false}
|
||||
text={(
|
||||
<>
|
||||
<div>{isFromGitHub ? meta!.version : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
</>
|
||||
)}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Update Button */}
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={handleTriggerLatestUpdate}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Org Info Row */}
|
||||
<div className="mb-1 flex h-4 items-center justify-between">
|
||||
<div className="mt-0.5 flex items-center">
|
||||
<OrgInfo
|
||||
packageNameClassName="w-auto"
|
||||
orgName={author}
|
||||
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
||||
/>
|
||||
{!!source && <PluginSourceBadge source={source} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isReadmeView && (
|
||||
<div className="flex gap-1">
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={modalStates.showPluginInfo}
|
||||
onCheckVersion={handleUpdate}
|
||||
onRemove={modalStates.showDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deprecation Notice */}
|
||||
{isFromMarketplace && (
|
||||
<DeprecationNotice
|
||||
status={status}
|
||||
deprecatedReason={deprecated_reason}
|
||||
alternativePluginId={alternative_plugin_id}
|
||||
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2} />}
|
||||
|
||||
{/* Plugin Auth for Tools */}
|
||||
{category === PluginCategoryEnum.tool && !isReadmeView && (
|
||||
<PluginAuth
|
||||
pluginPayload={{
|
||||
provider: provider?.name || '',
|
||||
category: AuthCategory.tool,
|
||||
providerType: provider?.type || '',
|
||||
detail,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={versionPicker.targetVersion}
|
||||
isDowngrade={versionPicker.isDowngrade}
|
||||
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
|
||||
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailHeader
|
||||
Reference in New Issue
Block a user