mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
chore: remove frontend changes
This commit is contained in:
@ -1,7 +1,8 @@
|
||||
import React, { type FC } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Theme } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type IconWithTooltipProps = {
|
||||
className?: string
|
||||
@ -24,10 +25,10 @@ const IconWithTooltip: FC<IconWithTooltipProps> = ({
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupClassName='p-1.5 border-[0.5px] border-[0.5px] border-components-panel-border bg-components-tooltip-bg text-text-secondary system-xs-medium'
|
||||
popupClassName="p-1.5 border-[0.5px] border-[0.5px] border-components-panel-border bg-components-tooltip-bg text-text-secondary system-xs-medium"
|
||||
popupContent={popupContent}
|
||||
>
|
||||
<div className='flex shrink-0 items-center justify-center'>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<Icon className={iconClassName} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { FC } from 'react'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark'
|
||||
import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
|
||||
type PartnerProps = {
|
||||
className?: string
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { FC } from 'react'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
import VerifiedDark from '@/app/components/base/icons/src/public/plugins/VerifiedDark'
|
||||
import VerifiedLight from '@/app/components/base/icons/src/public/plugins/VerifiedLight'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
|
||||
type VerifiedProps = {
|
||||
className?: string
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { camelCase } from 'es-toolkit/compat'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '../marketplace/hooks'
|
||||
import { camelCase } from 'lodash-es'
|
||||
|
||||
type DeprecationNoticeProps = {
|
||||
status: 'deleted' | 'active'
|
||||
@ -19,7 +20,14 @@ type DeprecationNoticeProps = {
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
const i18nPrefix = 'plugin.detailPanel.deprecation'
|
||||
const i18nPrefix = 'detailPanel.deprecation'
|
||||
|
||||
type DeprecatedReasonKey = 'businessAdjustments' | 'ownershipTransferred' | 'noMaintainer'
|
||||
const validReasonKeys: DeprecatedReasonKey[] = ['businessAdjustments', 'ownershipTransferred', 'noMaintainer']
|
||||
|
||||
function isValidReasonKey(key: string): key is DeprecatedReasonKey {
|
||||
return (validReasonKeys as string[]).includes(key)
|
||||
}
|
||||
|
||||
const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
status,
|
||||
@ -35,18 +43,16 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
const { t } = useMixedTranslation(locale)
|
||||
|
||||
const deprecatedReasonKey = useMemo(() => {
|
||||
if (!deprecatedReason) return ''
|
||||
return camelCase(deprecatedReason)
|
||||
if (!deprecatedReason)
|
||||
return null
|
||||
const key = camelCase(deprecatedReason)
|
||||
if (isValidReasonKey(key))
|
||||
return key
|
||||
return null
|
||||
}, [deprecatedReason])
|
||||
|
||||
// Check if the deprecatedReasonKey exists in i18n
|
||||
const hasValidDeprecatedReason = useMemo(() => {
|
||||
if (!deprecatedReason || !deprecatedReasonKey) return false
|
||||
|
||||
// Define valid reason keys that exist in i18n
|
||||
const validReasonKeys = ['businessAdjustments', 'ownershipTransferred', 'noMaintainer']
|
||||
return validReasonKeys.includes(deprecatedReasonKey)
|
||||
}, [deprecatedReason, deprecatedReasonKey])
|
||||
const hasValidDeprecatedReason = deprecatedReasonKey !== null
|
||||
|
||||
if (status !== 'deleted')
|
||||
return null
|
||||
@ -56,10 +62,11 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
<div className={cn(
|
||||
'relative flex items-start gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
|
||||
innerWrapperClassName,
|
||||
)}>
|
||||
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-toast-warning-bg opacity-40' />
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-0 top-0 -z-10 h-full w-full bg-toast-warning-bg opacity-40" />
|
||||
<div className={cn('flex size-6 shrink-0 items-center justify-center', iconWrapperClassName)}>
|
||||
<RiAlertFill className='size-4 text-text-warning-secondary' />
|
||||
<RiAlertFill className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<div className={cn('system-xs-regular grow py-1 text-text-primary', textClassName)}>
|
||||
{
|
||||
@ -71,14 +78,14 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
CustomLink: (
|
||||
<Link
|
||||
href={alternativePluginURL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='underline'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
values={{
|
||||
deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`),
|
||||
deprecatedReason: deprecatedReasonKey ? t(`${i18nPrefix}.reason.${deprecatedReasonKey}`, { ns: 'plugin' }) : '',
|
||||
alternativePluginId,
|
||||
}}
|
||||
/>
|
||||
@ -87,13 +94,13 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
{
|
||||
hasValidDeprecatedReason && !alternativePluginId && (
|
||||
<span>
|
||||
{t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })}
|
||||
{t(`${i18nPrefix}.onlyReason`, { ns: 'plugin', deprecatedReason: deprecatedReasonKey ? t(`${i18nPrefix}.reason.${deprecatedReasonKey}`, { ns: 'plugin' }) : '' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
!hasValidDeprecatedReason && (
|
||||
<span>{t(`${i18nPrefix}.noReason`)}</span>
|
||||
<span>{t(`${i18nPrefix}.noReason`, { ns: 'plugin' })}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { CopyCheck } from '../../base/icons/src/vender/line/files'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import cn from '@/utils/classnames'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
@ -47,15 +48,15 @@ const KeyValueItem: FC<Props> = ({
|
||||
const CopyIcon = isCopied ? CopyCheck : RiClipboardLine
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('system-xs-medium flex flex-col items-start justify-center text-text-tertiary', labelWidthClassName)}>{label}</span>
|
||||
<div className='flex items-center justify-center gap-0.5'>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<span className={cn(valueMaxWidthClassName, ' system-xs-medium truncate text-text-secondary')}>
|
||||
{maskedValue || value}
|
||||
</span>
|
||||
<Tooltip popupContent={t(`common.operation.${isCopied ? 'copied' : 'copy'}`)} position='top'>
|
||||
<Tooltip popupContent={t(`operation.${isCopied ? 'copied' : 'copy'}`, { ns: 'common' })} position="top">
|
||||
<ActionButton onClick={handleCopy}>
|
||||
<CopyIcon className='h-3.5 w-3.5 shrink-0 text-text-tertiary' />
|
||||
<CopyIcon className="h-3.5 w-3.5 shrink-0 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { RiCheckLine, RiCloseLine } from '@remixicon/react'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { shouldUseMcpIcon } from '@/utils/mcp'
|
||||
|
||||
const iconSizeMap = {
|
||||
@ -33,11 +33,11 @@ const Icon = ({
|
||||
<div className={cn('relative', className)}>
|
||||
<AppIcon
|
||||
size={size}
|
||||
iconType={'emoji'}
|
||||
iconType="emoji"
|
||||
icon={src.content}
|
||||
background={src.background}
|
||||
className='rounded-md'
|
||||
innerIcon={shouldUseMcpIcon(src) ? <Mcp className='h-8 w-8 text-text-primary-on-surface' /> : undefined}
|
||||
className="rounded-md"
|
||||
innerIcon={shouldUseMcpIcon(src) ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -52,15 +52,19 @@ const Icon = ({
|
||||
>
|
||||
{
|
||||
installed
|
||||
&& <div className={cn(iconClassName, 'bg-state-success-solid')}>
|
||||
<RiCheckLine className='h-3 w-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
&& (
|
||||
<div className={cn(iconClassName, 'bg-state-success-solid')}>
|
||||
<RiCheckLine className="h-3 w-3 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
installFailed
|
||||
&& <div className={cn(iconClassName, 'bg-state-destructive-solid')}>
|
||||
<RiCloseLine className='h-3 w-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
&& (
|
||||
<div className={cn(iconClassName, 'bg-state-destructive-solid')}>
|
||||
<RiCloseLine className="h-3 w-3 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -2,7 +2,7 @@ import { LeftCorner } from '../../../base/icons/src/vender/plugin'
|
||||
|
||||
const CornerMark = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<div className='absolute right-0 top-0 flex pl-[13px] '>
|
||||
<div className="absolute right-0 top-0 flex pl-[13px] ">
|
||||
<LeftCorner className="text-background-section" />
|
||||
<div className="system-2xs-medium-uppercase h-5 rounded-tr-xl bg-background-section pr-2 leading-5 text-text-tertiary">{text}</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { RiInstallLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
type Props = {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
orgName?: string
|
||||
@ -16,8 +17,8 @@ const OrgInfo = ({
|
||||
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
|
||||
{orgName && (
|
||||
<>
|
||||
<span className='system-xs-regular shrink-0 text-text-tertiary'>{orgName}</span>
|
||||
<span className='system-xs-regular shrink-0 text-text-quaternary'>/</span>
|
||||
<span className="system-xs-regular shrink-0 text-text-tertiary">{orgName}</span>
|
||||
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Group } from '../../../base/icons/src/vender/other'
|
||||
import Title from './title'
|
||||
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
wrapClassName: string
|
||||
@ -20,20 +20,23 @@ const Placeholder = ({
|
||||
<div className={wrapClassName}>
|
||||
<SkeletonRow>
|
||||
<div
|
||||
className='flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
border-components-panel-border bg-background-default p-1 backdrop-blur-sm'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<Group className='text-text-tertiary' />
|
||||
className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
border-components-panel-border bg-background-default p-1 backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<Group className="text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<SkeletonContainer>
|
||||
<div className="flex h-5 items-center">
|
||||
{loadingFileName ? (
|
||||
<Title title={loadingFileName} />
|
||||
) : (
|
||||
<SkeletonRectangle className="w-[260px]" />
|
||||
)}
|
||||
{loadingFileName
|
||||
? (
|
||||
<Title title={loadingFileName} />
|
||||
)
|
||||
: (
|
||||
<SkeletonRectangle className="w-[260px]" />
|
||||
)}
|
||||
</div>
|
||||
<SkeletonRow className="h-4">
|
||||
<SkeletonRectangle className="w-[41px]" />
|
||||
|
||||
@ -4,7 +4,7 @@ const Title = ({
|
||||
title: string
|
||||
}) => {
|
||||
return (
|
||||
<div className='system-md-semibold truncate text-text-secondary'>
|
||||
<div className="system-md-semibold truncate text-text-secondary">
|
||||
{title}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import * as React from 'react'
|
||||
import DownloadCount from './base/download-count'
|
||||
|
||||
type Props = {
|
||||
|
||||
1742
web/app/components/plugins/card/index.spec.tsx
Normal file
1742
web/app/components/plugins/card/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,21 @@
|
||||
'use client'
|
||||
import type { Plugin } from '../types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import {
|
||||
renderI18nObject,
|
||||
} from '@/i18n-config'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { Theme } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Partner from '../base/badges/partner'
|
||||
import Verified from '../base/badges/verified'
|
||||
import Icon from '../card/base/card-icon'
|
||||
import { useCategories } from '../hooks'
|
||||
import type { Plugin } from '../types'
|
||||
import CornerMark from './base/corner-mark'
|
||||
import Description from './base/description'
|
||||
import OrgInfo from './base/org-info'
|
||||
@ -30,7 +33,7 @@ export type Props = {
|
||||
footer?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
locale?: string
|
||||
locale?: Locale
|
||||
limitedInstall?: boolean
|
||||
}
|
||||
|
||||
@ -79,9 +82,11 @@ const Card = ({
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={getLocalizedText(label)} />
|
||||
{isPartner && <Partner className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.partnerTip')} />}
|
||||
{verified && <Verified className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.verifiedTip')} />}
|
||||
{titleLeft} {/* This can be version badge */}
|
||||
{isPartner && <Partner className="ml-0.5 h-4 w-4" text={t('marketplace.partnerTip', { ns: 'plugin' })} />}
|
||||
{verified && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
||||
{titleLeft}
|
||||
{' '}
|
||||
{/* This can be version badge */}
|
||||
</div>
|
||||
<OrgInfo
|
||||
className="mt-0.5"
|
||||
@ -98,12 +103,14 @@ const Card = ({
|
||||
{footer && <div>{footer}</div>}
|
||||
</div>
|
||||
{limitedInstall
|
||||
&& <div className='relative flex h-8 items-center gap-x-2 px-3 after:absolute after:bottom-0 after:left-0 after:right-0 after:top-0 after:bg-toast-warning-bg after:opacity-40'>
|
||||
<RiAlertFill className='h-3 w-3 shrink-0 text-text-warning-secondary' />
|
||||
<p className='system-xs-regular z-10 grow text-text-secondary'>
|
||||
{t('plugin.installModal.installWarning')}
|
||||
</p>
|
||||
</div>}
|
||||
&& (
|
||||
<div className="relative flex h-8 items-center gap-x-2 px-3 after:absolute after:bottom-0 after:left-0 after:right-0 after:top-0 after:bg-toast-warning-bg after:opacity-40">
|
||||
<RiAlertFill className="h-3 w-3 shrink-0 text-text-warning-secondary" />
|
||||
<p className="system-xs-regular z-10 grow text-text-secondary">
|
||||
{t('installModal.installWarning', { ns: 'plugin' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,7 +19,9 @@ export const tagKeys = [
|
||||
'entertainment',
|
||||
'utilities',
|
||||
'other',
|
||||
]
|
||||
] as const
|
||||
|
||||
export type TagKey = typeof tagKeys[number]
|
||||
|
||||
export const categoryKeys = [
|
||||
PluginCategoryEnum.model,
|
||||
@ -29,4 +31,6 @@ export const categoryKeys = [
|
||||
PluginCategoryEnum.extension,
|
||||
'bundle',
|
||||
PluginCategoryEnum.trigger,
|
||||
]
|
||||
] as const
|
||||
|
||||
export type CategoryKey = typeof categoryKeys[number]
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { CategoryKey, TagKey } from './constants'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@ -8,7 +9,7 @@ import {
|
||||
import { PluginCategoryEnum } from './types'
|
||||
|
||||
export type Tag = {
|
||||
name: string
|
||||
name: TagKey
|
||||
label: string
|
||||
}
|
||||
|
||||
@ -20,7 +21,7 @@ export const useTags = (translateFromOut?: TFunction) => {
|
||||
return tagKeys.map((tag) => {
|
||||
return {
|
||||
name: tag,
|
||||
label: t(`pluginTags.tags.${tag}`),
|
||||
label: t(`tags.${tag}`, { ns: 'pluginTags' }),
|
||||
}
|
||||
})
|
||||
}, [t])
|
||||
@ -48,7 +49,7 @@ export const useTags = (translateFromOut?: TFunction) => {
|
||||
}
|
||||
|
||||
type Category = {
|
||||
name: string
|
||||
name: CategoryKey
|
||||
label: string
|
||||
}
|
||||
|
||||
@ -61,12 +62,12 @@ export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean)
|
||||
if (category === PluginCategoryEnum.agent) {
|
||||
return {
|
||||
name: PluginCategoryEnum.agent,
|
||||
label: isSingle ? t('plugin.categorySingle.agent') : t('plugin.category.agents'),
|
||||
label: isSingle ? t('categorySingle.agent', { ns: 'plugin' }) : t('category.agents', { ns: 'plugin' }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: category,
|
||||
label: isSingle ? t(`plugin.categorySingle.${category}`) : t(`plugin.category.${category}s`),
|
||||
label: isSingle ? t(`categorySingle.${category}`, { ns: 'plugin' }) : t(`category.${category}s`, { ns: 'plugin' }),
|
||||
}
|
||||
})
|
||||
}, [t, isSingle])
|
||||
@ -92,8 +93,8 @@ export const PLUGIN_PAGE_TABS_MAP = {
|
||||
export const usePluginPageTabs = () => {
|
||||
const { t } = useTranslation()
|
||||
const tabs = [
|
||||
{ value: PLUGIN_PAGE_TABS_MAP.plugins, text: t('common.menus.plugins') },
|
||||
{ value: PLUGIN_PAGE_TABS_MAP.marketplace, text: t('common.menus.exploreMarketplace') },
|
||||
{ value: PLUGIN_PAGE_TABS_MAP.plugins, text: t('menus.plugins', { ns: 'common' }) },
|
||||
{ value: PLUGIN_PAGE_TABS_MAP.marketplace, text: t('menus.exploreMarketplace', { ns: 'common' }) },
|
||||
]
|
||||
return tabs
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { checkTaskStatus as fetchCheckTaskStatus } from '@/service/plugins'
|
||||
import type { PluginStatus } from '../../types'
|
||||
import { TaskStatus } from '../../types'
|
||||
import { checkTaskStatus as fetchCheckTaskStatus } from '@/service/plugins'
|
||||
import { sleep } from '@/utils'
|
||||
import { TaskStatus } from '../../types'
|
||||
|
||||
const INTERVAL = 10 * 1000 // 10 seconds
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Card from '../../card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
|
||||
import { pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Card from '../../card'
|
||||
import { pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils'
|
||||
|
||||
type Props = {
|
||||
payload?: Plugin | PluginDeclaration | PluginManifestInMarket | null
|
||||
@ -30,28 +30,28 @@ const Installed: FC<Props> = ({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<p className='system-md-regular text-text-secondary'>{(isFailed && errMsg) ? errMsg : t(`plugin.installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`)}</p>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<p className="system-md-regular text-text-secondary">{(isFailed && errMsg) ? errMsg : t(`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`, { ns: 'plugin' })}</p>
|
||||
{payload && (
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
payload={isMarketPayload ? pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket) : pluginManifestToCardPluginProps(payload as PluginDeclaration)}
|
||||
installed={!isFailed}
|
||||
installFailed={isFailed}
|
||||
titleLeft={<Badge className='mx-1' size="s" state={BadgeState.Default}>{(payload as PluginDeclaration).version || (payload as PluginManifestInMarket).latest_version}</Badge>}
|
||||
titleLeft={<Badge className="mx-1" size="s" state={BadgeState.Default}>{(payload as PluginDeclaration).version || (payload as PluginManifestInMarket).latest_version}</Badge>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
variant="primary"
|
||||
className="min-w-[72px]"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t('common.operation.close')}
|
||||
{t('operation.close', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,39 +1,40 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { Group } from '../../../base/icons/src/vender/other'
|
||||
import { LoadingPlaceholder } from '@/app/components/plugins/card/base/placeholder'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { LoadingPlaceholder } from '@/app/components/plugins/card/base/placeholder'
|
||||
import { Group } from '../../../base/icons/src/vender/other'
|
||||
|
||||
const LoadingError: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
checked={false}
|
||||
disabled
|
||||
/>
|
||||
<div className='hover-bg-components-panel-on-panel-item-bg relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs'>
|
||||
<div className="hover-bg-components-panel-on-panel-item-bg relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs">
|
||||
<div className="flex">
|
||||
<div
|
||||
className='relative flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
border-state-destructive-border bg-state-destructive-hover p-1 backdrop-blur-sm'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<Group className='text-text-quaternary' />
|
||||
className="relative flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
border-state-destructive-border bg-state-destructive-hover p-1 backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<Group className="text-text-quaternary" />
|
||||
</div>
|
||||
<div className='absolute bottom-[-4px] right-[-4px] rounded-full border-[2px] border-components-panel-bg bg-state-destructive-solid'>
|
||||
<RiCloseLine className='h-3 w-3 text-text-primary-on-surface' />
|
||||
<div className="absolute bottom-[-4px] right-[-4px] rounded-full border-[2px] border-components-panel-bg bg-state-destructive-solid">
|
||||
<RiCloseLine className="h-3 w-3 text-text-primary-on-surface" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 grow">
|
||||
<div className="system-md-semibold flex h-5 items-center text-text-destructive">
|
||||
{t('plugin.installModal.pluginLoadError')}
|
||||
{t('installModal.pluginLoadError', { ns: 'plugin' })}
|
||||
</div>
|
||||
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
|
||||
{t('plugin.installModal.pluginLoadErrorDesc')}
|
||||
<div className="system-xs-regular mt-0.5 text-text-tertiary">
|
||||
{t('installModal.pluginLoadErrorDesc', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import Placeholder from '../../card/base/placeholder'
|
||||
import * as React from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Placeholder from '../../card/base/placeholder'
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
checked={false}
|
||||
disabled
|
||||
/>
|
||||
<div className='hover-bg-components-panel-on-panel-item-bg relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs'>
|
||||
<div className="hover-bg-components-panel-on-panel-item-bg relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs">
|
||||
<Placeholder
|
||||
wrapClassName='w-full'
|
||||
wrapClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
import type { VersionProps } from '../../types'
|
||||
import * as React from 'react'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
|
||||
const Version: FC<VersionProps> = ({
|
||||
hasInstalled,
|
||||
@ -14,19 +14,19 @@ const Version: FC<VersionProps> = ({
|
||||
{
|
||||
!hasInstalled
|
||||
? (
|
||||
<Badge className='mx-1' size="s" state={BadgeState.Default}>{toInstallVersion}</Badge>
|
||||
)
|
||||
<Badge className="mx-1" size="s" state={BadgeState.Default}>{toInstallVersion}</Badge>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Badge className='mx-1' size="s" state={BadgeState.Warning}>
|
||||
{`${installedVersion} -> ${toInstallVersion}`}
|
||||
</Badge>
|
||||
{/* <div className='flex px-0.5 justify-center items-center gap-0.5'>
|
||||
<>
|
||||
<Badge className="mx-1" size="s" state={BadgeState.Warning}>
|
||||
{`${installedVersion} -> ${toInstallVersion}`}
|
||||
</Badge>
|
||||
{/* <div className='flex px-0.5 justify-center items-center gap-0.5'>
|
||||
<div className='text-text-warning system-xs-medium'>Used in 3 apps</div>
|
||||
<RiInformation2Line className='w-4 h-4 text-text-tertiary' />
|
||||
</div> */}
|
||||
</>
|
||||
)
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import Toast, { type IToastProps } from '@/app/components/base/toast'
|
||||
import type { GitHubRepoReleaseResponse } from '../types'
|
||||
import type { IToastProps } from '@/app/components/base/toast'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { GITHUB_ACCESS_TOKEN } from '@/config'
|
||||
import { uploadGitHub } from '@/service/plugins'
|
||||
import { compareVersion, getLatestVersion } from '@/utils/semver'
|
||||
import type { GitHubRepoReleaseResponse } from '../types'
|
||||
import { GITHUB_ACCESS_TOKEN } from '@/config'
|
||||
|
||||
const formatReleases = (releases: any) => {
|
||||
return releases.map((release: any) => ({
|
||||
@ -20,7 +21,8 @@ export const useGitHubReleases = () => {
|
||||
if (!GITHUB_ACCESS_TOKEN) {
|
||||
// Fetch releases without authentication from client
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`)
|
||||
if (!res.ok) throw new Error('Failed to fetch repository releases')
|
||||
if (!res.ok)
|
||||
throw new Error('Failed to fetch repository releases')
|
||||
const data = await res.json()
|
||||
return formatReleases(data)
|
||||
}
|
||||
@ -28,7 +30,8 @@ export const useGitHubReleases = () => {
|
||||
// Fetch releases with authentication from server
|
||||
const res = await fetch(`/repos/${owner}/${repo}/releases`)
|
||||
const bodyJson = await res.json()
|
||||
if (bodyJson.status !== 200) throw new Error(bodyJson.data.message)
|
||||
if (bodyJson.status !== 200)
|
||||
throw new Error(bodyJson.data.message)
|
||||
return formatReleases(bodyJson.data)
|
||||
}
|
||||
}
|
||||
@ -83,7 +86,7 @@ export const useGitHubUpload = () => {
|
||||
repoUrl: string,
|
||||
selectedVersion: string,
|
||||
selectedPackage: string,
|
||||
onSuccess?: (GitHubPackage: { manifest: any; unique_identifier: string }) => void,
|
||||
onSuccess?: (GitHubPackage: { manifest: any, unique_identifier: string }) => void,
|
||||
) => {
|
||||
try {
|
||||
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
|
||||
@ -91,7 +94,8 @@ export const useGitHubUpload = () => {
|
||||
manifest: response.manifest,
|
||||
unique_identifier: response.unique_identifier,
|
||||
}
|
||||
if (onSuccess) onSuccess(GitHubPackage)
|
||||
if (onSuccess)
|
||||
onSuccess(GitHubPackage)
|
||||
return GitHubPackage
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useCheckInstalled as useDoCheckInstalled } from '@/service/use-plugins'
|
||||
import type { VersionInfo } from '../../types'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { VersionInfo } from '../../types'
|
||||
import { useCheckInstalled as useDoCheckInstalled } from '@/service/use-plugins'
|
||||
|
||||
type Props = {
|
||||
pluginIds: string[],
|
||||
pluginIds: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
const useCheckInstalled = (props: Props) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
import type { Plugin, PluginManifestInMarket } from '../../types'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
|
||||
type PluginProps = (Plugin | PluginManifestInMarket) & { from: 'github' | 'marketplace' | 'package' }
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools'
|
||||
import { useInvalidateStrategyProviders } from '@/service/use-strategy'
|
||||
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useInvalidDataSourceListAuth } from '@/service/use-datasource'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import { useInvalidateStrategyProviders } from '@/service/use-strategy'
|
||||
import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools'
|
||||
import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
|
||||
const useRefreshPluginList = () => {
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,16 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { InstallStep } from '../../types'
|
||||
import type { Dependency } from '../../types'
|
||||
import ReadyToInstall from './ready-to-install'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { InstallStep } from '../../types'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import cn from '@/utils/classnames'
|
||||
import ReadyToInstall from './ready-to-install'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
export enum InstallType {
|
||||
fromLocal = 'fromLocal',
|
||||
@ -41,11 +42,11 @@ const InstallBundle: FC<Props> = ({
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (step === InstallStep.uploadFailed)
|
||||
return t(`${i18nPrefix}.uploadFailed`)
|
||||
return t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' })
|
||||
if (step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installComplete`)
|
||||
return t(`${i18nPrefix}.installComplete`, { ns: 'plugin' })
|
||||
|
||||
return t(`${i18nPrefix}.installPlugin`)
|
||||
return t(`${i18nPrefix}.installPlugin`, { ns: 'plugin' })
|
||||
}, [step, t])
|
||||
|
||||
return (
|
||||
@ -55,8 +56,8 @@ const InstallBundle: FC<Props> = ({
|
||||
className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
<div className="flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
|
||||
<div className="title-2xl-semi-bold self-stretch text-text-primary">
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../types'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useUploadGitHub } from '@/service/use-plugins'
|
||||
import Loading from '../../base/loading'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import LoadedItem from './loaded-item'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
checked: boolean
|
||||
@ -46,7 +47,8 @@ const Item: FC<Props> = ({
|
||||
if (error)
|
||||
onFetchError()
|
||||
}, [error])
|
||||
if (!payload) return <Loading />
|
||||
if (!payload)
|
||||
return <Loading />
|
||||
return (
|
||||
<LoadedItem
|
||||
payload={payload}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { Plugin } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import type { Plugin, VersionProps } from '../../../types'
|
||||
import * as React from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import useGetIcon from '../../base/use-get-icon'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import Card from '../../../card'
|
||||
import useGetIcon from '../../base/use-get-icon'
|
||||
import Version from '../../base/version'
|
||||
import type { VersionProps } from '../../../types'
|
||||
import usePluginInstallLimit from '../../hooks/use-install-plugin-limit'
|
||||
|
||||
type Props = {
|
||||
@ -32,15 +31,15 @@ const LoadedItem: FC<Props> = ({
|
||||
}
|
||||
const { canInstall } = usePluginInstallLimit(payload)
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
disabled={!canInstall}
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
checked={checked}
|
||||
onCheck={() => onCheckedChange(payload)}
|
||||
/>
|
||||
<Card
|
||||
className='grow'
|
||||
className="grow"
|
||||
payload={{
|
||||
...payload,
|
||||
icon: isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${payload.org}/${payload.name}/icon` : getIconUrl(payload.icon),
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { Plugin } from '../../../types'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
import * as React from 'react'
|
||||
import Loading from '../../base/loading'
|
||||
import LoadedItem from './loaded-item'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
checked: boolean
|
||||
@ -21,7 +21,8 @@ const MarketPlaceItem: FC<Props> = ({
|
||||
version,
|
||||
versionInfo,
|
||||
}) => {
|
||||
if (!payload) return <Loading />
|
||||
if (!payload)
|
||||
return <Loading />
|
||||
return (
|
||||
<LoadedItem
|
||||
checked={checked}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { Plugin } from '../../../types'
|
||||
import type { PackageDependency } from '../../../types'
|
||||
import type { PackageDependency, Plugin } from '../../../types'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
import * as React from 'react'
|
||||
import LoadingError from '../../base/loading-error'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import LoadedItem from './loaded-item'
|
||||
import LoadingError from '../../base/loading-error'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
checked: boolean
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import type { Dependency, InstallStatus, Plugin } from '../../types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { InstallStep } from '../../types'
|
||||
import Install from './steps/install'
|
||||
import Installed from './steps/installed'
|
||||
import type { Dependency, InstallStatus, Plugin } from '../../types'
|
||||
|
||||
type Props = {
|
||||
step: InstallStep
|
||||
onStepChange: (step: InstallStep) => void,
|
||||
onStepChange: (step: InstallStep) => void
|
||||
onStartToInstall: () => void
|
||||
setIsInstalling: (isInstalling: boolean) => void
|
||||
allPlugins: Dependency[]
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import { useImperativeHandle } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types'
|
||||
import MarketplaceItem from '../item/marketplace-item'
|
||||
import GithubItem from '../item/github-item'
|
||||
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { produce } from 'immer'
|
||||
import PackageItem from '../item/package-item'
|
||||
import LoadingError from '../../base/loading-error'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
|
||||
import LoadingError from '../../base/loading-error'
|
||||
import { pluginInstallLimit } from '../../hooks/use-install-plugin-limit'
|
||||
import GithubItem from '../item/github-item'
|
||||
import MarketplaceItem from '../item/marketplace-item'
|
||||
import PackageItem from '../item/package-item'
|
||||
|
||||
type Props = {
|
||||
allPlugins: Dependency[]
|
||||
@ -229,15 +229,17 @@ const InstallByDSLList = ({
|
||||
}
|
||||
const plugin = plugins[index]
|
||||
if (d.type === 'github') {
|
||||
return (<GithubItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
dependency={d as GitHubItemAndMarketPlaceDependency}
|
||||
onFetchedPayload={handleGitHubPluginFetched(index)}
|
||||
onFetchError={handleGitHubPluginFetchError(index)}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
/>)
|
||||
return (
|
||||
<GithubItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
dependency={d as GitHubItemAndMarketPlaceDependency}
|
||||
onFetchedPayload={handleGitHubPluginFetched(index)}
|
||||
onFetchError={handleGitHubPluginFetchError(index)}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (d.type === 'marketplace') {
|
||||
@ -264,8 +266,7 @@ const InstallByDSLList = ({
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,27 +1,26 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
type Dependency,
|
||||
type InstallStatus,
|
||||
type InstallStatusResponse,
|
||||
type Plugin,
|
||||
TaskStatus,
|
||||
type VersionInfo,
|
||||
} from '../../../types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Dependency, InstallStatus, InstallStatusResponse, Plugin, VersionInfo } from '../../../types'
|
||||
import type { ExposeRefs } from './install-multi'
|
||||
import InstallMulti from './install-multi'
|
||||
import { useInstallOrUpdate, usePluginTaskList } from '@/service/use-plugins'
|
||||
import useRefreshPluginList from '../../hooks/use-refresh-plugin-list'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { useInstallOrUpdate, usePluginTaskList } from '@/service/use-plugins'
|
||||
import {
|
||||
|
||||
TaskStatus,
|
||||
|
||||
} from '../../../types'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
import useRefreshPluginList from '../../hooks/use-refresh-plugin-list'
|
||||
import InstallMulti from './install-multi'
|
||||
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type Props = {
|
||||
allPlugins: Dependency[]
|
||||
@ -172,11 +171,11 @@ const Install: FC<Props> = ({
|
||||
const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace()
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { num: selectedPluginsNum })}</p>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { ns: 'plugin', num: selectedPluginsNum })}</p>
|
||||
</div>
|
||||
<div className='w-full space-y-1 rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="w-full space-y-1 rounded-2xl bg-background-section-burn p-2">
|
||||
<InstallMulti
|
||||
ref={installMultiRef}
|
||||
allPlugins={allPlugins}
|
||||
@ -191,27 +190,29 @@ const Install: FC<Props> = ({
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
{!isHideButton && (
|
||||
<div className='flex items-center justify-between gap-2 self-stretch p-6 pt-5'>
|
||||
<div className='px-2'>
|
||||
{canInstall && <div className='flex items-center gap-x-2' onClick={handleClickSelectAll}>
|
||||
<Checkbox checked={isSelectAll} indeterminate={isIndeterminate} />
|
||||
<p className='system-sm-medium cursor-pointer text-text-secondary'>{isSelectAll ? t('common.operation.deSelectAll') : t('common.operation.selectAll')}</p>
|
||||
</div>}
|
||||
<div className="flex items-center justify-between gap-2 self-stretch p-6 pt-5">
|
||||
<div className="px-2">
|
||||
{canInstall && (
|
||||
<div className="flex items-center gap-x-2" onClick={handleClickSelectAll}>
|
||||
<Checkbox checked={isSelectAll} indeterminate={isIndeterminate} />
|
||||
<p className="system-sm-medium cursor-pointer text-text-secondary">{isSelectAll ? t('operation.deSelectAll', { ns: 'common' }) : t('operation.selectAll', { ns: 'common' })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center justify-end gap-2 self-stretch'>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch">
|
||||
{!canInstall && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={handleCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
<Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
variant="primary"
|
||||
className="flex min-w-[72px] space-x-0.5"
|
||||
disabled={!canInstall || isInstalling || selectedPlugins.length === 0 || !canInstallPluginFromMarketplace}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
{isInstalling && <RiLoader2Line className="h-4 w-4 animate-spin-slow" />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`, { ns: 'plugin' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { InstallStatus, Plugin } from '../../../types'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
import useGetIcon from '../../base/use-get-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import useGetIcon from '../../base/use-get-icon'
|
||||
|
||||
type Props = {
|
||||
list: Plugin[]
|
||||
@ -26,21 +26,21 @@ const Installed: FC<Props> = ({
|
||||
const { getIconUrl } = useGetIcon()
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
{/* <p className='text-text-secondary system-md-regular'>{(isFailed && errMsg) ? errMsg : t(`plugin.installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`)}</p> */}
|
||||
<div className='flex flex-wrap content-start items-start gap-1 space-y-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 space-y-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
{list.map((plugin, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={plugin.plugin_id}
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
payload={{
|
||||
...plugin,
|
||||
icon: installStatus[index].isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` : getIconUrl(plugin.icon),
|
||||
}}
|
||||
installed={installStatus[index].success}
|
||||
installFailed={!installStatus[index].success}
|
||||
titleLeft={plugin.version ? <Badge className='mx-1' size="s" state={BadgeState.Default}>{plugin.version}</Badge> : null}
|
||||
titleLeft={plugin.version ? <Badge className="mx-1" size="s" state={BadgeState.Default}>{plugin.version}</Badge> : null}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@ -48,13 +48,13 @@ const Installed: FC<Props> = ({
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
{!isHideButton && (
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
variant="primary"
|
||||
className="min-w-[72px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.close')}
|
||||
{t('operation.close', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { InstallState } from '@/app/components/plugins/types'
|
||||
import { useGitHubReleases } from '../hooks'
|
||||
import { convertRepoToUrl, parseGitHubUrl } from '../utils'
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
|
||||
import { InstallStepFromGitHub } from '../../types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import SetURL from './steps/setURL'
|
||||
import SelectPackage from './steps/selectPackage'
|
||||
import Installed from '../base/installed'
|
||||
import Loaded from './steps/loaded'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
import cn from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { InstallStepFromGitHub } from '../../types'
|
||||
import Installed from '../base/installed'
|
||||
import { useGitHubReleases } from '../hooks'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
import { convertRepoToUrl, parseGitHubUrl } from '../utils'
|
||||
import Loaded from './steps/loaded'
|
||||
import SelectPackage from './steps/selectPackage'
|
||||
import SetURL from './steps/setURL'
|
||||
|
||||
const i18nPrefix = 'plugin.installFromGitHub'
|
||||
const i18nPrefix = 'installFromGitHub'
|
||||
|
||||
type InstallFromGitHubProps = {
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
@ -60,21 +61,21 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
|
||||
|
||||
const packages: Item[] = state.selectedVersion
|
||||
? (state.releases
|
||||
.find(release => release.tag_name === state.selectedVersion)
|
||||
?.assets
|
||||
.map(asset => ({
|
||||
value: asset.name,
|
||||
name: asset.name,
|
||||
})) || [])
|
||||
.find(release => release.tag_name === state.selectedVersion)
|
||||
?.assets
|
||||
.map(asset => ({
|
||||
value: asset.name,
|
||||
name: asset.name,
|
||||
})) || [])
|
||||
: []
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (state.step === InstallStepFromGitHub.installed)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`, { ns: 'plugin' })
|
||||
if (state.step === InstallStepFromGitHub.installFailed)
|
||||
return t(`${i18nPrefix}.installFailed`)
|
||||
return t(`${i18nPrefix}.installFailed`, { ns: 'plugin' })
|
||||
|
||||
return updatePayload ? t(`${i18nPrefix}.updatePlugin`) : t(`${i18nPrefix}.installPlugin`)
|
||||
return updatePayload ? t(`${i18nPrefix}.updatePlugin`, { ns: 'plugin' }) : t(`${i18nPrefix}.installPlugin`, { ns: 'plugin' })
|
||||
}, [state.step, t, updatePayload])
|
||||
|
||||
const handleUrlSubmit = async () => {
|
||||
@ -82,7 +83,7 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
|
||||
if (!isValid || !owner || !repo) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('plugin.error.inValidGitHubUrl'),
|
||||
message: t('error.inValidGitHubUrl', { ns: 'plugin' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -98,20 +99,20 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('plugin.error.noReleasesFound'),
|
||||
message: t('error.noReleasesFound', { ns: 'plugin' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('plugin.error.fetchReleasesError'),
|
||||
message: t('error.fetchReleasesError', { ns: 'plugin' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (e: any, isInstall: boolean) => {
|
||||
const message = e?.response?.message || t('plugin.installModal.installFailedDesc')
|
||||
const message = e?.response?.message || t('installModal.installFailedDesc', { ns: 'plugin' })
|
||||
setErrorMsg(message)
|
||||
setState(prevState => ({ ...prevState, step: isInstall ? InstallStepFromGitHub.installFailed : InstallStepFromGitHub.uploadFailed }))
|
||||
}
|
||||
@ -172,62 +173,66 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
|
||||
border-components-panel-border bg-components-panel-bg p-0`)}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='flex grow flex-col items-start gap-1'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
<div className="flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
|
||||
<div className="flex grow flex-col items-start gap-1">
|
||||
<div className="title-2xl-semi-bold self-stretch text-text-primary">
|
||||
{getTitle()}
|
||||
</div>
|
||||
<div className='system-xs-regular self-stretch text-text-tertiary'>
|
||||
{!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t('plugin.installFromGitHub.installNote')}
|
||||
<div className="system-xs-regular self-stretch text-text-tertiary">
|
||||
{!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t('installFromGitHub.installNote', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step))
|
||||
? <Installed
|
||||
payload={manifest}
|
||||
isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
: <div className={`flex flex-col items-start justify-center self-stretch px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}>
|
||||
{state.step === InstallStepFromGitHub.setUrl && (
|
||||
<SetURL
|
||||
repoUrl={state.repoUrl}
|
||||
onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))}
|
||||
onNext={handleUrlSubmit}
|
||||
? (
|
||||
<Installed
|
||||
payload={manifest}
|
||||
isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={`flex flex-col items-start justify-center self-stretch px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}>
|
||||
{state.step === InstallStepFromGitHub.setUrl && (
|
||||
<SetURL
|
||||
repoUrl={state.repoUrl}
|
||||
onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))}
|
||||
onNext={handleUrlSubmit}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)}
|
||||
{state.step === InstallStepFromGitHub.selectPackage && (
|
||||
<SelectPackage
|
||||
updatePayload={updatePayload!}
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
versions={versions}
|
||||
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
|
||||
selectedPackage={state.selectedPackage}
|
||||
packages={packages}
|
||||
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
|
||||
onUploaded={handleUploaded}
|
||||
onFailed={handleUploadFail}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
{state.step === InstallStepFromGitHub.readyToInstall && (
|
||||
<Loaded
|
||||
updatePayload={updatePayload!}
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
payload={manifest as any}
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
selectedPackage={state.selectedPackage}
|
||||
onBack={handleBack}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.step === InstallStepFromGitHub.selectPackage && (
|
||||
<SelectPackage
|
||||
updatePayload={updatePayload!}
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
versions={versions}
|
||||
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
|
||||
selectedPackage={state.selectedPackage}
|
||||
packages={packages}
|
||||
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
|
||||
onUploaded={handleUploaded}
|
||||
onFailed={handleUploadFail}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
{state.step === InstallStepFromGitHub.readyToInstall && (
|
||||
<Loaded
|
||||
updatePayload={updatePayload!}
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
payload={manifest as any}
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
selectedPackage={state.selectedPackage}
|
||||
onBack={handleBack}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
/>
|
||||
)}
|
||||
</div>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,525 @@
|
||||
import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../types'
|
||||
import Loaded from './loaded'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseCheckInstalled = vi.fn()
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: (params: { pluginIds: string[], enabled: boolean }) => mockUseCheckInstalled(params),
|
||||
}))
|
||||
|
||||
const mockUpdateFromGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
updateFromGitHub: (...args: unknown[]) => mockUpdateFromGitHub(...args),
|
||||
}))
|
||||
|
||||
const mockInstallPackageFromGitHub = vi.fn()
|
||||
const mockHandleRefetch = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromGitHub: () => ({ mutateAsync: mockInstallPackageFromGitHub }),
|
||||
usePluginTaskList: () => ({ handleRefetch: mockHandleRefetch }),
|
||||
}))
|
||||
|
||||
const mockCheck = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({ check: mockCheck }),
|
||||
}))
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => (
|
||||
<div data-testid="plugin-card">
|
||||
<span data-testid="card-name">{payload.name}</span>
|
||||
{titleLeft && <span data-testid="title-left">{titleLeft}</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Version component
|
||||
vi.mock('../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
toInstallVersion: string
|
||||
}) => (
|
||||
<span data-testid="version-info">
|
||||
{hasInstalled ? `Update from ${installedVersion} to ${toInstallVersion}` : `Install ${toInstallVersion}`}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
// Factory functions
|
||||
const createMockPayload = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'Test Description' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPluginPayload = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-pkg',
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Test' },
|
||||
brief: { 'en-US': 'Brief' },
|
||||
description: { 'en-US': 'Description' },
|
||||
introduction: 'Intro',
|
||||
repository: '',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createUpdatePayload = (): UpdateFromGitHubPayload => ({
|
||||
originalPackageInfo: {
|
||||
id: 'original-id',
|
||||
repo: 'owner/repo',
|
||||
version: 'v0.9.0',
|
||||
package: 'plugin.zip',
|
||||
releases: [],
|
||||
},
|
||||
})
|
||||
|
||||
describe('Loaded', () => {
|
||||
const defaultProps = {
|
||||
updatePayload: undefined,
|
||||
uniqueIdentifier: 'test-unique-id',
|
||||
payload: createMockPayload() as PluginDeclaration | Plugin,
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onBack: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
onInstalled: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: false,
|
||||
})
|
||||
mockUpdateFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render ready to install message', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render back button when not installing', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render install button', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show version info in card title', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-info')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Tests
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should display plugin name from payload', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should pass correct version to Version component', () => {
|
||||
render(<Loaded {...defaultProps} payload={createMockPayload({ version: '2.0.0' })} />)
|
||||
|
||||
expect(screen.getByTestId('version-info')).toHaveTextContent('Install 2.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button State Tests
|
||||
// ================================
|
||||
describe('Button State', () => {
|
||||
it('should disable install button while loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable install button when not loading', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const onBack = vi.fn()
|
||||
render(<Loaded {...defaultProps} onBack={onBack} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onStartToInstall when install starts', async () => {
|
||||
const onStartToInstall = vi.fn()
|
||||
render(<Loaded {...defaultProps} onStartToInstall={onStartToInstall} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installation Flow Tests
|
||||
// ================================
|
||||
describe('Installation Flows', () => {
|
||||
it('should call installPackageFromGitHub for fresh install', async () => {
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromGitHub).toHaveBeenCalledWith({
|
||||
repoUrl: 'owner/repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
uniqueIdentifier: 'test-unique-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateFromGitHub when updatePayload is provided', async () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
render(<Loaded {...defaultProps} updatePayload={updatePayload} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFromGitHub).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
'original-id',
|
||||
'test-unique-id',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateFromGitHub when plugin is already installed', async () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-plugin-id': {
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'installed-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFromGitHub).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
'installed-uid',
|
||||
'test-unique-id',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled when installation completes immediately', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check task status when not immediately installed', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefetch).toHaveBeenCalled()
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
pluginUniqueIdentifier: 'test-unique-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled with true when task succeeds', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Handling Tests
|
||||
// ================================
|
||||
describe('Error Handling', () => {
|
||||
it('should call onFailed when task fails', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Installation failed' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Loaded {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Installation failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed with string error', async () => {
|
||||
mockInstallPackageFromGitHub.mockRejectedValue('String error message')
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Loaded {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('String error message')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed without message for non-string errors', async () => {
|
||||
mockInstallPackageFromGitHub.mockRejectedValue(new Error('Error object'))
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Loaded {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Auto-install Effect Tests
|
||||
// ================================
|
||||
describe('Auto-install Effect', () => {
|
||||
it('should call onInstalled when already installed with same identifier', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-plugin-id': {
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'test-unique-id',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />)
|
||||
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onInstalled when identifiers differ', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-plugin-id': {
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'different-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />)
|
||||
|
||||
expect(onInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installing State Tests
|
||||
// ================================
|
||||
describe('Installing State', () => {
|
||||
it('should hide back button while installing', async () => {
|
||||
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
|
||||
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}))
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveInstall!({ all_installed: true, task_id: 'task-1' })
|
||||
})
|
||||
|
||||
it('should show installing text while installing', async () => {
|
||||
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
|
||||
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}))
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveInstall!({ all_installed: true, task_id: 'task-1' })
|
||||
})
|
||||
|
||||
it('should not trigger install twice when already installing', async () => {
|
||||
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
|
||||
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}))
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
const installButton = screen.getByRole('button', { name: /plugin.installModal.install/i })
|
||||
|
||||
// Click twice
|
||||
fireEvent.click(installButton)
|
||||
fireEvent.click(installButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromGitHub).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
resolveInstall!({ all_installed: true, task_id: 'task-1' })
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing onStartToInstall callback', async () => {
|
||||
render(<Loaded {...defaultProps} onStartToInstall={undefined} />)
|
||||
|
||||
// Should not throw when callback is undefined
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
}).not.toThrow()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromGitHub).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle plugin without plugin_id', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Loaded {...defaultProps} payload={createMockPayload()} />)
|
||||
|
||||
expect(mockUseCheckInstalled).toHaveBeenCalledWith({
|
||||
pluginIds: [undefined],
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve state after component update', () => {
|
||||
const { rerender } = render(<Loaded {...defaultProps} />)
|
||||
|
||||
rerender(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,22 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { type Plugin, type PluginDeclaration, TaskStatus, type UpdateFromGitHubPayload } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { updateFromGitHub } from '@/service/plugins'
|
||||
import { useInstallPackageFromGitHub } from '@/service/use-plugins'
|
||||
import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { usePluginTaskList } from '@/service/use-plugins'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { parseGitHubUrl } from '../../utils'
|
||||
import { updateFromGitHub } from '@/service/plugins'
|
||||
import { useInstallPackageFromGitHub, usePluginTaskList } from '@/service/use-plugins'
|
||||
import Card from '../../../card'
|
||||
import { TaskStatus } from '../../../types'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import Version from '../../base/version'
|
||||
import { parseGitHubUrl, pluginManifestToCardPluginProps } from '../../utils'
|
||||
|
||||
type LoadedProps = {
|
||||
updatePayload: UpdateFromGitHubPayload
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
uniqueIdentifier: string
|
||||
payload: PluginDeclaration | Plugin
|
||||
repoUrl: string
|
||||
@ -28,7 +28,7 @@ type LoadedProps = {
|
||||
onFailed: (message?: string) => void
|
||||
}
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
const Loaded: React.FC<LoadedProps> = ({
|
||||
updatePayload,
|
||||
@ -64,7 +64,8 @@ const Loaded: React.FC<LoadedProps> = ({
|
||||
}, [hasInstalled])
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return
|
||||
if (isInstalling)
|
||||
return
|
||||
setIsInstalling(true)
|
||||
onStartToInstall?.()
|
||||
|
||||
@ -142,34 +143,36 @@ const Loaded: React.FC<LoadedProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`)}</p>
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
payload={pluginManifestToCardPluginProps(payload as PluginDeclaration)}
|
||||
titleLeft={!isLoading && <Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>}
|
||||
titleLeft={!isLoading && (
|
||||
<Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-4 flex items-center justify-end gap-2 self-stretch'>
|
||||
<div className="mt-4 flex items-center justify-end gap-2 self-stretch">
|
||||
{!isInstalling && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={onBack}>
|
||||
{t('plugin.installModal.back')}
|
||||
<Button variant="secondary" className="min-w-[72px]" onClick={onBack}>
|
||||
{t('installModal.back', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
variant="primary"
|
||||
className="flex min-w-[72px] space-x-0.5"
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling || isLoading}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
{isInstalling && <RiLoader2Line className="h-4 w-4 animate-spin-slow" />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`, { ns: 'plugin' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,877 @@
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
import SelectPackage from './selectPackage'
|
||||
|
||||
// Mock the useGitHubUpload hook
|
||||
const mockHandleUpload = vi.fn()
|
||||
vi.mock('../../hooks', () => ({
|
||||
useGitHubUpload: () => ({ handleUpload: mockHandleUpload }),
|
||||
}))
|
||||
|
||||
// Factory functions
|
||||
const createMockManifest = (): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'Test Description' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
})
|
||||
|
||||
const createVersions = (): Item[] => [
|
||||
{ value: 'v1.0.0', name: 'v1.0.0' },
|
||||
{ value: 'v0.9.0', name: 'v0.9.0' },
|
||||
]
|
||||
|
||||
const createPackages = (): Item[] => [
|
||||
{ value: 'plugin.zip', name: 'plugin.zip' },
|
||||
{ value: 'plugin.tar.gz', name: 'plugin.tar.gz' },
|
||||
]
|
||||
|
||||
const createUpdatePayload = (): UpdateFromGitHubPayload => ({
|
||||
originalPackageInfo: {
|
||||
id: 'original-id',
|
||||
repo: 'owner/repo',
|
||||
version: 'v0.9.0',
|
||||
package: 'plugin.zip',
|
||||
releases: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Test props type - updatePayload is optional for testing
|
||||
type TestProps = {
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
repoUrl?: string
|
||||
selectedVersion?: string
|
||||
versions?: Item[]
|
||||
onSelectVersion?: (item: Item) => void
|
||||
selectedPackage?: string
|
||||
packages?: Item[]
|
||||
onSelectPackage?: (item: Item) => void
|
||||
onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void
|
||||
onFailed?: (errorMsg: string) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
describe('SelectPackage', () => {
|
||||
const createDefaultProps = () => ({
|
||||
updatePayload: undefined as UpdateFromGitHubPayload | undefined,
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: '',
|
||||
versions: createVersions(),
|
||||
onSelectVersion: vi.fn() as (item: Item) => void,
|
||||
selectedPackage: '',
|
||||
packages: createPackages(),
|
||||
onSelectPackage: vi.fn() as (item: Item) => void,
|
||||
onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void,
|
||||
onFailed: vi.fn() as (errorMsg: string) => void,
|
||||
onBack: vi.fn() as () => void,
|
||||
})
|
||||
|
||||
// Helper function to render with proper type handling
|
||||
const renderSelectPackage = (overrides: TestProps = {}) => {
|
||||
const props = { ...createDefaultProps(), ...overrides }
|
||||
// Cast to any to bypass strict type checking since component accepts optional updatePayload
|
||||
return render(<SelectPackage {...(props as Parameters<typeof SelectPackage>[0])} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHandleUpload.mockReset()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render version label', () => {
|
||||
renderSelectPackage()
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render package label', () => {
|
||||
renderSelectPackage()
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render back button when not in edit mode', () => {
|
||||
renderSelectPackage({ updatePayload: undefined })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render back button when in edit mode', () => {
|
||||
renderSelectPackage({ updatePayload: createUpdatePayload() })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render next button', () => {
|
||||
renderSelectPackage()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Tests
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should pass selectedVersion to PortalSelect', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// PortalSelect should display the selected version
|
||||
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass selectedPackage to PortalSelect', () => {
|
||||
renderSelectPackage({ selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByText('plugin.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show installed version badge when updatePayload version differs', () => {
|
||||
renderSelectPackage({
|
||||
updatePayload: createUpdatePayload(),
|
||||
selectedVersion: 'v1.0.0',
|
||||
})
|
||||
|
||||
expect(screen.getByText(/v0\.9\.0\s*->\s*v1\.0\.0/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button State Tests
|
||||
// ================================
|
||||
describe('Button State', () => {
|
||||
it('should disable next button when no version selected', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when version selected but no package', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable next button when both version and package selected', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const onBack = vi.fn()
|
||||
renderSelectPackage({ onBack })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleUploadPackage when next button is clicked', async () => {
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not invoke upload when next button is disabled', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
expect(mockHandleUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Upload Handling Tests
|
||||
// ================================
|
||||
describe('Upload Handling', () => {
|
||||
it('should call onUploaded with correct data on successful upload', async () => {
|
||||
const mockManifest = createMockManifest()
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'test-uid', manifest: mockManifest })
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'test-uid',
|
||||
manifest: mockManifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed with response message on upload error', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: { message: 'API Error' } })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('API Error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed with default message when no response message', async () => {
|
||||
mockHandleUpload.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call upload twice when already uploading', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'plugin.installModal.next' })
|
||||
|
||||
// Click twice rapidly - this tests the isUploading guard at line 49-50
|
||||
// The first click starts the upload, the second should be ignored
|
||||
fireEvent.click(nextButton)
|
||||
fireEvent.click(nextButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Resolve the upload
|
||||
resolveUpload!()
|
||||
})
|
||||
|
||||
it('should disable back button while uploading', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
|
||||
it('should strip github.com prefix from repoUrl', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/myorg/myrepo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'myorg/myrepo',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty versions array', () => {
|
||||
renderSelectPackage({ versions: [] })
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty packages array', () => {
|
||||
renderSelectPackage({ packages: [] })
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle updatePayload with installed version', () => {
|
||||
renderSelectPackage({ updatePayload: createUpdatePayload() })
|
||||
|
||||
// Should not show back button in edit mode
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-enable buttons after upload completes', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-enable buttons after upload fails', async () => {
|
||||
mockHandleUpload.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// PortalSelect Readonly State Tests
|
||||
// ================================
|
||||
describe('PortalSelect Readonly State', () => {
|
||||
it('should make package select readonly when no version selected', () => {
|
||||
renderSelectPackage({ selectedVersion: '' })
|
||||
|
||||
// When no version is selected, package select should be readonly
|
||||
// This is tested by verifying the component renders correctly
|
||||
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
|
||||
expect(trigger).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should make package select active when version is selected', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// When version is selected, package select should be active
|
||||
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
|
||||
expect(trigger).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// installedValue Props Tests
|
||||
// ================================
|
||||
describe('installedValue Props', () => {
|
||||
it('should pass installedValue when updatePayload is provided', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
renderSelectPackage({ updatePayload })
|
||||
|
||||
// The installed version should be passed to PortalSelect
|
||||
// updatePayload.originalPackageInfo.version = 'v0.9.0'
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not pass installedValue when updatePayload is undefined', () => {
|
||||
renderSelectPackage({ updatePayload: undefined })
|
||||
|
||||
// No installed version indicator
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle updatePayload with different version value', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
updatePayload.originalPackageInfo.version = 'v2.0.0'
|
||||
renderSelectPackage({ updatePayload })
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show installed badge in version list', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
renderSelectPackage({ updatePayload, selectedVersion: '' })
|
||||
|
||||
fireEvent.click(screen.getByText('plugin.installFromGitHub.selectVersionPlaceholder'))
|
||||
|
||||
expect(screen.getByText('INSTALLED')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Next Button Disabled State Combinations
|
||||
// ================================
|
||||
describe('Next Button Disabled State Combinations', () => {
|
||||
it('should disable next button when only version is missing', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when only package is missing', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when both are missing', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when uploading even with valid selections', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// RepoUrl Format Handling Tests
|
||||
// ================================
|
||||
describe('RepoUrl Format Handling', () => {
|
||||
it('should handle repoUrl without trailing slash', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle repoUrl with different org/repo combinations', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/my-organization/my-plugin-repo',
|
||||
selectedVersion: 'v2.0.0',
|
||||
selectedPackage: 'build.tar.gz',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'my-organization/my-plugin-repo',
|
||||
'v2.0.0',
|
||||
'build.tar.gz',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass through repoUrl without github prefix', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'plain-org/plain-repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'plain-org/plain-repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// isEdit Mode Comprehensive Tests
|
||||
// ================================
|
||||
describe('isEdit Mode Comprehensive', () => {
|
||||
it('should set isEdit to true when updatePayload is truthy', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
renderSelectPackage({ updatePayload })
|
||||
|
||||
// Back button should not be rendered in edit mode
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set isEdit to false when updatePayload is undefined', () => {
|
||||
renderSelectPackage({ updatePayload: undefined })
|
||||
|
||||
// Back button should be rendered when not in edit mode
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow upload in edit mode without back button', async () => {
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
updatePayload: createUpdatePayload(),
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Response Handling Tests
|
||||
// ================================
|
||||
describe('Error Response Handling', () => {
|
||||
it('should handle error with response.message property', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: { message: 'Custom API Error' } })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Custom API Error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error with empty response object', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: {} })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error without response property', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ code: 'NETWORK_ERROR' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error with response but no message', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle string error', async () => {
|
||||
mockHandleUpload.mockRejectedValue('String error message')
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Props Tests
|
||||
// ================================
|
||||
describe('Callback Props', () => {
|
||||
it('should pass onSelectVersion to PortalSelect', () => {
|
||||
const onSelectVersion = vi.fn()
|
||||
renderSelectPackage({ onSelectVersion })
|
||||
|
||||
// The callback is passed to PortalSelect, which is a base component
|
||||
// We verify it's rendered correctly
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass onSelectPackage to PortalSelect', () => {
|
||||
const onSelectPackage = vi.fn()
|
||||
renderSelectPackage({ onSelectPackage })
|
||||
|
||||
// The callback is passed to PortalSelect, which is a base component
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Upload State Management Tests
|
||||
// ================================
|
||||
describe('Upload State Management', () => {
|
||||
it('should set isUploading to true when upload starts', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
// Both buttons should be disabled during upload
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
|
||||
it('should set isUploading to false after successful upload', async () => {
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
|
||||
})
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isUploading to false after failed upload', async () => {
|
||||
mockHandleUpload.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow back button click while uploading', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
const onBack = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onBack,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
// Try to click back button while disabled
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
|
||||
|
||||
// onBack should not be called
|
||||
expect(onBack).not.toHaveBeenCalled()
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// handleUpload Callback Tests
|
||||
// ================================
|
||||
describe('handleUpload Callback', () => {
|
||||
it('should invoke onSuccess callback with correct data structure', async () => {
|
||||
const mockManifest = createMockManifest()
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({
|
||||
unique_identifier: 'test-unique-identifier',
|
||||
manifest: mockManifest,
|
||||
})
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
manifest: mockManifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct repo, version, and package to handleUpload', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/test-org/test-repo',
|
||||
selectedVersion: 'v3.0.0',
|
||||
selectedPackage: 'release.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'test-org/test-repo',
|
||||
'v3.0.0',
|
||||
'release.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { useGitHubUpload } from '../../hooks'
|
||||
|
||||
const i18nPrefix = 'plugin.installFromGitHub'
|
||||
const i18nPrefix = 'installFromGitHub'
|
||||
|
||||
type SelectPackageProps = {
|
||||
updatePayload: UpdateFromGitHubPayload
|
||||
@ -46,7 +46,8 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
|
||||
const handleUploadPackage = async () => {
|
||||
if (isUploading) return
|
||||
if (isUploading)
|
||||
return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const repo = repoUrl.replace('https://github.com/', '')
|
||||
@ -61,7 +62,7 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
|
||||
if (e.response?.message)
|
||||
onFailed(e.response?.message)
|
||||
else
|
||||
onFailed(t(`${i18nPrefix}.uploadFailed`))
|
||||
onFailed(t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' }))
|
||||
}
|
||||
finally {
|
||||
setIsUploading(false)
|
||||
@ -71,53 +72,54 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor='version'
|
||||
className='flex flex-col items-start justify-center self-stretch text-text-secondary'
|
||||
htmlFor="version"
|
||||
className="flex flex-col items-start justify-center self-stretch text-text-secondary"
|
||||
>
|
||||
<span className='system-sm-semibold'>{t(`${i18nPrefix}.selectVersion`)}</span>
|
||||
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectVersion`, { ns: 'plugin' })}</span>
|
||||
</label>
|
||||
<PortalSelect
|
||||
value={selectedVersion}
|
||||
onSelect={onSelectVersion}
|
||||
items={versions}
|
||||
installedValue={updatePayload?.originalPackageInfo.version}
|
||||
placeholder={t(`${i18nPrefix}.selectVersionPlaceholder`) || ''}
|
||||
popupClassName='w-[512px] z-[1001]'
|
||||
triggerClassName='text-components-input-text-filled'
|
||||
placeholder={t(`${i18nPrefix}.selectVersionPlaceholder`, { ns: 'plugin' }) || ''}
|
||||
popupClassName="w-[512px] z-[1001]"
|
||||
triggerClassName="text-components-input-text-filled"
|
||||
/>
|
||||
<label
|
||||
htmlFor='package'
|
||||
className='flex flex-col items-start justify-center self-stretch text-text-secondary'
|
||||
htmlFor="package"
|
||||
className="flex flex-col items-start justify-center self-stretch text-text-secondary"
|
||||
>
|
||||
<span className='system-sm-semibold'>{t(`${i18nPrefix}.selectPackage`)}</span>
|
||||
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectPackage`, { ns: 'plugin' })}</span>
|
||||
</label>
|
||||
<PortalSelect
|
||||
value={selectedPackage}
|
||||
onSelect={onSelectPackage}
|
||||
items={packages}
|
||||
readonly={!selectedVersion}
|
||||
placeholder={t(`${i18nPrefix}.selectPackagePlaceholder`) || ''}
|
||||
popupClassName='w-[512px] z-[1001]'
|
||||
triggerClassName='text-components-input-text-filled'
|
||||
placeholder={t(`${i18nPrefix}.selectPackagePlaceholder`, { ns: 'plugin' }) || ''}
|
||||
popupClassName="w-[512px] z-[1001]"
|
||||
triggerClassName="text-components-input-text-filled"
|
||||
/>
|
||||
<div className='mt-4 flex items-center justify-end gap-2 self-stretch'>
|
||||
<div className="mt-4 flex items-center justify-end gap-2 self-stretch">
|
||||
{!isEdit
|
||||
&& <Button
|
||||
variant='secondary'
|
||||
className='min-w-[72px]'
|
||||
onClick={onBack}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{t('plugin.installModal.back')}
|
||||
</Button>
|
||||
}
|
||||
&& (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="min-w-[72px]"
|
||||
onClick={onBack}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{t('installModal.back', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
variant="primary"
|
||||
className="min-w-[72px]"
|
||||
onClick={handleUploadPackage}
|
||||
disabled={!selectedVersion || !selectedPackage || isUploading}
|
||||
>
|
||||
{t('plugin.installModal.next')}
|
||||
{t('installModal.next', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,180 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SetURL from './setURL'
|
||||
|
||||
describe('SetURL', () => {
|
||||
const defaultProps = {
|
||||
repoUrl: '',
|
||||
onChange: vi.fn(),
|
||||
onNext: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render label with GitHub repo text', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.gitHubRepo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input field with correct attributes', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('type', 'url')
|
||||
expect(input).toHaveAttribute('id', 'repoUrl')
|
||||
expect(input).toHaveAttribute('name', 'repoUrl')
|
||||
expect(input).toHaveAttribute('placeholder', 'Please enter GitHub repo URL')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render next button', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should associate label with input field', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Tests
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should display repoUrl value in input', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('https://github.com/test/repo')
|
||||
})
|
||||
|
||||
it('should display empty string when repoUrl is empty', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="" />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when input value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SetURL {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith('https://github.com/owner/repo')
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<SetURL {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.cancel' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onNext when next button is clicked', () => {
|
||||
const onNext = vi.fn()
|
||||
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" onNext={onNext} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
expect(onNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button State Tests
|
||||
// ================================
|
||||
describe('Button State', () => {
|
||||
it('should disable next button when repoUrl is empty', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when repoUrl is only whitespace', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl=" " />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable next button when repoUrl has content', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable cancel button regardless of repoUrl', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle URL with special characters', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SetURL {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://github.com/test-org/repo_name-123' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('https://github.com/test-org/repo_name-123')
|
||||
})
|
||||
|
||||
it('should handle very long URLs', () => {
|
||||
const longUrl = `https://github.com/${'a'.repeat(100)}/${'b'.repeat(100)}`
|
||||
render(<SetURL {...defaultProps} repoUrl={longUrl} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue(longUrl)
|
||||
})
|
||||
|
||||
it('should handle onChange with empty string', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SetURL {...defaultProps} repoUrl="some-value" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should preserve callback references on rerender', () => {
|
||||
const onNext = vi.fn()
|
||||
const { rerender } = render(<SetURL {...defaultProps} repoUrl="https://github.com/a/b" onNext={onNext} />)
|
||||
|
||||
rerender(<SetURL {...defaultProps} repoUrl="https://github.com/a/b" onNext={onNext} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
expect(onNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type SetURLProps = {
|
||||
repoUrl: string
|
||||
@ -16,37 +16,37 @@ const SetURL: React.FC<SetURLProps> = ({ repoUrl, onChange, onNext, onCancel })
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor='repoUrl'
|
||||
className='flex flex-col items-start justify-center self-stretch text-text-secondary'
|
||||
htmlFor="repoUrl"
|
||||
className="flex flex-col items-start justify-center self-stretch text-text-secondary"
|
||||
>
|
||||
<span className='system-sm-semibold'>{t('plugin.installFromGitHub.gitHubRepo')}</span>
|
||||
<span className="system-sm-semibold">{t('installFromGitHub.gitHubRepo', { ns: 'plugin' })}</span>
|
||||
</label>
|
||||
<input
|
||||
type='url'
|
||||
id='repoUrl'
|
||||
name='repoUrl'
|
||||
type="url"
|
||||
id="repoUrl"
|
||||
name="repoUrl"
|
||||
value={repoUrl}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className='shadows-shadow-xs system-sm-regular flex grow items-center gap-[2px]
|
||||
className="shadows-shadow-xs system-sm-regular flex grow items-center gap-[2px]
|
||||
self-stretch overflow-hidden text-ellipsis rounded-lg border border-components-input-border-active
|
||||
bg-components-input-bg-active p-2 text-components-input-text-filled'
|
||||
placeholder='Please enter GitHub repo URL'
|
||||
bg-components-input-bg-active p-2 text-components-input-text-filled"
|
||||
placeholder="Please enter GitHub repo URL"
|
||||
/>
|
||||
<div className='mt-4 flex items-center justify-end gap-2 self-stretch'>
|
||||
<div className="mt-4 flex items-center justify-end gap-2 self-stretch">
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='min-w-[72px]'
|
||||
variant="secondary"
|
||||
className="min-w-[72px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('plugin.installModal.cancel')}
|
||||
{t('installModal.cancel', { ns: 'plugin' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
variant="primary"
|
||||
className="min-w-[72px]"
|
||||
onClick={onNext}
|
||||
disabled={!repoUrl.trim()}
|
||||
>
|
||||
{t('plugin.installModal.next')}
|
||||
{t('installModal.next', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { Dependency, PluginDeclaration } from '../../types'
|
||||
import { InstallStep } from '../../types'
|
||||
import Uploading from './steps/uploading'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import ReadyToInstallPackage from './ready-to-install'
|
||||
import ReadyToInstallBundle from '../install-bundle/ready-to-install'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { InstallStep } from '../../types'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import cn from '@/utils/classnames'
|
||||
import ReadyToInstallBundle from '../install-bundle/ready-to-install'
|
||||
import ReadyToInstallPackage from './ready-to-install'
|
||||
import Uploading from './steps/uploading'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type InstallFromLocalPackageProps = {
|
||||
file: File
|
||||
@ -42,15 +43,15 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (step === InstallStep.uploadFailed)
|
||||
return t(`${i18nPrefix}.uploadFailed`)
|
||||
return t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' })
|
||||
if (isBundle && step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installComplete`)
|
||||
return t(`${i18nPrefix}.installComplete`, { ns: 'plugin' })
|
||||
if (step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`, { ns: 'plugin' })
|
||||
if (step === InstallStep.installFailed)
|
||||
return t(`${i18nPrefix}.installFailed`)
|
||||
return t(`${i18nPrefix}.installFailed`, { ns: 'plugin' })
|
||||
|
||||
return t(`${i18nPrefix}.installPlugin`)
|
||||
return t(`${i18nPrefix}.installPlugin`, { ns: 'plugin' })
|
||||
}, [isBundle, step, t])
|
||||
|
||||
const { getIconUrl } = useGetIcon()
|
||||
@ -91,8 +92,8 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
<div className="flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
|
||||
<div className="title-2xl-semi-bold self-stretch text-text-primary">
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
@ -106,28 +107,30 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
onFailed={handleUploadFail}
|
||||
/>
|
||||
)}
|
||||
{isBundle ? (
|
||||
<ReadyToInstallBundle
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
allPlugins={dependencies}
|
||||
/>
|
||||
) : (
|
||||
<ReadyToInstallPackage
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
manifest={manifest}
|
||||
errorMsg={errorMsg}
|
||||
onError={setErrorMsg}
|
||||
/>
|
||||
)}
|
||||
{isBundle
|
||||
? (
|
||||
<ReadyToInstallBundle
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
allPlugins={dependencies}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ReadyToInstallPackage
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
manifest={manifest}
|
||||
errorMsg={errorMsg}
|
||||
onError={setErrorMsg}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,471 @@
|
||||
import type { PluginDeclaration } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../types'
|
||||
import ReadyToInstall from './ready-to-install'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: mockRefreshPluginList,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Install component
|
||||
let _installOnInstalled: ((notRefresh?: boolean) => void) | null = null
|
||||
let _installOnFailed: ((message?: string) => void) | null = null
|
||||
let _installOnCancel: (() => void) | null = null
|
||||
let _installOnStartToInstall: (() => void) | null = null
|
||||
|
||||
vi.mock('./steps/install', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
onCancel,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
}: {
|
||||
uniqueIdentifier: string
|
||||
payload: PluginDeclaration
|
||||
onCancel: () => void
|
||||
onStartToInstall?: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
}) => {
|
||||
_installOnInstalled = onInstalled
|
||||
_installOnFailed = onFailed
|
||||
_installOnCancel = onCancel
|
||||
_installOnStartToInstall = onStartToInstall ?? null
|
||||
return (
|
||||
<div data-testid="install-step">
|
||||
<span data-testid="install-uid">{uniqueIdentifier}</span>
|
||||
<span data-testid="install-payload-name">{payload.name}</span>
|
||||
<button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="install-start-btn" onClick={() => onStartToInstall?.()}>
|
||||
Start Install
|
||||
</button>
|
||||
<button data-testid="install-installed-btn" onClick={() => onInstalled()}>
|
||||
Installed
|
||||
</button>
|
||||
<button data-testid="install-installed-no-refresh-btn" onClick={() => onInstalled(true)}>
|
||||
Installed (No Refresh)
|
||||
</button>
|
||||
<button data-testid="install-failed-btn" onClick={() => onFailed()}>
|
||||
Failed
|
||||
</button>
|
||||
<button data-testid="install-failed-msg-btn" onClick={() => onFailed('Error message')}>
|
||||
Failed with Message
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Installed component
|
||||
vi.mock('../base/installed', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isFailed,
|
||||
errMsg,
|
||||
onCancel,
|
||||
}: {
|
||||
payload: PluginDeclaration | null
|
||||
isFailed: boolean
|
||||
errMsg: string | null
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="installed-step">
|
||||
<span data-testid="installed-payload-name">{payload?.name || 'null'}</span>
|
||||
<span data-testid="installed-is-failed">{isFailed ? 'true' : 'false'}</span>
|
||||
<span data-testid="installed-err-msg">{errMsg || 'null'}</span>
|
||||
<button data-testid="installed-cancel-btn" onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ReadyToInstall', () => {
|
||||
const defaultProps = {
|
||||
step: InstallStep.readyToInstall,
|
||||
onStepChange: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
setIsInstalling: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
manifest: createMockManifest(),
|
||||
errorMsg: null as string | null,
|
||||
onError: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_installOnInstalled = null
|
||||
_installOnFailed = null
|
||||
_installOnCancel = null
|
||||
_installOnStartToInstall = null
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render Install component when step is readyToInstall', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Installed component when step is uploadFailed', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.uploadFailed} />)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Installed component when step is installed', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Installed component when step is installFailed', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} />)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Passing Tests
|
||||
// ================================
|
||||
describe('Props Passing', () => {
|
||||
it('should pass uniqueIdentifier to Install component', () => {
|
||||
render(<ReadyToInstall {...defaultProps} uniqueIdentifier="custom-uid" />)
|
||||
|
||||
expect(screen.getByTestId('install-uid')).toHaveTextContent('custom-uid')
|
||||
})
|
||||
|
||||
it('should pass manifest to Install component', () => {
|
||||
const manifest = createMockManifest({ name: 'Custom Plugin' })
|
||||
render(<ReadyToInstall {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-payload-name')).toHaveTextContent('Custom Plugin')
|
||||
})
|
||||
|
||||
it('should pass manifest to Installed component', () => {
|
||||
const manifest = createMockManifest({ name: 'Installed Plugin' })
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('Installed Plugin')
|
||||
})
|
||||
|
||||
it('should pass errorMsg to Installed component', () => {
|
||||
render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installFailed}
|
||||
errorMsg="Some error"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('Some error')
|
||||
})
|
||||
|
||||
it('should pass isFailed=true for uploadFailed step', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.uploadFailed} />)
|
||||
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass isFailed=true for installFailed step', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} />)
|
||||
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass isFailed=false for installed step', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />)
|
||||
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// handleInstalled Callback Tests
|
||||
// ================================
|
||||
describe('handleInstalled Callback', () => {
|
||||
it('should call onStepChange with installed when handleInstalled is triggered', () => {
|
||||
const onStepChange = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onStepChange={onStepChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
})
|
||||
|
||||
it('should call refreshPluginList when handleInstalled is triggered without notRefresh', () => {
|
||||
const manifest = createMockManifest()
|
||||
render(<ReadyToInstall {...defaultProps} manifest={manifest} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(mockRefreshPluginList).toHaveBeenCalledWith(manifest)
|
||||
})
|
||||
|
||||
it('should not call refreshPluginList when handleInstalled is triggered with notRefresh=true', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-no-refresh-btn'))
|
||||
|
||||
expect(mockRefreshPluginList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setIsInstalling(false) when handleInstalled is triggered', () => {
|
||||
const setIsInstalling = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} setIsInstalling={setIsInstalling} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// handleFailed Callback Tests
|
||||
// ================================
|
||||
describe('handleFailed Callback', () => {
|
||||
it('should call onStepChange with installFailed when handleFailed is triggered', () => {
|
||||
const onStepChange = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onStepChange={onStepChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
|
||||
})
|
||||
|
||||
it('should call setIsInstalling(false) when handleFailed is triggered', () => {
|
||||
const setIsInstalling = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} setIsInstalling={setIsInstalling} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call onError when handleFailed is triggered with error message', () => {
|
||||
const onError = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onError={onError} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-msg-btn'))
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Error message')
|
||||
})
|
||||
|
||||
it('should not call onError when handleFailed is triggered without error message', () => {
|
||||
const onError = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onError={onError} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// onClose Callback Tests
|
||||
// ================================
|
||||
describe('onClose Callback', () => {
|
||||
it('should call onClose when cancel is clicked in Install component', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-cancel-btn'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel is clicked in Installed component', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('installed-cancel-btn'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// onStartToInstall Callback Tests
|
||||
// ================================
|
||||
describe('onStartToInstall Callback', () => {
|
||||
it('should pass onStartToInstall to Install component', () => {
|
||||
const onStartToInstall = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onStartToInstall={onStartToInstall} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-start-btn'))
|
||||
|
||||
expect(onStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Step Transitions Tests
|
||||
// ================================
|
||||
describe('Step Transitions', () => {
|
||||
it('should handle transition from readyToInstall to installed', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />,
|
||||
)
|
||||
|
||||
// Initially shows Install component
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
// Simulate successful installation
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
|
||||
// Rerender with new step
|
||||
rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onStepChange={onStepChange} />)
|
||||
|
||||
// Now shows Installed component
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle transition from readyToInstall to installFailed', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />,
|
||||
)
|
||||
|
||||
// Initially shows Install component
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
// Simulate failed installation
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
|
||||
|
||||
// Rerender with new step
|
||||
rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} onStepChange={onStepChange} />)
|
||||
|
||||
// Now shows Installed component with failed state
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null manifest', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} manifest={null} />)
|
||||
|
||||
expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('null')
|
||||
})
|
||||
|
||||
it('should handle null errorMsg', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg={null} />)
|
||||
|
||||
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null')
|
||||
})
|
||||
|
||||
it('should handle empty string errorMsg', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg="" />)
|
||||
|
||||
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Stability Tests
|
||||
// ================================
|
||||
describe('Callback Stability', () => {
|
||||
it('should maintain stable handleInstalled callback across re-renders', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const setIsInstalling = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Callback should still work
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should maintain stable handleFailed callback across re-renders', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const setIsInstalling = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Callback should still work
|
||||
fireEvent.click(screen.getByTestId('install-failed-msg-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
expect(onError).toHaveBeenCalledWith('Error message')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,22 +1,23 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import type { PluginDeclaration } from '../../types'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { InstallStep } from '../../types'
|
||||
import Install from './steps/install'
|
||||
import Installed from '../base/installed'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
import Install from './steps/install'
|
||||
|
||||
type Props = {
|
||||
step: InstallStep
|
||||
onStepChange: (step: InstallStep) => void,
|
||||
onStepChange: (step: InstallStep) => void
|
||||
onStartToInstall: () => void
|
||||
setIsInstalling: (isInstalling: boolean) => void
|
||||
onClose: () => void
|
||||
uniqueIdentifier: string | null,
|
||||
manifest: PluginDeclaration | null,
|
||||
errorMsg: string | null,
|
||||
onError: (errorMsg: string) => void,
|
||||
uniqueIdentifier: string | null
|
||||
manifest: PluginDeclaration | null
|
||||
errorMsg: string | null
|
||||
onError: (errorMsg: string) => void
|
||||
}
|
||||
|
||||
const ReadyToInstall: FC<Props> = ({
|
||||
|
||||
@ -0,0 +1,626 @@
|
||||
import type { PluginDeclaration } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../types'
|
||||
import Install from './install'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0', minimum_dify_version: '0.8.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock external dependencies
|
||||
const mockUseCheckInstalled = vi.fn()
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => mockUseCheckInstalled(),
|
||||
}))
|
||||
|
||||
const mockInstallPackageFromLocal = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromLocal: () => ({
|
||||
mutateAsync: mockInstallPackageFromLocal,
|
||||
}),
|
||||
usePluginTaskList: () => ({
|
||||
handleRefetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUninstallPlugin = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: (...args: unknown[]) => mockUninstallPlugin(...args),
|
||||
}))
|
||||
|
||||
const mockCheck = vi.fn()
|
||||
const mockStop = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheck,
|
||||
stop: mockStop,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
langGeniusVersionInfo: mockLangGeniusVersionInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
// Handle interpolation params (excluding ns)
|
||||
const { ns: _ns, ...params } = options || {}
|
||||
if (Object.keys(params).length > 0) {
|
||||
return `${fullKey}:${JSON.stringify(params)}`
|
||||
}
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
|
||||
<span data-testid="trans">
|
||||
{i18nKey}
|
||||
{components?.trustSource}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: {
|
||||
payload: Record<string, unknown>
|
||||
titleLeft?: React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="card">
|
||||
<span data-testid="card-name">{payload?.name as string}</span>
|
||||
<div data-testid="card-title-left">{titleLeft}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
toInstallVersion: string
|
||||
}) => (
|
||||
<div data-testid="version">
|
||||
<span data-testid="version-has-installed">{hasInstalled ? 'true' : 'false'}</span>
|
||||
<span data-testid="version-installed">{installedVersion || 'null'}</span>
|
||||
<span data-testid="version-to-install">{toInstallVersion}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({
|
||||
name: manifest.name,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Install', () => {
|
||||
const defaultProps = {
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
payload: createMockManifest(),
|
||||
onCancel: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
onInstalled: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockInstallPackageFromLocal.mockReset()
|
||||
mockUninstallPlugin.mockReset()
|
||||
mockCheck.mockReset()
|
||||
mockStop.mockReset()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render ready to install message', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trust source message', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('trans')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render install button', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show version component when not loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show version component when loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('version')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Version Display Tests
|
||||
// ================================
|
||||
describe('Version Display', () => {
|
||||
it('should display toInstallVersion from payload', () => {
|
||||
const payload = createMockManifest({ version: '2.0.0' })
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('version-to-install')).toHaveTextContent('2.0.0')
|
||||
})
|
||||
|
||||
it('should display hasInstalled=false when not installed', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should display hasInstalled=true when already installed', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '0.9.0',
|
||||
installedId: 'installed-id',
|
||||
uniqueIdentifier: 'old-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-has-installed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('version-installed')).toHaveTextContent('0.9.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Install Button State Tests
|
||||
// ================================
|
||||
describe('Install Button State', () => {
|
||||
it('should disable install button when loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable install button when not loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Cancel Button Tests
|
||||
// ================================
|
||||
describe('Cancel Button', () => {
|
||||
it('should call onCancel and stop when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<Install {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(mockStop).toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should hide cancel button when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installation Flow Tests
|
||||
// ================================
|
||||
describe('Installation Flow', () => {
|
||||
it('should call onStartToInstall when install button is clicked', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
const onStartToInstall = vi.fn()
|
||||
render(<Install {...defaultProps} onStartToInstall={onStartToInstall} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled when all_installed is true', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check task status when all_installed is false', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test-unique-identifier',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed when task status is failed', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Task failed error' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Install {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Task failed error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should uninstall existing plugin before installing new version', async () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '0.9.0',
|
||||
installedId: 'installed-id-to-uninstall',
|
||||
uniqueIdentifier: 'old-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mockUninstallPlugin.mockResolvedValue({})
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('installed-id-to-uninstall')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromLocal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Handling Tests
|
||||
// ================================
|
||||
describe('Error Handling', () => {
|
||||
it('should call onFailed with error string', async () => {
|
||||
mockInstallPackageFromLocal.mockRejectedValue('Installation error string')
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Install {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Installation error string')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed without message when error is not string', async () => {
|
||||
mockInstallPackageFromLocal.mockRejectedValue({ code: 'ERROR' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Install {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Auto Install Behavior Tests
|
||||
// ================================
|
||||
describe('Auto Install Behavior', () => {
|
||||
it('should call onInstalled when already installed with same uniqueIdentifier', async () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '1.0.0',
|
||||
installedId: 'installed-id',
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-call onInstalled when uniqueIdentifier differs', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '1.0.0',
|
||||
installedId: 'installed-id',
|
||||
uniqueIdentifier: 'different-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dify Version Compatibility Tests
|
||||
// ================================
|
||||
describe('Dify Version Compatibility', () => {
|
||||
it('should not show warning when dify version is compatible', () => {
|
||||
mockLangGeniusVersionInfo.current_version = '1.0.0'
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when dify version is incompatible', () => {
|
||||
mockLangGeniusVersionInfo.current_version = '1.0.0'
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be compatible when minimum_dify_version is undefined', () => {
|
||||
mockLangGeniusVersionInfo.current_version = '1.0.0'
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be compatible when current_version is empty', () => {
|
||||
mockLangGeniusVersionInfo.current_version = ''
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
// When current_version is empty, should be compatible (no warning)
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be compatible when current_version is undefined', () => {
|
||||
mockLangGeniusVersionInfo.current_version = undefined as unknown as string
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
// When current_version is undefined, should be compatible (no warning)
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installing State Tests
|
||||
// ================================
|
||||
describe('Installing State', () => {
|
||||
it('should show installing text when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable install button when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.installing/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading spinner when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector('.animate-spin-slow')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger install twice when already installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' })
|
||||
|
||||
// Click install
|
||||
fireEvent.click(installButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Try to click again (button should be disabled but let's verify the guard works)
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.installing/ }))
|
||||
|
||||
// Should still only be called once due to isInstalling guard
|
||||
expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Props Tests
|
||||
// ================================
|
||||
describe('Callback Props', () => {
|
||||
it('should work without onStartToInstall callback', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(
|
||||
<Install
|
||||
{...defaultProps}
|
||||
onStartToInstall={undefined}
|
||||
onInstalled={onInstalled}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,21 +1,23 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { type PluginDeclaration, TaskStatus } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { PluginDeclaration } from '../../../types'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import Version from '../../base/version'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins'
|
||||
import Card from '../../../card'
|
||||
import { TaskStatus } from '../../../types'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import Version from '../../base/version'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type Props = {
|
||||
uniqueIdentifier: string
|
||||
@ -65,7 +67,8 @@ const Installed: FC<Props> = ({
|
||||
|
||||
const { handleRefetch } = usePluginTaskList(payload.category)
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return
|
||||
if (isInstalling)
|
||||
return
|
||||
setIsInstalling(true)
|
||||
onStartToInstall?.()
|
||||
|
||||
@ -113,48 +116,50 @@ const Installed: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`)}</p>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={`${i18nPrefix}.fromTrustSource`}
|
||||
components={{ trustSource: <span className='system-md-semibold' /> }}
|
||||
components={{ trustSource: <span className="system-md-semibold" /> }}
|
||||
/>
|
||||
</p>
|
||||
{!isDifyVersionCompatible && (
|
||||
<p className='system-md-regular flex items-center gap-1 text-text-warning'>
|
||||
{t('plugin.difyVersionNotCompatible', { minimalDifyVersion: payload.meta.minimum_dify_version })}
|
||||
<p className="system-md-regular flex items-center gap-1 text-text-warning">
|
||||
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: payload.meta.minimum_dify_version })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
payload={pluginManifestToCardPluginProps(payload)}
|
||||
titleLeft={!isLoading && <Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>}
|
||||
titleLeft={!isLoading && (
|
||||
<Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
{!isInstalling && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={handleCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
<Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
variant="primary"
|
||||
className="flex min-w-[72px] space-x-0.5"
|
||||
disabled={isInstalling || isLoading}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
{isInstalling && <RiLoader2Line className="h-4 w-4 animate-spin-slow" />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`, { ns: 'plugin' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,356 @@
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
import Uploading from './uploading'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockDependencies = (): Dependency[] => [
|
||||
{
|
||||
type: 'package',
|
||||
value: {
|
||||
unique_identifier: 'dep-1',
|
||||
manifest: createMockManifest({ name: 'Dep Plugin 1' }),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const createMockFile = (name: string = 'test-plugin.difypkg'): File => {
|
||||
return new File(['test content'], name, { type: 'application/octet-stream' })
|
||||
}
|
||||
|
||||
// Mock external dependencies
|
||||
const mockUploadFile = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadFile: (...args: unknown[]) => mockUploadFile(...args),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
// Handle interpolation params (excluding ns)
|
||||
const { ns: _ns, ...params } = options || {}
|
||||
if (Object.keys(params).length > 0) {
|
||||
return `${fullKey}:${JSON.stringify(params)}`
|
||||
}
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, isLoading, loadingFileName }: {
|
||||
payload: { name: string }
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
}) => (
|
||||
<div data-testid="card">
|
||||
<span data-testid="card-name">{payload?.name}</span>
|
||||
<span data-testid="card-is-loading">{isLoading ? 'true' : 'false'}</span>
|
||||
<span data-testid="card-loading-filename">{loadingFileName || 'null'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Uploading', () => {
|
||||
const defaultProps = {
|
||||
isBundle: false,
|
||||
file: createMockFile(),
|
||||
onCancel: vi.fn(),
|
||||
onPackageUploaded: vi.fn(),
|
||||
onBundleUploaded: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUploadFile.mockReset()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render uploading message with file name', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/plugin.installModal.uploadingPackage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading spinner', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
// The spinner has animate-spin-slow class
|
||||
const spinner = document.querySelector('.animate-spin-slow')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render card with loading state', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-is-loading')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should render card with file name', () => {
|
||||
const file = createMockFile('my-plugin.difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('my-plugin.difypkg')
|
||||
expect(screen.getByTestId('card-loading-filename')).toHaveTextContent('my-plugin.difypkg')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled install button', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' })
|
||||
expect(installButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Upload Behavior Tests
|
||||
// ================================
|
||||
describe('Upload Behavior', () => {
|
||||
it('should call uploadFile on mount', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call uploadFile with isBundle=true for bundle files', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} isBundle />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed when upload fails with error message', async () => {
|
||||
const errorMessage = 'Upload failed: file too large'
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: { message: errorMessage },
|
||||
})
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Uploading {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: The uploadFile API has an unconventional contract where it always rejects.
|
||||
// Success vs failure is determined by whether response.message exists:
|
||||
// - If response.message exists → treated as failure (calls onFailed)
|
||||
// - If response.message is absent → treated as success (calls onPackageUploaded/onBundleUploaded)
|
||||
// This explains why we use mockRejectedValue for "success" scenarios below.
|
||||
|
||||
it('should call onPackageUploaded when upload rejects without error message (success case)', async () => {
|
||||
const mockResult = {
|
||||
unique_identifier: 'test-uid',
|
||||
manifest: createMockManifest(),
|
||||
}
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: mockResult,
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(
|
||||
<Uploading
|
||||
{...defaultProps}
|
||||
isBundle={false}
|
||||
onPackageUploaded={onPackageUploaded}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: mockResult.unique_identifier,
|
||||
manifest: mockResult.manifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onBundleUploaded when upload rejects without error message (success case)', async () => {
|
||||
const mockDependencies = createMockDependencies()
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: mockDependencies,
|
||||
})
|
||||
|
||||
const onBundleUploaded = vi.fn()
|
||||
render(
|
||||
<Uploading
|
||||
{...defaultProps}
|
||||
isBundle
|
||||
onBundleUploaded={onBundleUploaded}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Cancel Button Tests
|
||||
// ================================
|
||||
describe('Cancel Button', () => {
|
||||
it('should call onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
render(<Uploading {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// File Name Display Tests
|
||||
// ================================
|
||||
describe('File Name Display', () => {
|
||||
it('should display correct file name for package file', () => {
|
||||
const file = createMockFile('custom-plugin.difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('custom-plugin.difypkg')
|
||||
})
|
||||
|
||||
it('should display correct file name for bundle file', () => {
|
||||
const file = createMockFile('custom-bundle.difybndl')
|
||||
render(<Uploading {...defaultProps} file={file} isBundle />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('custom-bundle.difybndl')
|
||||
})
|
||||
|
||||
it('should display file name in uploading message', () => {
|
||||
const file = createMockFile('special-plugin.difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
// The message includes the file name as a parameter
|
||||
expect(screen.getByText(/plugin\.installModal\.uploadingPackage/)).toHaveTextContent('special-plugin.difypkg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty response gracefully', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: {},
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: undefined,
|
||||
manifest: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle response with only unique_identifier', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: { unique_identifier: 'only-uid' },
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'only-uid',
|
||||
manifest: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle file with special characters in name', () => {
|
||||
const file = createMockFile('my plugin (v1.0).difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('my plugin (v1.0).difypkg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Variations Tests
|
||||
// ================================
|
||||
describe('Props Variations', () => {
|
||||
it('should work with different file types', () => {
|
||||
const files = [
|
||||
createMockFile('plugin-a.difypkg'),
|
||||
createMockFile('plugin-b.zip'),
|
||||
createMockFile('bundle.difybndl'),
|
||||
]
|
||||
|
||||
files.forEach((file) => {
|
||||
const { unmount } = render(<Uploading {...defaultProps} file={file} />)
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent(file.name)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isBundle=false to uploadFile for package files', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} isBundle={false} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isBundle=true to uploadFile for bundle files', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} isBundle />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import Card from '../../../card'
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { uploadFile } from '@/service/plugins'
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
import Card from '../../../card'
|
||||
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type Props = {
|
||||
isBundle: boolean
|
||||
@ -58,18 +59,19 @@ const Uploading: FC<Props> = ({
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<div className='flex items-center gap-1 self-stretch'>
|
||||
<RiLoader2Line className='h-4 w-4 animate-spin-slow text-text-accent' />
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="flex items-center gap-1 self-stretch">
|
||||
<RiLoader2Line className="h-4 w-4 animate-spin-slow text-text-accent" />
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
{t(`${i18nPrefix}.uploadingPackage`, {
|
||||
ns: 'plugin',
|
||||
packageName: fileName,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
payload={{ name: fileName } as any}
|
||||
isLoading
|
||||
loadingFileName={fileName}
|
||||
@ -79,16 +81,16 @@ const Uploading: FC<Props> = ({
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
<Button variant="secondary" className="min-w-[72px]" onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
variant="primary"
|
||||
className="min-w-[72px]"
|
||||
disabled
|
||||
>
|
||||
{t(`${i18nPrefix}.install`)}
|
||||
{t(`${i18nPrefix}.install`, { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,928 @@
|
||||
import type { Dependency, Plugin, PluginManifestInMarket } from '../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../types'
|
||||
import InstallFromMarketplace from './index'
|
||||
|
||||
// Factory functions for test data
|
||||
// Use type casting to avoid strict locale requirements in tests
|
||||
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
|
||||
plugin_unique_identifier: 'test-unique-identifier',
|
||||
name: 'Test Plugin',
|
||||
org: 'test-org',
|
||||
icon: 'test-icon.png',
|
||||
label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'],
|
||||
category: PluginCategoryEnum.tool,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'],
|
||||
introduction: 'Introduction text',
|
||||
verified: true,
|
||||
install_count: 100,
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-package-id',
|
||||
icon: 'test-icon.png',
|
||||
verified: true,
|
||||
label: { en_US: 'Test Plugin' },
|
||||
brief: { en_US: 'A test plugin' },
|
||||
description: { en_US: 'A test plugin description' },
|
||||
introduction: 'Introduction text',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockDependencies = (): Dependency[] => [
|
||||
{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'test/plugin1',
|
||||
version: 'v1.0.0',
|
||||
package: 'plugin1.zip',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
plugin_unique_identifier: 'plugin-2-uid',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
let mockHideLogicState = {
|
||||
modalClassName: 'test-modal-class',
|
||||
foldAnimInto: vi.fn(),
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
vi.mock('../hooks/use-hide-logic', () => ({
|
||||
default: () => mockHideLogicState,
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./steps/install', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
onCancel,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
onStartToInstall,
|
||||
}: {
|
||||
uniqueIdentifier: string
|
||||
payload: PluginManifestInMarket | Plugin
|
||||
onCancel: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
onStartToInstall: () => void
|
||||
}) => (
|
||||
<div data-testid="install-step">
|
||||
<span data-testid="unique-identifier">{uniqueIdentifier}</span>
|
||||
<span data-testid="payload-name">{payload?.name}</span>
|
||||
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button>
|
||||
<button data-testid="install-success-btn" onClick={() => onInstalled()}>Install Success</button>
|
||||
<button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}>Install Success No Refresh</button>
|
||||
<button data-testid="install-fail-btn" onClick={() => onFailed('Installation failed')}>Install Fail</button>
|
||||
<button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}>Install Fail No Msg</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../install-bundle/ready-to-install', () => ({
|
||||
default: ({
|
||||
step,
|
||||
onStepChange,
|
||||
onStartToInstall,
|
||||
setIsInstalling,
|
||||
onClose,
|
||||
allPlugins,
|
||||
isFromMarketPlace,
|
||||
}: {
|
||||
step: InstallStep
|
||||
onStepChange: (step: InstallStep) => void
|
||||
onStartToInstall: () => void
|
||||
setIsInstalling: (isInstalling: boolean) => void
|
||||
onClose: () => void
|
||||
allPlugins: Dependency[]
|
||||
isFromMarketPlace?: boolean
|
||||
}) => (
|
||||
<div data-testid="bundle-step">
|
||||
<span data-testid="bundle-step-value">{step}</span>
|
||||
<span data-testid="bundle-plugins-count">{allPlugins?.length || 0}</span>
|
||||
<span data-testid="is-from-marketplace">{isFromMarketPlace ? 'true' : 'false'}</span>
|
||||
<button data-testid="bundle-cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button data-testid="bundle-start-install-btn" onClick={onStartToInstall}>Start Install</button>
|
||||
<button data-testid="bundle-set-installing-true" onClick={() => setIsInstalling(true)}>Set Installing True</button>
|
||||
<button data-testid="bundle-set-installing-false" onClick={() => setIsInstalling(false)}>Set Installing False</button>
|
||||
<button data-testid="bundle-change-to-installed" onClick={() => onStepChange(InstallStep.installed)}>Change to Installed</button>
|
||||
<button data-testid="bundle-change-to-failed" onClick={() => onStepChange(InstallStep.installFailed)}>Change to Failed</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../base/installed', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isMarketPayload,
|
||||
isFailed,
|
||||
errMsg,
|
||||
onCancel,
|
||||
}: {
|
||||
payload: PluginManifestInMarket | Plugin | null
|
||||
isMarketPayload?: boolean
|
||||
isFailed: boolean
|
||||
errMsg?: string | null
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="installed-step">
|
||||
<span data-testid="installed-payload">{payload?.name || 'no-payload'}</span>
|
||||
<span data-testid="is-market-payload">{isMarketPayload ? 'true' : 'false'}</span>
|
||||
<span data-testid="is-failed">{isFailed ? 'true' : 'false'}</span>
|
||||
<span data-testid="error-msg">{errMsg || 'no-error'}</span>
|
||||
<button data-testid="installed-close-btn" onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InstallFromMarketplace', () => {
|
||||
const defaultProps = {
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
manifest: createMockManifest(),
|
||||
onSuccess: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHideLogicState = {
|
||||
modalClassName: 'test-modal-class',
|
||||
foldAnimInto: vi.fn(),
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render modal with correct initial state for single plugin', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with bundle step when isBundle is true', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should pass isFromMarketPlace as true to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('is-from-marketplace')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass correct props to Install component', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('test-unique-identifier')
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should apply modal className from useHideLogic', () => {
|
||||
expect(mockHideLogicState.modalClassName).toBe('test-modal-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Title Display Tests
|
||||
// ================================
|
||||
describe('Title Display', () => {
|
||||
it('should show install title in readyToInstall step', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success title when installation completes for single plugin', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show bundle complete title when bundle installation completes', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show failed title when installation fails', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
describe('State Management', () => {
|
||||
it('should transition from readyToInstall to installed on success', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should transition from readyToInstall to installFailed on failure', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle failure without error message', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-no-msg-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('error-msg')).toHaveTextContent('no-error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update step via onStepChange in bundle mode', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Stability Tests (Memoization)
|
||||
// ================================
|
||||
describe('Callback Stability', () => {
|
||||
it('should maintain stable getTitle callback across rerenders', () => {
|
||||
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
|
||||
rerender(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain stable handleInstalled callback', async () => {
|
||||
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
rerender(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain stable handleFailed callback', async () => {
|
||||
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
rerender(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when cancel is clicked', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-btn'))
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call foldAnimInto when modal close is triggered', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(mockHideLogicState.foldAnimInto).toBeDefined()
|
||||
})
|
||||
|
||||
it('should call handleStartToInstall when start install is triggered', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('start-install-btn'))
|
||||
|
||||
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSuccess when close button is clicked in installed step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('installed-close-btn'))
|
||||
|
||||
expect(defaultProps.onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose in bundle mode cancel', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-cancel-btn'))
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Refresh Plugin List Tests
|
||||
// ================================
|
||||
describe('Refresh Plugin List', () => {
|
||||
it('should call refreshPluginList when installation completes without notRefresh flag', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshPluginList).toHaveBeenCalledWith(defaultProps.manifest)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call refreshPluginList when notRefresh flag is true', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-no-refresh-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshPluginList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// setIsInstalling Tests
|
||||
// ================================
|
||||
describe('setIsInstalling Behavior', () => {
|
||||
it('should call setIsInstalling(false) when installation completes', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setIsInstalling(false) when installation fails', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass setIsInstalling to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-set-installing-true'))
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-set-installing-false'))
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installed Component Props Tests
|
||||
// ================================
|
||||
describe('Installed Component Props', () => {
|
||||
it('should pass isMarketPayload as true to Installed component', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('is-market-payload')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct payload to Installed component', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-payload')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isFailed as true when installation fails', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass error message to Installed component on failure', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Prop Variations Tests
|
||||
// ================================
|
||||
describe('Prop Variations', () => {
|
||||
it('should work with Plugin type manifest', () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
manifest={plugin}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should work with PluginManifestInMarket type manifest', () => {
|
||||
const manifest = createMockManifest({ name: 'Market Plugin' })
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
manifest={manifest}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Market Plugin')
|
||||
})
|
||||
|
||||
it('should handle different uniqueIdentifier values', () => {
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
uniqueIdentifier="custom-unique-id-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('custom-unique-id-123')
|
||||
})
|
||||
|
||||
it('should work without isBundle prop (default to single plugin)', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with isBundle=false', () => {
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with empty dependencies array in bundle mode', () => {
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle manifest with minimal required fields', () => {
|
||||
const minimalManifest = createMockManifest({
|
||||
name: 'Minimal',
|
||||
version: '0.0.1',
|
||||
})
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
manifest={minimalManifest}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Minimal')
|
||||
})
|
||||
|
||||
it('should handle multiple rapid state transitions', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
// Trigger installation completion
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should stay in installed state
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle bundle mode step changes', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change to installed step
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle bundle mode failure step change', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-failed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Install component in terminal steps', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Installed component for success state with isFailed false', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Installed component for failure state with isFailed true', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Terminal Steps Rendering Tests
|
||||
// ================================
|
||||
describe('Terminal Steps Rendering', () => {
|
||||
it('should render Installed component when step is installed', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Installed component when step is installFailed', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Install component when in terminal step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
// Initially Install is shown
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Data Flow Tests
|
||||
// ================================
|
||||
describe('Data Flow', () => {
|
||||
it('should pass uniqueIdentifier to Install component', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} uniqueIdentifier="flow-test-id" />)
|
||||
|
||||
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('flow-test-id')
|
||||
})
|
||||
|
||||
it('should pass manifest payload to Install component', () => {
|
||||
const customManifest = createMockManifest({ name: 'Flow Test Plugin' })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={customManifest} />)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Flow Test Plugin')
|
||||
})
|
||||
|
||||
it('should pass dependencies to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should pass current step to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-step-value')).toHaveTextContent(InstallStep.readyToInstall)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Manifest Category Variations Tests
|
||||
// ================================
|
||||
describe('Manifest Category Variations', () => {
|
||||
it('should handle tool category manifest', () => {
|
||||
const manifest = createMockManifest({ category: PluginCategoryEnum.tool })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle model category manifest', () => {
|
||||
const manifest = createMockManifest({ category: PluginCategoryEnum.model })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle extension category manifest', () => {
|
||||
const manifest = createMockManifest({ category: PluginCategoryEnum.extension })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Hook Integration Tests
|
||||
// ================================
|
||||
describe('Hook Integration', () => {
|
||||
it('should use handleStartToInstall from useHideLogic', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('start-install-btn'))
|
||||
|
||||
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use setIsInstalling from useHideLogic in handleInstalled', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use setIsInstalling from useHideLogic in handleFailed', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use refreshPluginList from useRefreshPluginList', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// getTitle Memoization Tests
|
||||
// ================================
|
||||
describe('getTitle Memoization', () => {
|
||||
it('should return installPlugin title for readyToInstall step', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return installedSuccessfully for non-bundle installed step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return installComplete for bundle installed step', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return installFailed for installFailed step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { Dependency, Plugin, PluginManifestInMarket } from '../../types'
|
||||
import { InstallStep } from '../../types'
|
||||
import Install from './steps/install'
|
||||
import Installed from '../base/installed'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { InstallStep } from '../../types'
|
||||
import Installed from '../base/installed'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
import ReadyToInstallBundle from '../install-bundle/ready-to-install'
|
||||
import cn from '@/utils/classnames'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import Install from './steps/install'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
uniqueIdentifier: string
|
||||
@ -46,12 +47,12 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (isBundle && step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installComplete`)
|
||||
return t(`${i18nPrefix}.installComplete`, { ns: 'plugin' })
|
||||
if (step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`, { ns: 'plugin' })
|
||||
if (step === InstallStep.installFailed)
|
||||
return t(`${i18nPrefix}.installFailed`)
|
||||
return t(`${i18nPrefix}.installPlugin`)
|
||||
return t(`${i18nPrefix}.installFailed`, { ns: 'plugin' })
|
||||
return t(`${i18nPrefix}.installPlugin`, { ns: 'plugin' })
|
||||
}, [isBundle, step, t])
|
||||
|
||||
const handleInstalled = useCallback((notRefresh?: boolean) => {
|
||||
@ -72,53 +73,57 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={foldAnimInto}
|
||||
wrapperClassName='z-[9999]'
|
||||
wrapperClassName="z-[9999]"
|
||||
className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
<div className="flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
|
||||
<div className="title-2xl-semi-bold self-stretch text-text-primary">
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
isBundle ? (
|
||||
<ReadyToInstallBundle
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
allPlugins={dependencies!}
|
||||
isFromMarketPlace
|
||||
/>
|
||||
) : (<>
|
||||
{
|
||||
step === InstallStep.readyToInstall && (
|
||||
<Install
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
payload={manifest!}
|
||||
onCancel={onClose}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
isBundle
|
||||
? (
|
||||
<ReadyToInstallBundle
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
[InstallStep.installed, InstallStep.installFailed].includes(step) && (
|
||||
<Installed
|
||||
payload={manifest!}
|
||||
isMarketPayload
|
||||
isFailed={step === InstallStep.installFailed}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onSuccess}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
allPlugins={dependencies!}
|
||||
isFromMarketPlace
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{
|
||||
step === InstallStep.readyToInstall && (
|
||||
<Install
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
payload={manifest!}
|
||||
onCancel={onClose}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
[InstallStep.installed, InstallStep.installFailed].includes(step) && (
|
||||
<Installed
|
||||
payload={manifest!}
|
||||
isMarketPayload
|
||||
isFailed={step === InstallStep.installFailed}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onSuccess}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Modal >
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,729 @@
|
||||
import type { Plugin, PluginManifestInMarket } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../types'
|
||||
import Install from './install'
|
||||
|
||||
// Factory functions for test data
|
||||
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
|
||||
plugin_unique_identifier: 'test-unique-identifier',
|
||||
name: 'Test Plugin',
|
||||
org: 'test-org',
|
||||
icon: 'test-icon.png',
|
||||
label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'],
|
||||
category: PluginCategoryEnum.tool,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'],
|
||||
introduction: 'Introduction text',
|
||||
verified: true,
|
||||
install_count: 100,
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-package-id',
|
||||
icon: 'test-icon.png',
|
||||
verified: true,
|
||||
label: { en_US: 'Test Plugin' },
|
||||
brief: { en_US: 'A test plugin' },
|
||||
description: { en_US: 'A test plugin description' },
|
||||
introduction: 'Introduction text',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock variables for controlling test behavior
|
||||
let mockInstalledInfo: Record<string, { installedId: string, installedVersion: string, uniqueIdentifier: string }> | undefined
|
||||
let mockIsLoading = false
|
||||
const mockInstallPackageFromMarketPlace = vi.fn()
|
||||
const mockUpdatePackageFromMarketPlace = vi.fn()
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
const mockStopTaskStatus = vi.fn()
|
||||
const mockHandleRefetch = vi.fn()
|
||||
let mockPluginDeclaration: { manifest: { meta: { minimum_dify_version: string } } } | undefined
|
||||
let mockCanInstall = true
|
||||
let mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
|
||||
// Mock useCheckInstalled
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: ({ pluginIds }: { pluginIds: string[], enabled: boolean }) => ({
|
||||
installedInfo: mockInstalledInfo,
|
||||
isLoading: mockIsLoading,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockInstallPackageFromMarketPlace,
|
||||
}),
|
||||
useUpdatePackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockUpdatePackageFromMarketPlace,
|
||||
}),
|
||||
usePluginDeclarationFromMarketPlace: () => ({
|
||||
data: mockPluginDeclaration,
|
||||
}),
|
||||
usePluginTaskList: () => ({
|
||||
handleRefetch: mockHandleRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock checkTaskStatus
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheckTaskStatus,
|
||||
stop: mockStopTaskStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useAppContext
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
langGeniusVersionInfo: mockLangGeniusVersionInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useInstallPluginLimit
|
||||
vi.mock('../../hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({ canInstall: mockCanInstall }),
|
||||
}))
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft, className, limitedInstall }: {
|
||||
payload: any
|
||||
titleLeft?: React.ReactNode
|
||||
className?: string
|
||||
limitedInstall?: boolean
|
||||
}) => (
|
||||
<div data-testid="plugin-card">
|
||||
<span data-testid="card-payload-name">{payload?.name}</span>
|
||||
<span data-testid="card-limited-install">{limitedInstall ? 'true' : 'false'}</span>
|
||||
{titleLeft && <div data-testid="card-title-left">{titleLeft}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Version component
|
||||
vi.mock('../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
toInstallVersion: string
|
||||
}) => (
|
||||
<div data-testid="version-component">
|
||||
<span data-testid="has-installed">{hasInstalled ? 'true' : 'false'}</span>
|
||||
<span data-testid="installed-version">{installedVersion || 'none'}</span>
|
||||
<span data-testid="to-install-version">{toInstallVersion}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock utils
|
||||
vi.mock('../../utils', () => ({
|
||||
pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({
|
||||
name: payload.name,
|
||||
icon: payload.icon,
|
||||
category: payload.category,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Install Component (steps/install.tsx)', () => {
|
||||
const defaultProps = {
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
payload: createMockManifest(),
|
||||
onCancel: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
onInstalled: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInstalledInfo = undefined
|
||||
mockIsLoading = false
|
||||
mockPluginDeclaration = undefined
|
||||
mockCanInstall = true
|
||||
mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
mockUpdatePackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-456',
|
||||
})
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
status: TaskStatus.success,
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render ready to install text', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card with correct payload', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should render cancel button when not installing', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render install button', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.install')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render version component while loading', () => {
|
||||
mockIsLoading = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('version-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render version component when not loading', () => {
|
||||
mockIsLoading = false
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Version Display Tests
|
||||
// ================================
|
||||
describe('Version Display', () => {
|
||||
it('should show hasInstalled as false when not installed', () => {
|
||||
mockInstalledInfo = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should show hasInstalled as true when already installed', () => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'old-unique-id',
|
||||
},
|
||||
}
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('installed-version')).toHaveTextContent('0.9.0')
|
||||
})
|
||||
|
||||
it('should show correct toInstallVersion from payload.version', () => {
|
||||
const manifest = createMockManifest({ version: '2.0.0' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('to-install-version')).toHaveTextContent('2.0.0')
|
||||
})
|
||||
|
||||
it('should fallback to latest_version when version is undefined', () => {
|
||||
const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Version Compatibility Tests
|
||||
// ================================
|
||||
describe('Version Compatibility', () => {
|
||||
it('should not show warning when no plugin declaration', () => {
|
||||
mockPluginDeclaration = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show warning when dify version is compatible', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: '2.0.0' }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '1.0.0' } },
|
||||
}
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when dify version is incompatible', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '2.0.0' } },
|
||||
}
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Install Limit Tests
|
||||
// ================================
|
||||
describe('Install Limit', () => {
|
||||
it('should pass limitedInstall=false to Card when canInstall is true', () => {
|
||||
mockCanInstall = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-limited-install')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass limitedInstall=true to Card when canInstall is false', () => {
|
||||
mockCanInstall = false
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-limited-install')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should disable install button when canInstall is false', () => {
|
||||
mockCanInstall = false
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button States Tests
|
||||
// ================================
|
||||
describe('Button States', () => {
|
||||
it('should disable install button when loading', () => {
|
||||
mockIsLoading = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable install button when not loading and canInstall', () => {
|
||||
mockIsLoading = false
|
||||
mockCanInstall = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
|
||||
expect(installBtn).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Cancel Button Tests
|
||||
// ================================
|
||||
describe('Cancel Button', () => {
|
||||
it('should call onCancel and stop when cancel is clicked', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
expect(mockStopTaskStatus).toHaveBeenCalled()
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// New Installation Flow Tests
|
||||
// ================================
|
||||
describe('New Installation Flow', () => {
|
||||
it('should call onStartToInstall when install button is clicked', async () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
expect(defaultProps.onStartToInstall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call installPackageFromMarketPlace for new installation', async () => {
|
||||
mockInstalledInfo = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('test-unique-identifier')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled immediately when all_installed is true', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onInstalled).toHaveBeenCalled()
|
||||
expect(mockCheckTaskStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check task status when all_installed is false', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefetch).toHaveBeenCalled()
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledWith({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test-unique-identifier',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled with true when task succeeds', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success })
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onInstalled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed when task fails', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
status: TaskStatus.failed,
|
||||
error: 'Task failed error',
|
||||
})
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onFailed).toHaveBeenCalledWith('Task failed error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Update Installation Flow Tests
|
||||
// ================================
|
||||
describe('Update Installation Flow', () => {
|
||||
beforeEach(() => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'old-unique-id',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('should call updatePackageFromMarketPlace for update installation', async () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePackageFromMarketPlace).toHaveBeenCalledWith({
|
||||
original_plugin_unique_identifier: 'old-unique-id',
|
||||
new_plugin_unique_identifier: 'test-unique-identifier',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call installPackageFromMarketPlace when updating', async () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Auto-Install on Already Installed Tests
|
||||
// ================================
|
||||
describe('Auto-Install on Already Installed', () => {
|
||||
it('should call onInstalled when already installed with same uniqueIdentifier', async () => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
},
|
||||
}
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-install when uniqueIdentifier differs', async () => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'different-unique-id',
|
||||
},
|
||||
}
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
// Wait a bit to ensure onInstalled is not called
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(defaultProps.onInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Handling Tests
|
||||
// ================================
|
||||
describe('Error Handling', () => {
|
||||
it('should call onFailed with string error', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockRejectedValue('String error message')
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onFailed).toHaveBeenCalledWith('String error message')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed without message for non-string error', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockRejectedValue(new Error('Error object'))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onFailed).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installing State Tests
|
||||
// ================================
|
||||
describe('Installing State', () => {
|
||||
it('should hide cancel button while installing', async () => {
|
||||
// Make the install take some time
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show installing text while installing', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable install button while installing', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const installBtn = screen.getByText('plugin.installModal.installing').closest('button')
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger multiple installs when clicking rapidly', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')!
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(installBtn)
|
||||
})
|
||||
|
||||
// Wait for the button to be disabled
|
||||
await waitFor(() => {
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
// Try clicking again - should not trigger another install
|
||||
await act(async () => {
|
||||
fireEvent.click(installBtn)
|
||||
fireEvent.click(installBtn)
|
||||
})
|
||||
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Prop Variations Tests
|
||||
// ================================
|
||||
describe('Prop Variations', () => {
|
||||
it('should work with PluginManifestInMarket payload', () => {
|
||||
const manifest = createMockManifest({ name: 'Manifest Plugin' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Manifest Plugin')
|
||||
})
|
||||
|
||||
it('should work with Plugin payload', () => {
|
||||
const plugin = createMockPlugin({ name: 'Plugin Type' })
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Plugin Type')
|
||||
})
|
||||
|
||||
it('should work without onStartToInstall callback', async () => {
|
||||
const propsWithoutCallback = {
|
||||
...defaultProps,
|
||||
onStartToInstall: undefined,
|
||||
}
|
||||
render(<Install {...propsWithoutCallback} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
// Should not throw and should proceed with installation
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle different uniqueIdentifier values', async () => {
|
||||
render(<Install {...defaultProps} uniqueIdentifier="custom-id-123" />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('custom-id-123')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty plugin_id gracefully', () => {
|
||||
const manifest = createMockManifest()
|
||||
// Manifest doesn't have plugin_id, so installedInfo won't match
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle undefined installedInfo', () => {
|
||||
mockInstalledInfo = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle null current_version in langGeniusVersionInfo', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: null as any }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '1.0.0' } },
|
||||
}
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
// Should not show warning when current_version is null (defaults to compatible)
|
||||
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Component Memoization Tests
|
||||
// ================================
|
||||
describe('Component Memoization', () => {
|
||||
it('should maintain stable component across rerenders with same props', () => {
|
||||
const { rerender } = render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
|
||||
rerender(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,23 +1,24 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
// import { RiInformation2Line } from '@remixicon/react'
|
||||
import { type Plugin, type PluginManifestInMarket, TaskStatus } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import { pluginManifestInMarketToPluginProps } from '../../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Plugin, PluginManifestInMarket } from '../../../types'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import Version from '../../base/version'
|
||||
import { usePluginTaskList } from '@/service/use-plugins'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import Card from '../../../card'
|
||||
// import { RiInformation2Line } from '@remixicon/react'
|
||||
import { TaskStatus } from '../../../types'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import Version from '../../base/version'
|
||||
import useInstallPluginLimit from '../../hooks/use-install-plugin-limit'
|
||||
import { pluginManifestInMarketToPluginProps } from '../../utils'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type Props = {
|
||||
uniqueIdentifier: string
|
||||
@ -67,7 +68,8 @@ const Installed: FC<Props> = ({
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return
|
||||
if (isInstalling)
|
||||
return
|
||||
onStartToInstall?.()
|
||||
setIsInstalling(true)
|
||||
try {
|
||||
@ -122,50 +124,53 @@ const Installed: FC<Props> = ({
|
||||
const { langGeniusVersionInfo } = useAppContext()
|
||||
const { data: pluginDeclaration } = usePluginDeclarationFromMarketPlace(uniqueIdentifier)
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!pluginDeclaration || !langGeniusVersionInfo.current_version) return true
|
||||
if (!pluginDeclaration || !langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.current_version, pluginDeclaration])
|
||||
|
||||
const { canInstall } = useInstallPluginLimit({ ...payload, from: 'marketplace' })
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`)}</p>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
|
||||
{!isDifyVersionCompatible && (
|
||||
<p className='system-md-regular text-text-warning'>
|
||||
{t('plugin.difyVersionNotCompatible', { minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}
|
||||
<p className="system-md-regular text-text-warning">
|
||||
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
payload={pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket)}
|
||||
titleLeft={!isLoading && <Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>}
|
||||
titleLeft={!isLoading && (
|
||||
<Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>
|
||||
)}
|
||||
limitedInstall={!canInstall}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
{!isInstalling && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={handleCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
<Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
variant="primary"
|
||||
className="flex min-w-[72px] space-x-0.5"
|
||||
disabled={isInstalling || isLoading || !canInstall}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
{isInstalling && <RiLoader2Line className="h-4 w-4 animate-spin-slow" />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`, { ns: 'plugin' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../types'
|
||||
import type { GitHubUrlInfo } from '@/app/components/plugins/types'
|
||||
import { isEmpty } from 'lodash-es'
|
||||
import { isEmpty } from 'es-toolkit/compat'
|
||||
|
||||
export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaration): Plugin => {
|
||||
return {
|
||||
|
||||
@ -3,6 +3,15 @@
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type { TagKey } from '../constants'
|
||||
import type { Plugin } from '../types'
|
||||
import type {
|
||||
MarketplaceCollection,
|
||||
PluginsSort,
|
||||
SearchParams,
|
||||
SearchParamsFromCollection,
|
||||
} from './types'
|
||||
import { debounce, noop } from 'es-toolkit/compat'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@ -14,31 +23,23 @@ import {
|
||||
createContext,
|
||||
useContextSelector,
|
||||
} from 'use-context-selector'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import type { Plugin } from '../types'
|
||||
import { useMarketplaceFilters } from '@/hooks/use-query-params'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import {
|
||||
getValidCategoryKeys,
|
||||
getValidTagKeys,
|
||||
} from '../utils'
|
||||
import type {
|
||||
MarketplaceCollection,
|
||||
PluginsSort,
|
||||
SearchParams,
|
||||
SearchParamsFromCollection,
|
||||
} from './types'
|
||||
import { DEFAULT_SORT } from './constants'
|
||||
import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
useMarketplaceContainerScroll,
|
||||
useMarketplacePlugins,
|
||||
} from './hooks'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import {
|
||||
getMarketplaceListCondition,
|
||||
getMarketplaceListFilterType,
|
||||
updateSearchParams,
|
||||
} from './utils'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { debounce, noop } from 'lodash-es'
|
||||
|
||||
export type MarketplaceContextValue = {
|
||||
searchPluginText: string
|
||||
@ -107,16 +108,22 @@ export const MarketplaceContextProvider = ({
|
||||
scrollContainerId,
|
||||
showSearchParams,
|
||||
}: MarketplaceContextProviderProps) => {
|
||||
// Use nuqs hook for URL-based filter state
|
||||
const [urlFilters, setUrlFilters] = useMarketplaceFilters()
|
||||
|
||||
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
|
||||
const exclude = useMemo(() => {
|
||||
if (shouldExclude)
|
||||
return data?.plugins.map(plugin => plugin.plugin_id)
|
||||
}, [data?.plugins, shouldExclude])
|
||||
const queryFromSearchParams = searchParams?.q || ''
|
||||
const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : []
|
||||
|
||||
// Initialize from URL params (legacy support) or use nuqs state
|
||||
const queryFromSearchParams = searchParams?.q || urlFilters.q
|
||||
const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[])
|
||||
const hasValidTags = !!tagsFromSearchParams.length
|
||||
const hasValidCategory = getValidCategoryKeys(searchParams?.category)
|
||||
const hasValidCategory = getValidCategoryKeys(urlFilters.category)
|
||||
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
|
||||
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
|
||||
@ -158,10 +165,6 @@ export const MarketplaceContextProvider = ({
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
const url = new URL(window.location.href)
|
||||
if (searchParams?.language)
|
||||
url.searchParams.set('language', searchParams?.language)
|
||||
history.replaceState({}, '', url)
|
||||
}
|
||||
else {
|
||||
if (shouldExclude && isSuccess) {
|
||||
@ -183,28 +186,32 @@ export const MarketplaceContextProvider = ({
|
||||
resetPlugins()
|
||||
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
|
||||
|
||||
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
|
||||
updateSearchParams({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
})
|
||||
}, 500), [])
|
||||
|
||||
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
|
||||
const applyUrlFilters = useCallback(() => {
|
||||
if (!showSearchParams)
|
||||
return
|
||||
const nextFilters = {
|
||||
q: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
}
|
||||
const categoryChanged = urlFilters.category !== nextFilters.category
|
||||
setUrlFilters(nextFilters, {
|
||||
history: categoryChanged ? 'push' : 'replace',
|
||||
})
|
||||
}, [setUrlFilters, showSearchParams, urlFilters.category])
|
||||
|
||||
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
|
||||
applyUrlFilters()
|
||||
}, 500), [applyUrlFilters])
|
||||
|
||||
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
|
||||
if (debounced) {
|
||||
debouncedUpdateSearchParams()
|
||||
}
|
||||
else {
|
||||
updateSearchParams({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
})
|
||||
applyUrlFilters()
|
||||
}
|
||||
}, [debouncedUpdateSearchParams, showSearchParams])
|
||||
}, [applyUrlFilters, debouncedUpdateSearchParams])
|
||||
|
||||
const handleQueryPlugins = useCallback((debounced?: boolean) => {
|
||||
handleUpdateSearchParams(debounced)
|
||||
|
||||
@ -0,0 +1,683 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Import component after mocks are set up
|
||||
import Description from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies
|
||||
// ================================
|
||||
|
||||
// Track mock locale for testing
|
||||
let mockDefaultLocale = 'en-US'
|
||||
|
||||
// Mock translations with realistic values
|
||||
const pluginTranslations: Record<string, string> = {
|
||||
'marketplace.empower': 'Empower your AI development',
|
||||
'marketplace.discover': 'Discover',
|
||||
'marketplace.difyMarketplace': 'Dify Marketplace',
|
||||
'marketplace.and': 'and',
|
||||
'category.models': 'Models',
|
||||
'category.tools': 'Tools',
|
||||
'category.datasources': 'Data Sources',
|
||||
'category.triggers': 'Triggers',
|
||||
'category.agents': 'Agent Strategies',
|
||||
'category.extensions': 'Extensions',
|
||||
'category.bundles': 'Bundles',
|
||||
}
|
||||
|
||||
const commonTranslations: Record<string, string> = {
|
||||
'operation.in': 'in',
|
||||
}
|
||||
|
||||
// Mock getLocaleOnServer and translate
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: vi.fn(() => Promise.resolve(mockDefaultLocale)),
|
||||
getTranslation: vi.fn((locale: string, ns: string) => {
|
||||
return Promise.resolve({
|
||||
t: (key: string) => {
|
||||
if (ns === 'plugin')
|
||||
return pluginTranslations[key] || key
|
||||
if (ns === 'common')
|
||||
return commonTranslations[key] || key
|
||||
return key
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Description Component Tests
|
||||
// ================================
|
||||
describe('Description', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render h1 heading with empower text', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Empower your AI development')
|
||||
})
|
||||
|
||||
it('should render h2 subheading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h1', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('title-4xl-semi-bold')
|
||||
expect(heading).toHaveClass('mb-2')
|
||||
expect(heading).toHaveClass('text-center')
|
||||
expect(heading).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('body-md-regular')
|
||||
expect(subheading).toHaveClass('text-center')
|
||||
expect(subheading).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Non-Chinese Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Non-Chinese Locale Rendering', () => {
|
||||
it('should render discover text for en-US locale', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all category names', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "and" conjunction text', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
|
||||
it('should render "in" preposition at the end for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText('in')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text at the end for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render category spans with styled underline effect', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
|
||||
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
|
||||
expect(styledSpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class to category spans', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const styledSpans = container.querySelectorAll('.text-text-secondary')
|
||||
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Chinese (zh-Hans) Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Chinese (zh-Hans) Locale Rendering', () => {
|
||||
it('should render "in" text at the beginning for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// In zh-Hans mode, "in" appears at the beginning
|
||||
const inElements = screen.getAllByText('in')
|
||||
expect(inElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render discover text for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all categories for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both zh-Hans specific elements and shared elements', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
|
||||
// then the same category list with "and" -> Bundles
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Variations Tests
|
||||
// ================================
|
||||
describe('Locale Prop Variations', () => {
|
||||
it('should use default locale when locale prop is undefined', async () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(await Description({}))
|
||||
|
||||
// Should use the default locale from getLocaleOnServer
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use provided locale prop instead of default', async () => {
|
||||
mockDefaultLocale = 'ja-JP'
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// The locale prop should be used, triggering non-Chinese rendering
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'ja-JP' }))
|
||||
|
||||
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should handle ko-KR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'ko-KR' }))
|
||||
|
||||
// Should render in non-Chinese format
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle de-DE locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'de-DE' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle fr-FR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'fr-FR' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pt-BR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'pt-BR' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle es-ES locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'es-ES' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Conditional Rendering Tests
|
||||
// ================================
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should render zh-Hans specific content when locale is zh-Hans', async () => {
|
||||
const { container } = render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// zh-Hans has additional span with mr-1 before "in" text at the start
|
||||
const mrSpan = container.querySelector('span.mr-1')
|
||||
expect(mrSpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render non-Chinese specific content when locale is not zh-Hans', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// Non-Chinese has "in" and "Dify Marketplace" at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should not render zh-Hans intro content for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// For en-US, the order should be Discover ... in Dify Marketplace
|
||||
// The "in" text should only appear once at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "in" should appear after "Bundles" and before "Dify Marketplace"
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
expect(bundlesIndex).toBeLessThan(inIndex)
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
|
||||
it('should render zh-Hans with proper word order', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Category Styling Tests
|
||||
// ================================
|
||||
describe('Category Styling', () => {
|
||||
it('should apply underline effect with after pseudo-element styling', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpan = container.querySelector('.after\\:absolute')
|
||||
expect(categorySpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct after pseudo-element classes', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Check for the specific after pseudo-element classes
|
||||
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply full width to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:w-full')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply correct height to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:h-2')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply bg-text-text-selected to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should have z-index 1 on category spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply left margin to category spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.ml-1')
|
||||
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('should apply both left and right margin to specific spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Extensions and Bundles spans have both ml-1 and mr-1
|
||||
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
|
||||
expect(extensionsBundlesSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty props object', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fragment as root element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Fragment renders h1 and h2 as direct children
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle locale prop with undefined value', async () => {
|
||||
render(await Description({ locale: undefined }))
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zh-Hant as non-Chinese simplified', async () => {
|
||||
render(await Description({ locale: 'zh-Hant' }))
|
||||
|
||||
// zh-Hant is different from zh-Hans, should use non-Chinese format
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Check that "Dify Marketplace" appears at the end (non-Chinese format)
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
// For non-Chinese locales, Discover should come before Dify Marketplace
|
||||
expect(discoverIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Content Structure Tests
|
||||
// ================================
|
||||
describe('Content Structure', () => {
|
||||
it('should have comma separators between categories', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Commas should exist between categories
|
||||
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
|
||||
})
|
||||
|
||||
it('should have "and" before last category (Bundles)', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "and" should appear before Bundles
|
||||
const andIndex = content.indexOf('and')
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
|
||||
expect(andIndex).toBeLessThan(bundlesIndex)
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for en-US', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
const expectedOrder = [
|
||||
'Discover',
|
||||
'Models',
|
||||
'Tools',
|
||||
'Data Sources',
|
||||
'Triggers',
|
||||
'Agent Strategies',
|
||||
'Extensions',
|
||||
'and',
|
||||
'Bundles',
|
||||
'in',
|
||||
'Dify Marketplace',
|
||||
]
|
||||
|
||||
let lastIndex = -1
|
||||
for (const text of expectedOrder) {
|
||||
const currentIndex = content.indexOf(text)
|
||||
expect(currentIndex).toBeGreaterThan(lastIndex)
|
||||
lastIndex = currentIndex
|
||||
}
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for zh-Hans', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const modelsIndex = content.indexOf('Models')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
expect(discoverIndex).toBeLessThan(modelsIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Layout Tests
|
||||
// ================================
|
||||
describe('Layout', () => {
|
||||
it('should have shrink-0 on h1 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on h2 subheading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have flex layout on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('flex')
|
||||
})
|
||||
|
||||
it('should have items-center on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should have justify-center on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('justify-center')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Function Tests
|
||||
// ================================
|
||||
describe('Translation Functions', () => {
|
||||
it('should call getTranslation for plugin namespace', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('en-US', 'plugin')
|
||||
})
|
||||
|
||||
it('should call getTranslation for common namespace', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('en-US', 'common')
|
||||
})
|
||||
|
||||
it('should call getLocaleOnServer when locale prop is undefined', async () => {
|
||||
const { getLocaleOnServer } = await import('@/i18n-config/server')
|
||||
render(await Description({}))
|
||||
|
||||
expect(getLocaleOnServer).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use locale prop when provided', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'ja-JP' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'plugin')
|
||||
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'common')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
const h2 = screen.getByRole('heading', { level: 2 })
|
||||
|
||||
expect(h1).toBeInTheDocument()
|
||||
expect(h2).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have readable text content', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
expect(h1.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('should have visible h1 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
it('should have visible h2 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Description Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
it('should render complete component structure', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should render complete zh-Hans structure', async () => {
|
||||
const { container } = render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should correctly switch between zh-Hans and en-US layouts', async () => {
|
||||
// Render en-US
|
||||
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
|
||||
const enContent = enContainer.querySelector('h2')?.textContent || ''
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
|
||||
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
|
||||
|
||||
// Both should have all categories
|
||||
expect(enContent).toContain('Models')
|
||||
expect(zhContent).toContain('Models')
|
||||
|
||||
// But order should differ
|
||||
const enMarketplaceIndex = enContent.indexOf('Dify Marketplace')
|
||||
const enDiscoverIndex = enContent.indexOf('Discover')
|
||||
const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace')
|
||||
const zhDiscoverIndex = zhContent.indexOf('Discover')
|
||||
|
||||
// en-US: Discover comes before Dify Marketplace
|
||||
expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex)
|
||||
|
||||
// zh-Hans: Dify Marketplace comes before Discover
|
||||
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
|
||||
})
|
||||
|
||||
it('should maintain consistent styling across locales', async () => {
|
||||
// Render en-US
|
||||
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
|
||||
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
|
||||
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
|
||||
|
||||
// Both should have same number of styled category spans
|
||||
expect(enCategoryCount).toBe(zhCategoryCount)
|
||||
expect(enCategoryCount).toBe(7)
|
||||
})
|
||||
})
|
||||
@ -1,10 +1,12 @@
|
||||
/* eslint-disable dify-i18n/require-ns-option */
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import {
|
||||
getLocaleOnServer,
|
||||
useTranslation as translate,
|
||||
getTranslation as translate,
|
||||
} from '@/i18n-config/server'
|
||||
|
||||
type DescriptionProps = {
|
||||
locale?: string
|
||||
locale?: Locale
|
||||
}
|
||||
const Description = async ({
|
||||
locale: localeFromProps,
|
||||
@ -16,14 +18,14 @@ const Description = async ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className='title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary'>
|
||||
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary">
|
||||
{t('marketplace.empower')}
|
||||
</h1>
|
||||
<h2 className='body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary'>
|
||||
<h2 className="body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary">
|
||||
{
|
||||
isZhHans && (
|
||||
<>
|
||||
<span className='mr-1'>{tCommon('operation.in')}</span>
|
||||
<span className="mr-1">{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
@ -66,7 +68,7 @@ const Description = async ({
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
<span className='mr-1'>{tCommon('operation.in')}</span>
|
||||
<span className="mr-1">{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
</>
|
||||
)
|
||||
|
||||
836
web/app/components/plugins/marketplace/empty/index.spec.tsx
Normal file
836
web/app/components/plugins/marketplace/empty/index.spec.tsx
Normal file
@ -0,0 +1,836 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Empty from './index'
|
||||
import Line from './line'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.noPluginFound': 'No plugin found',
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTheme hook with controllable theme value
|
||||
let mockTheme = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Line Component Tests
|
||||
// ================================
|
||||
describe('Line', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SVG element', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Light Theme Tests
|
||||
// ================================
|
||||
describe('Light Theme', () => {
|
||||
beforeEach(() => {
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render light mode SVG', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '241')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 2 241')
|
||||
})
|
||||
|
||||
it('should render light mode path with correct d attribute', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('d', 'M1 0.5L1 240.5')
|
||||
})
|
||||
|
||||
it('should render light mode linear gradient with correct id', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const gradient = container.querySelector('#paint0_linear_1989_74474')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render light mode gradient with white stop colors', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops.length).toBe(3)
|
||||
|
||||
// First stop - white with 0.01 opacity
|
||||
expect(stops[0]).toHaveAttribute('stop-color', 'white')
|
||||
expect(stops[0]).toHaveAttribute('stop-opacity', '0.01')
|
||||
|
||||
// Middle stop - dark color with 0.08 opacity
|
||||
expect(stops[1]).toHaveAttribute('stop-color', '#101828')
|
||||
expect(stops[1]).toHaveAttribute('stop-opacity', '0.08')
|
||||
|
||||
// Last stop - white with 0.01 opacity
|
||||
expect(stops[2]).toHaveAttribute('stop-color', 'white')
|
||||
expect(stops[2]).toHaveAttribute('stop-opacity', '0.01')
|
||||
})
|
||||
|
||||
it('should apply className to SVG in light mode', () => {
|
||||
const { container } = render(<Line className="test-class" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('test-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dark Theme Tests
|
||||
// ================================
|
||||
describe('Dark Theme', () => {
|
||||
beforeEach(() => {
|
||||
mockTheme = 'dark'
|
||||
})
|
||||
|
||||
it('should render dark mode SVG', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '240')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 2 240')
|
||||
})
|
||||
|
||||
it('should render dark mode path with correct d attribute', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('d', 'M1 0L1 240')
|
||||
})
|
||||
|
||||
it('should render dark mode linear gradient with correct id', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const gradient = container.querySelector('#paint0_linear_6295_52176')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dark mode gradient stops', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops.length).toBe(3)
|
||||
|
||||
// First stop - no color, 0.01 opacity
|
||||
expect(stops[0]).toHaveAttribute('stop-opacity', '0.01')
|
||||
|
||||
// Middle stop - light color with 0.14 opacity
|
||||
expect(stops[1]).toHaveAttribute('stop-color', '#C8CEDA')
|
||||
expect(stops[1]).toHaveAttribute('stop-opacity', '0.14')
|
||||
|
||||
// Last stop - no color, 0.01 opacity
|
||||
expect(stops[2]).toHaveAttribute('stop-opacity', '0.01')
|
||||
})
|
||||
|
||||
it('should apply className to SVG in dark mode', () => {
|
||||
const { container } = render(<Line className="dark-test-class" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('dark-test-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Variations Tests
|
||||
// ================================
|
||||
describe('Props Variations', () => {
|
||||
it('should handle undefined className', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
const { container } = render(<Line className="" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple class names', () => {
|
||||
const { container } = render(<Line className="class-1 class-2 class-3" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('class-1')
|
||||
expect(svg).toHaveClass('class-2')
|
||||
expect(svg).toHaveClass('class-3')
|
||||
})
|
||||
|
||||
it('should handle Tailwind utility classes', () => {
|
||||
const { container } = render(
|
||||
<Line className="absolute right-[-1px] top-1/2 -translate-y-1/2" />,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('absolute')
|
||||
expect(svg).toHaveClass('right-[-1px]')
|
||||
expect(svg).toHaveClass('top-1/2')
|
||||
expect(svg).toHaveClass('-translate-y-1/2')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Theme Switching Tests
|
||||
// ================================
|
||||
describe('Theme Switching', () => {
|
||||
it('should render different SVG dimensions based on theme', () => {
|
||||
// Light mode
|
||||
mockTheme = 'light'
|
||||
const { container: lightContainer, unmount: unmountLight } = render(<Line />)
|
||||
expect(lightContainer.querySelector('svg')).toHaveAttribute('height', '241')
|
||||
unmountLight()
|
||||
|
||||
// Dark mode
|
||||
mockTheme = 'dark'
|
||||
const { container: darkContainer } = render(<Line />)
|
||||
expect(darkContainer.querySelector('svg')).toHaveAttribute('height', '240')
|
||||
})
|
||||
|
||||
it('should use different gradient IDs based on theme', () => {
|
||||
// Light mode
|
||||
mockTheme = 'light'
|
||||
const { container: lightContainer, unmount: unmountLight } = render(<Line />)
|
||||
expect(lightContainer.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument()
|
||||
expect(lightContainer.querySelector('#paint0_linear_6295_52176')).not.toBeInTheDocument()
|
||||
unmountLight()
|
||||
|
||||
// Dark mode
|
||||
mockTheme = 'dark'
|
||||
const { container: darkContainer } = render(<Line />)
|
||||
expect(darkContainer.querySelector('#paint0_linear_6295_52176')).toBeInTheDocument()
|
||||
expect(darkContainer.querySelector('#paint0_linear_1989_74474')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle theme value of light explicitly', () => {
|
||||
mockTheme = 'light'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
expect(container.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle non-dark theme as light mode', () => {
|
||||
mockTheme = 'system'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
// Non-dark themes should use light mode SVG
|
||||
expect(container.querySelector('svg')).toHaveAttribute('height', '241')
|
||||
})
|
||||
|
||||
it('should render SVG with fill none', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render path with gradient stroke', () => {
|
||||
mockTheme = 'light'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_1989_74474)')
|
||||
})
|
||||
|
||||
it('should render dark mode path with gradient stroke', () => {
|
||||
mockTheme = 'dark'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_6295_52176)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Empty Component Tests
|
||||
// ================================
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 16 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should render default no plugin found text', () => {
|
||||
render(<Empty />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Group icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Icon wrapper should be present
|
||||
const iconWrapper = container.querySelector('.h-14.w-14')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render four Line components around the icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Four SVG elements from Line components + 1 Group icon SVG = 5 total
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render center content with absolute positioning', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerContent = container.querySelector('.absolute.left-1\\/2.top-1\\/2')
|
||||
expect(centerContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Text Prop Tests
|
||||
// ================================
|
||||
describe('Text Prop', () => {
|
||||
it('should render custom text when provided', () => {
|
||||
render(<Empty text="Custom empty message" />)
|
||||
|
||||
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No plugin found')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default translation when text is empty string', () => {
|
||||
render(<Empty text="" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default translation when text is undefined', () => {
|
||||
render(<Empty text={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render long custom text', () => {
|
||||
const longText = 'This is a very long message that describes why there are no plugins found in the current search results and what the user might want to do next to find what they are looking for'
|
||||
render(<Empty text={longText} />)
|
||||
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text with special characters', () => {
|
||||
render(<Empty text="No plugins found for query: <search>" />)
|
||||
|
||||
expect(screen.getByText('No plugins found for query: <search>')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// LightCard Prop Tests
|
||||
// ================================
|
||||
describe('LightCard Prop', () => {
|
||||
it('should render overlay when lightCard is false', () => {
|
||||
const { container } = render(<Empty lightCard={false} />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overlay when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overlay by default when lightCard is undefined', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply light card styling to placeholder cards when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should apply default styling to placeholder cards when lightCard is false', () => {
|
||||
const { container } = render(<Empty lightCard={false} />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-section-burn')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should apply opacity to light card placeholder', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.opacity-75')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ClassName Prop Tests
|
||||
// ================================
|
||||
describe('ClassName Prop', () => {
|
||||
it('should apply custom className to container', () => {
|
||||
const { container } = render(<Empty className="custom-class" />)
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve base classes when adding custom className', () => {
|
||||
const { container } = render(<Empty className="custom-class" />)
|
||||
|
||||
const element = container.querySelector('.custom-class')
|
||||
expect(element).toHaveClass('relative')
|
||||
expect(element).toHaveClass('flex')
|
||||
expect(element).toHaveClass('h-0')
|
||||
expect(element).toHaveClass('grow')
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
const { container } = render(<Empty className="" />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined className', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const element = container.firstChild as HTMLElement
|
||||
expect(element).toHaveClass('relative')
|
||||
})
|
||||
|
||||
it('should handle multiple custom classes', () => {
|
||||
const { container } = render(<Empty className="class-a class-b class-c" />)
|
||||
|
||||
const element = container.querySelector('.class-a')
|
||||
expect(element).toHaveClass('class-b')
|
||||
expect(element).toHaveClass('class-c')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Tests
|
||||
// ================================
|
||||
describe('Locale Prop', () => {
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
// Translation should still work
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined locale', () => {
|
||||
render(<Empty locale={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle en-US locale', () => {
|
||||
render(<Empty locale="en-US" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale', () => {
|
||||
render(<Empty locale="ja-JP" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Placeholder Cards Layout Tests
|
||||
// ================================
|
||||
describe('Placeholder Cards Layout', () => {
|
||||
it('should remove right margin on every 4th card', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 3, 7, 11, 15 (4th, 8th, 12th, 16th) should have mr-0
|
||||
expect(cards[3]).toHaveClass('mr-0')
|
||||
expect(cards[7]).toHaveClass('mr-0')
|
||||
expect(cards[11]).toHaveClass('mr-0')
|
||||
expect(cards[15]).toHaveClass('mr-0')
|
||||
})
|
||||
|
||||
it('should have margin on cards that are not at the end of row', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards not at row end should have mr-3
|
||||
expect(cards[0]).toHaveClass('mr-3')
|
||||
expect(cards[1]).toHaveClass('mr-3')
|
||||
expect(cards[2]).toHaveClass('mr-3')
|
||||
})
|
||||
|
||||
it('should remove bottom margin on last row cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 12, 13, 14, 15 should have mb-0
|
||||
expect(cards[12]).toHaveClass('mb-0')
|
||||
expect(cards[13]).toHaveClass('mb-0')
|
||||
expect(cards[14]).toHaveClass('mb-0')
|
||||
expect(cards[15]).toHaveClass('mb-0')
|
||||
})
|
||||
|
||||
it('should have bottom margin on non-last row cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 0-11 should have mb-3
|
||||
expect(cards[0]).toHaveClass('mb-3')
|
||||
expect(cards[5]).toHaveClass('mb-3')
|
||||
expect(cards[11]).toHaveClass('mb-3')
|
||||
})
|
||||
|
||||
it('should have correct width calculation for 4 columns', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.w-\\[calc\\(\\(100\\%-36px\\)\\/4\\)\\]')
|
||||
expect(cards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should have rounded corners on cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.rounded-xl')
|
||||
// 16 cards + 1 icon wrapper = 17 rounded-xl elements
|
||||
expect(cards.length).toBeGreaterThanOrEqual(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Icon Container Tests
|
||||
// ================================
|
||||
describe('Icon Container', () => {
|
||||
it('should render icon container with border', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const iconContainer = container.querySelector('.border-dashed')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container with shadow', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const iconContainer = container.querySelector('.shadow-lg')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container centered', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerWrapper = container.querySelector('.-translate-x-1\\/2.-translate-y-1\\/2')
|
||||
expect(centerWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have z-index for center content', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerContent = container.querySelector('.z-\\[2\\]')
|
||||
expect(centerContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Line Positioning Tests
|
||||
// ================================
|
||||
describe('Line Positioning', () => {
|
||||
it('should position Line components correctly around icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Right line
|
||||
const rightLine = container.querySelector('.right-\\[-1px\\]')
|
||||
expect(rightLine).toBeInTheDocument()
|
||||
|
||||
// Left line
|
||||
const leftLine = container.querySelector('.left-\\[-1px\\]')
|
||||
expect(leftLine).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rotated Line components for top and bottom', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const rotatedLines = container.querySelectorAll('.rotate-90')
|
||||
expect(rotatedLines.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Combined Props Tests
|
||||
// ================================
|
||||
describe('Combined Props', () => {
|
||||
it('should handle all props together', () => {
|
||||
const { container } = render(
|
||||
<Empty
|
||||
text="Custom message"
|
||||
lightCard
|
||||
className="custom-wrapper"
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(container.querySelector('.custom-wrapper')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with lightCard false and custom text', () => {
|
||||
const { container } = render(
|
||||
<Empty text="No results" lightCard={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className with lightCard prop', () => {
|
||||
const { container } = render(
|
||||
<Empty className="test-class" lightCard />,
|
||||
)
|
||||
|
||||
const element = container.querySelector('.test-class')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// Verify light card styling is applied
|
||||
const lightCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(lightCards.length).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty props object', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only text prop', () => {
|
||||
render(<Empty text="Only text" />)
|
||||
|
||||
expect(screen.getByText('Only text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only lightCard prop', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only className prop', () => {
|
||||
const { container } = render(<Empty className="only-class" />)
|
||||
|
||||
expect(container.querySelector('.only-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only locale prop', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with unicode characters', () => {
|
||||
render(<Empty text="没有找到插件 🔍" />)
|
||||
|
||||
expect(screen.getByText('没有找到插件 🔍')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with HTML entities', () => {
|
||||
render(<Empty text="No plugins & no results" />)
|
||||
|
||||
expect(screen.getByText('No plugins & no results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle whitespace-only text', () => {
|
||||
const { container } = render(<Empty text=" " />)
|
||||
|
||||
// Whitespace-only text is truthy, so it should be rendered
|
||||
const textContainer = container.querySelector('.system-md-regular')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer?.textContent).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have text content visible', () => {
|
||||
render(<Empty text="No plugins available" />)
|
||||
|
||||
const textElement = screen.getByText('No plugins available')
|
||||
expect(textElement).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render text in proper container', () => {
|
||||
const { container } = render(<Empty text="Test message" />)
|
||||
|
||||
const textContainer = container.querySelector('.system-md-regular')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer).toHaveTextContent('Test message')
|
||||
})
|
||||
|
||||
it('should center text content', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const textContainer = container.querySelector('.text-center')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Overlay Tests
|
||||
// ================================
|
||||
describe('Overlay', () => {
|
||||
it('should render overlay with correct z-index', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.z-\\[1\\]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overlay with full coverage', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.inset-0')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overlay when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const overlay = container.querySelector('.inset-0.z-\\[1\\]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Empty and Line Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render Line components with correct theme in Empty', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// In light mode, should use light gradient ID
|
||||
const lightGradients = container.querySelectorAll('#paint0_linear_1989_74474')
|
||||
expect(lightGradients.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render Line components with dark theme in Empty', () => {
|
||||
mockTheme = 'dark'
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// In dark mode, should use dark gradient ID
|
||||
const darkGradients = container.querySelectorAll('#paint0_linear_6295_52176')
|
||||
expect(darkGradients.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should apply positioning classes to Line components', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Check for Line positioning classes
|
||||
expect(container.querySelector('.right-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(container.querySelector('.left-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.rotate-90').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render complete Empty component structure', () => {
|
||||
const { container } = render(<Empty text="Test" lightCard className="test" locale="en-US" />)
|
||||
|
||||
// Container
|
||||
expect(container.querySelector('.test')).toBeInTheDocument()
|
||||
|
||||
// Placeholder cards
|
||||
expect(container.querySelectorAll('.h-\\[144px\\]').length).toBe(16)
|
||||
|
||||
// Icon container
|
||||
expect(container.querySelector('.h-14.w-14')).toBeInTheDocument()
|
||||
|
||||
// Line components (4) + Group icon (1) = 5 SVGs total
|
||||
expect(container.querySelectorAll('svg').length).toBe(5)
|
||||
|
||||
// Text
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
|
||||
// No overlay for lightCard
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||
import Line from './line'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Line from './line'
|
||||
|
||||
type Props = {
|
||||
text?: string
|
||||
@ -40,20 +40,21 @@ const Empty = ({
|
||||
{
|
||||
!lightCard && (
|
||||
<div
|
||||
className='absolute inset-0 z-[1] bg-marketplace-plugin-empty'
|
||||
></div>
|
||||
className="absolute inset-0 z-[1] bg-marketplace-plugin-empty"
|
||||
>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='absolute left-1/2 top-1/2 z-[2] flex -translate-x-1/2 -translate-y-1/2 flex-col items-center'>
|
||||
<div className='relative mb-3 flex h-14 w-14 items-center justify-center rounded-xl border border-dashed border-divider-deep bg-components-card-bg shadow-lg'>
|
||||
<Group className='h-5 w-5 text-text-primary' />
|
||||
<Line className='absolute right-[-1px] top-1/2 -translate-y-1/2' />
|
||||
<Line className='absolute left-[-1px] top-1/2 -translate-y-1/2' />
|
||||
<Line className='absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rotate-90' />
|
||||
<Line className='absolute left-1/2 top-full -translate-x-1/2 -translate-y-1/2 rotate-90' />
|
||||
<div className="absolute left-1/2 top-1/2 z-[2] flex -translate-x-1/2 -translate-y-1/2 flex-col items-center">
|
||||
<div className="relative mb-3 flex h-14 w-14 items-center justify-center rounded-xl border border-dashed border-divider-deep bg-components-card-bg shadow-lg">
|
||||
<Group className="h-5 w-5 text-text-primary" />
|
||||
<Line className="absolute right-[-1px] top-1/2 -translate-y-1/2" />
|
||||
<Line className="absolute left-[-1px] top-1/2 -translate-y-1/2" />
|
||||
<Line className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rotate-90" />
|
||||
<Line className="absolute left-1/2 top-full -translate-x-1/2 -translate-y-1/2 rotate-90" />
|
||||
</div>
|
||||
<div className='system-md-regular text-center text-text-tertiary'>
|
||||
{text || t('plugin.marketplace.noPluginFound')}
|
||||
<div className="system-md-regular text-center text-text-tertiary">
|
||||
{text || t('marketplace.noPluginFound', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,13 +13,13 @@ const Line = ({
|
||||
|
||||
if (isDarkMode) {
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='240' viewBox='0 0 2 240' fill='none' className={className}>
|
||||
<path d='M1 0L1 240' stroke='url(#paint0_linear_6295_52176)' />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2" height="240" viewBox="0 0 2 240" fill="none" className={className}>
|
||||
<path d="M1 0L1 240" stroke="url(#paint0_linear_6295_52176)" />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_6295_52176' x1='-7.99584' y1='240' x2='-7.88094' y2='3.95539e-05' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopOpacity='0.01' />
|
||||
<stop offset='0.503965' stopColor='#C8CEDA' stopOpacity='0.14' />
|
||||
<stop offset='1' stopOpacity='0.01' />
|
||||
<linearGradient id="paint0_linear_6295_52176" x1="-7.99584" y1="240" x2="-7.88094" y2="3.95539e-05" gradientUnits="userSpaceOnUse">
|
||||
<stop stopOpacity="0.01" />
|
||||
<stop offset="0.503965" stopColor="#C8CEDA" stopOpacity="0.14" />
|
||||
<stop offset="1" stopOpacity="0.01" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
@ -27,13 +27,13 @@ const Line = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='241' viewBox='0 0 2 241' fill='none' className={className}>
|
||||
<path d='M1 0.5L1 240.5' stroke='url(#paint0_linear_1989_74474)' />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2" height="241" viewBox="0 0 2 241" fill="none" className={className}>
|
||||
<path d="M1 0.5L1 240.5" stroke="url(#paint0_linear_1989_74474)" />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_1989_74474' x1='-7.99584' y1='240.5' x2='-7.88094' y2='0.50004' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopColor='white' stopOpacity='0.01' />
|
||||
<stop offset='0.503965' stopColor='#101828' stopOpacity='0.08' />
|
||||
<stop offset='1' stopColor='white' stopOpacity='0.01' />
|
||||
<linearGradient id="paint0_linear_1989_74474" x1="-7.99584" y1="240.5" x2="-7.88094" y2="0.50004" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" stopOpacity="0.01" />
|
||||
<stop offset="0.503965" stopColor="#101828" stopOpacity="0.08" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.01" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@ -1,15 +1,3 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import type {
|
||||
Plugin,
|
||||
} from '../types'
|
||||
@ -18,15 +6,27 @@ import type {
|
||||
MarketplaceCollection,
|
||||
PluginsSearchParams,
|
||||
} from './types'
|
||||
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import {
|
||||
getFormattedPlugin,
|
||||
getMarketplaceCollectionsAndPlugins,
|
||||
getMarketplacePluginsByCollectionId,
|
||||
} from './utils'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
|
||||
3154
web/app/components/plugins/marketplace/index.spec.tsx
Normal file
3154
web/app/components/plugins/marketplace/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,15 @@
|
||||
import { MarketplaceContextProvider } from './context'
|
||||
import Description from './description'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import type { MarketplaceCollection, SearchParams } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { MarketplaceContextProvider } from './context'
|
||||
import Description from './description'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import { getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
|
||||
type MarketplaceProps = {
|
||||
locale: string
|
||||
locale: Locale
|
||||
showInstallButton?: boolean
|
||||
shouldExclude?: boolean
|
||||
searchParams?: SearchParams
|
||||
|
||||
@ -1,22 +1,24 @@
|
||||
'use client'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useTheme } from 'next-themes'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useI18N } from '@/context/i18n'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useI18N } from '@/context/i18n'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
|
||||
type CardWrapperProps = {
|
||||
plugin: Plugin
|
||||
showInstallButton?: boolean
|
||||
locale?: string
|
||||
locale?: Locale
|
||||
}
|
||||
const CardWrapperComponent = ({
|
||||
plugin,
|
||||
@ -40,45 +42,41 @@ const CardWrapperComponent = ({
|
||||
|
||||
// Memoize tag labels to prevent recreating array on every render
|
||||
const tagLabels = useMemo(() =>
|
||||
plugin.tags.map(tag => getTagLabel(tag.name)),
|
||||
[plugin.tags, getTagLabel],
|
||||
)
|
||||
plugin.tags.map(tag => getTagLabel(tag.name)), [plugin.tags, getTagLabel])
|
||||
|
||||
if (showInstallButton) {
|
||||
return (
|
||||
<div
|
||||
className='group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover'
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
locale={locale}
|
||||
footer={
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
tags={tagLabels}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
{
|
||||
<div className='absolute bottom-0 hidden w-full items-center space-x-2 rounded-b-xl bg-gradient-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent px-4 pb-4 pt-4 group-hover:flex'>
|
||||
<div className="absolute bottom-0 hidden w-full items-center space-x-2 rounded-b-xl bg-gradient-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent px-4 pb-4 pt-4 group-hover:flex">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[calc(50%-4px)]"
|
||||
onClick={showInstallFromMarketplace}
|
||||
>
|
||||
{t('detailPanel.operation.install', { ns: 'plugin' })}
|
||||
</Button>
|
||||
<a href={getPluginLinkInMarketplace(plugin, marketplaceLinkParams)} target="_blank" className="block w-[calc(50%-4px)] flex-1 shrink-0">
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-[calc(50%-4px)]'
|
||||
onClick={showInstallFromMarketplace}
|
||||
className="w-full gap-0.5"
|
||||
>
|
||||
{t('plugin.detailPanel.operation.install')}
|
||||
{t('detailPanel.operation.detail', { ns: 'plugin' })}
|
||||
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
<a href={getPluginLinkInMarketplace(plugin, marketplaceLinkParams)} target='_blank' className='block w-[calc(50%-4px)] flex-1 shrink-0'>
|
||||
<Button
|
||||
className='w-full gap-0.5'
|
||||
>
|
||||
{t('plugin.detailPanel.operation.detail')}
|
||||
<RiArrowRightUpLine className='ml-1 h-4 w-4' />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
isShowInstallFromMarketplace && (
|
||||
<InstallFromMarketplace
|
||||
@ -95,19 +93,19 @@ const CardWrapperComponent = ({
|
||||
|
||||
return (
|
||||
<a
|
||||
className='group relative inline-block cursor-pointer rounded-xl'
|
||||
className="group relative inline-block cursor-pointer rounded-xl"
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
locale={locale}
|
||||
footer={
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
tags={tagLabels}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
|
||||
1702
web/app/components/plugins/marketplace/list/index.spec.tsx
Normal file
1702
web/app/components/plugins/marketplace/list/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Empty from '../empty'
|
||||
import cn from '@/utils/classnames'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
locale: Locale
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: () => void
|
||||
@ -48,7 +49,8 @@ const List = ({
|
||||
<div className={cn(
|
||||
'grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{
|
||||
plugins.map((plugin) => {
|
||||
if (cardRender)
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import CardWrapper from './card-wrapper'
|
||||
|
||||
type ListWithCollectionProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
locale: Locale
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
|
||||
@ -37,21 +38,21 @@ const ListWithCollection = ({
|
||||
}).map(collection => (
|
||||
<div
|
||||
key={collection.name}
|
||||
className='py-3'
|
||||
className="py-3"
|
||||
>
|
||||
<div className='flex items-end justify-between'>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className='title-xl-semi-bold text-text-primary'>{collection.label[getLanguage(locale)]}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{collection.description[getLanguage(locale)]}</div>
|
||||
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
|
||||
</div>
|
||||
{
|
||||
collection.searchable && onMoreClick && (
|
||||
<div
|
||||
className='system-xs-medium flex cursor-pointer items-center text-text-accent '
|
||||
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
|
||||
onClick={() => onMoreClick?.(collection.search_params)}
|
||||
>
|
||||
{t('plugin.marketplace.viewMore')}
|
||||
<RiArrowRightSLine className='h-4 w-4' />
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -59,7 +60,8 @@ const ListWithCollection = ({
|
||||
<div className={cn(
|
||||
'mt-2 grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{
|
||||
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
|
||||
if (cardRender)
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import List from './index'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import List from './index'
|
||||
|
||||
type ListWrapperProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
locale: Locale
|
||||
}
|
||||
const ListWrapper = ({
|
||||
marketplaceCollections,
|
||||
@ -39,26 +40,28 @@ const ListWrapper = ({
|
||||
&& isSuccessCollections
|
||||
&& !searchPluginText
|
||||
&& !filterPluginTags.length
|
||||
)
|
||||
) {
|
||||
handleQueryPlugins()
|
||||
}
|
||||
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className='relative flex grow flex-col bg-background-default-subtle px-12 py-2'>
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
||||
>
|
||||
{
|
||||
plugins && (
|
||||
<div className='mb-4 flex items-center pt-3'>
|
||||
<div className='title-xl-semi-bold text-text-primary'>{t('plugin.marketplace.pluginsResult', { num: pluginsTotal })}</div>
|
||||
<div className='mx-3 h-3.5 w-[1px] bg-divider-regular'></div>
|
||||
<div className="mb-4 flex items-center pt-3">
|
||||
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
|
||||
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
||||
<SortDropdown locale={locale} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && page === 1 && (
|
||||
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
'use client'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiBrain2Line,
|
||||
@ -10,6 +8,8 @@ import {
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { useMarketplaceContext } from './context'
|
||||
import { useMixedTranslation } from './hooks'
|
||||
@ -41,55 +41,57 @@ const PluginTypeSwitch = ({
|
||||
const options = [
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
text: t('plugin.category.all'),
|
||||
text: t('category.all', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
text: t('plugin.category.models'),
|
||||
icon: <RiBrain2Line className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.models', { ns: 'plugin' }),
|
||||
icon: <RiBrain2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
text: t('plugin.category.tools'),
|
||||
icon: <RiHammerLine className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.tools', { ns: 'plugin' }),
|
||||
icon: <RiHammerLine className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
text: t('plugin.category.datasources'),
|
||||
icon: <RiDatabase2Line className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.datasources', { ns: 'plugin' }),
|
||||
icon: <RiDatabase2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
text: t('plugin.category.triggers'),
|
||||
icon: <TriggerIcon className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.triggers', { ns: 'plugin' }),
|
||||
icon: <TriggerIcon className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
text: t('plugin.category.agents'),
|
||||
icon: <RiSpeakAiLine className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.agents', { ns: 'plugin' }),
|
||||
icon: <RiSpeakAiLine className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
text: t('plugin.category.extensions'),
|
||||
icon: <RiPuzzle2Line className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.extensions', { ns: 'plugin' }),
|
||||
icon: <RiPuzzle2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
text: t('plugin.category.bundles'),
|
||||
icon: <RiArchive2Line className='mr-1.5 h-4 w-4' />,
|
||||
text: t('category.bundles', { ns: 'plugin' }),
|
||||
icon: <RiArchive2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
]
|
||||
|
||||
const handlePopState = useCallback(() => {
|
||||
if (!showSearchParams)
|
||||
return
|
||||
// nuqs handles popstate automatically
|
||||
const url = new URL(window.location.href)
|
||||
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
handleActivePluginTypeChange(category)
|
||||
}, [showSearchParams, handleActivePluginTypeChange])
|
||||
|
||||
useEffect(() => {
|
||||
// nuqs manages popstate internally, but we keep this for URL sync
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
@ -100,7 +102,8 @@ const PluginTypeSwitch = ({
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
className,
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
|
||||
1291
web/app/components/plugins/marketplace/search-box/index.spec.tsx
Normal file
1291
web/app/components/plugins/marketplace/search-box/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
import { RiCloseLine, RiSearchLine } from '@remixicon/react'
|
||||
import TagsFilter from './tags-filter'
|
||||
import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import TagsFilter from './tags-filter'
|
||||
|
||||
type SearchBoxProps = {
|
||||
search: string
|
||||
@ -40,12 +39,9 @@ const SearchBox = ({
|
||||
className={cn('z-[11] flex items-center', wrapperClassName)}
|
||||
>
|
||||
<div className={
|
||||
cn('flex items-center',
|
||||
usedInMarketplace && 'rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1.5 shadow-md',
|
||||
!usedInMarketplace && 'radius-md border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover',
|
||||
inputClassName,
|
||||
)
|
||||
}>
|
||||
cn('flex items-center', usedInMarketplace && 'rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1.5 shadow-md', !usedInMarketplace && 'radius-md border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover', inputClassName)
|
||||
}
|
||||
>
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<>
|
||||
@ -55,8 +51,8 @@ const SearchBox = ({
|
||||
usedInMarketplace
|
||||
locale={locale}
|
||||
/>
|
||||
<Divider type='vertical' className='mx-1 h-3.5' />
|
||||
<div className='flex grow items-center gap-x-2 p-1'>
|
||||
<Divider type="vertical" className="mx-1 h-3.5" />
|
||||
<div className="flex grow items-center gap-x-2 p-1">
|
||||
<input
|
||||
className={cn(
|
||||
'body-md-medium inline-block grow appearance-none bg-transparent text-text-secondary outline-none',
|
||||
@ -71,9 +67,9 @@ const SearchBox = ({
|
||||
search && (
|
||||
<ActionButton
|
||||
onClick={() => onSearchChange('')}
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
>
|
||||
<RiCloseLine className='size-4' />
|
||||
<RiCloseLine className="size-4" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
@ -84,8 +80,8 @@ const SearchBox = ({
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<>
|
||||
<div className='flex grow items-center py-[7px] pl-2 pr-3'>
|
||||
<RiSearchLine className='size-4 text-components-input-text-placeholder' />
|
||||
<div className="flex grow items-center py-[7px] pl-2 pr-3">
|
||||
<RiSearchLine className="size-4 text-components-input-text-placeholder" />
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
@ -102,14 +98,14 @@ const SearchBox = ({
|
||||
search && (
|
||||
<ActionButton
|
||||
onClick={() => onSearchChange('')}
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
>
|
||||
<RiCloseLine className='size-4' />
|
||||
<RiCloseLine className="size-4" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Divider type='vertical' className='mx-0 mr-0.5 h-3.5' />
|
||||
<Divider type="vertical" className="mx-0 mr-0.5 h-3.5" />
|
||||
<TagsFilter
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
@ -120,12 +116,12 @@ const SearchBox = ({
|
||||
}
|
||||
</div>
|
||||
{supportAddCustomTool && (
|
||||
<div className='flex shrink-0 items-center'>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<ActionButton
|
||||
className='ml-2 rounded-full bg-components-button-primary-bg text-components-button-primary-text hover:bg-components-button-primary-bg hover:text-components-button-primary-text'
|
||||
className="ml-2 rounded-full bg-components-button-primary-bg text-components-button-primary-text hover:bg-components-button-primary-bg hover:text-components-button-primary-text"
|
||||
onClick={onShowAddCustomCollectionModal}
|
||||
>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -18,14 +18,14 @@ const SearchBoxWrapper = ({
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
wrapperClassName='z-[11] mx-auto w-[640px] shrink-0'
|
||||
inputClassName='w-full'
|
||||
wrapperClassName="z-[11] mx-auto w-[640px] shrink-0"
|
||||
inputClassName="w-full"
|
||||
search={searchPluginText}
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={handleFilterPluginTagsChange}
|
||||
locale={locale}
|
||||
placeholder={t('plugin.searchPlugins')}
|
||||
placeholder={t('searchPlugins', { ns: 'plugin' })}
|
||||
usedInMarketplace
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import MarketplaceTrigger from './trigger/marketplace'
|
||||
@ -40,7 +40,7 @@ const TagsFilter = ({
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='bottom-start'
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
@ -49,7 +49,7 @@ const TagsFilter = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
{
|
||||
@ -76,29 +76,29 @@ const TagsFilter = ({
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
|
||||
<div className='p-2 pb-1'>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('pluginTags.searchTags') || ''}
|
||||
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='max-h-[448px] overflow-y-auto p-1'>
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.name}
|
||||
className='flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleCheck(option.name)}
|
||||
>
|
||||
<Checkbox
|
||||
className='mr-1'
|
||||
className="mr-1"
|
||||
checked={tags.includes(option.name)}
|
||||
/>
|
||||
<div className='system-sm-medium px-1 text-text-secondary'>
|
||||
<div className="system-sm-medium px-1 text-text-secondary">
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import type { Tag } from '../../../hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '../../hooks'
|
||||
|
||||
type MarketplaceTriggerProps = {
|
||||
@ -31,24 +31,25 @@ const MarketplaceTrigger = ({
|
||||
open && !selectedTagsLength && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<div className="p-0.5">
|
||||
<RiFilter3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
|
||||
</div>
|
||||
<div className='system-sm-medium flex items-center gap-x-1 p-1'>
|
||||
<div className="system-sm-medium flex items-center gap-x-1 p-1">
|
||||
{
|
||||
!selectedTagsLength && <span>{t('pluginTags.allTags')}</span>
|
||||
!selectedTagsLength && <span>{t('allTags', { ns: 'pluginTags' })}</span>
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<span className='text-text-secondary'>
|
||||
<span className="text-text-secondary">
|
||||
{tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className='system-xs-medium text-text-tertiary'>
|
||||
+{selectedTagsLength - 2}
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -56,15 +57,15 @@ const MarketplaceTrigger = ({
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className='size-4 text-text-quaternary'
|
||||
className="size-4 text-text-quaternary"
|
||||
onClick={() => onTagsChange([])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedTagsLength && (
|
||||
<div className='p-0.5'>
|
||||
<RiArrowDownSLine className='size-4 text-text-tertiary' />
|
||||
<div className="p-0.5">
|
||||
<RiArrowDownSLine className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import type { Tag } from '../../../hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ToolSelectorTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
@ -26,19 +26,20 @@ const ToolSelectorTrigger = ({
|
||||
open && !selectedTagsLength && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<div className="p-0.5">
|
||||
<RiPriceTag3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<div className='system-sm-medium flex items-center gap-x-0.5 px-0.5 py-1'>
|
||||
<span className='text-text-secondary'>
|
||||
<div className="system-sm-medium flex items-center gap-x-0.5 px-0.5 py-1">
|
||||
<span className="text-text-secondary">
|
||||
{tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')}
|
||||
</span>
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className='system-xs-medium text-text-tertiary'>
|
||||
+{selectedTagsLength - 2}
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -48,7 +49,7 @@ const ToolSelectorTrigger = ({
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className='size-4 text-text-quaternary'
|
||||
className="size-4 text-text-quaternary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagsChange([])
|
||||
|
||||
@ -0,0 +1,742 @@
|
||||
import type { MarketplaceContextValue } from '../context'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SortDropdown from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.sortBy': 'Sort by',
|
||||
'plugin.marketplace.sortOption.mostPopular': 'Most Popular',
|
||||
'plugin.marketplace.sortOption.recentlyUpdated': 'Recently Updated',
|
||||
'plugin.marketplace.sortOption.newlyReleased': 'Newly Released',
|
||||
'plugin.marketplace.sortOption.firstReleased': 'First Released',
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: mockTranslation,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock marketplace context with controllable values
|
||||
let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const mockHandleSortChange = vi.fn()
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => {
|
||||
const contextValue = {
|
||||
sort: mockSort,
|
||||
handleSortChange: mockHandleSortChange,
|
||||
} as unknown as MarketplaceContextValue
|
||||
return selector(contextValue)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock portal component with controllable open state
|
||||
let mockPortalOpenState = false
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
mockPortalOpenState = open
|
||||
return (
|
||||
<div data-testid="portal-wrapper" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
// Match actual behavior: only render when portal is open
|
||||
if (!mockPortalOpenState)
|
||||
return null
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Factory Functions
|
||||
// ================================
|
||||
|
||||
type SortOption = {
|
||||
value: string
|
||||
order: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const createSortOptions = (): SortOption[] => [
|
||||
{ value: 'install_count', order: 'DESC', text: 'Most Popular' },
|
||||
{ value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' },
|
||||
{ value: 'created_at', order: 'DESC', text: 'Newly Released' },
|
||||
{ value: 'created_at', order: 'ASC', text: 'First Released' },
|
||||
]
|
||||
|
||||
// ================================
|
||||
// SortDropdown Component Tests
|
||||
// ================================
|
||||
describe('SortDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
mockPortalOpenState = false
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort by label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected option text', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow down icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary')
|
||||
expect(arrowIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element with correct styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Testing
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should accept locale prop', () => {
|
||||
render(<SortDropdown locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call useMixedTranslation with provided locale', () => {
|
||||
render(<SortDropdown locale="ja-JP" />)
|
||||
|
||||
// Translation function should be called for labels
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should render without locale prop (undefined)', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty string locale', () => {
|
||||
render(<SortDropdown locale="" />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed state', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should display correct selected option for install_count DESC', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for version_updated_at DESC', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at DESC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Newly Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at ASC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// After click, portal content should be visible
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger clicked again', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Open
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown on trigger click', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sort options when open', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
|
||||
expect(within(content).getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleSortChange when option clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Click on "Recently Updated"
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'version_updated_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Most Popular', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Newly Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for First Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow selecting currently selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should support userEvent for trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Check Icon Tests
|
||||
// ================================
|
||||
describe('Check Icon', () => {
|
||||
it('should show check icon for selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Check icon should be present in the dropdown
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show check icon only for matching sortBy AND sortOrder', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// "Newly Released" (created_at DESC) should have check icon
|
||||
// "First Released" (created_at ASC) should NOT have check icon
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should not show check icon for different sortOrder with same sortBy', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Only one check icon should be visible (for Newly Released, not First Released)
|
||||
const checkIcons = container.querySelectorAll('.text-text-accent')
|
||||
expect(checkIcons.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dropdown Options Structure Tests
|
||||
// ================================
|
||||
describe('Dropdown Options Structure', () => {
|
||||
const sortOptions = createSortOptions()
|
||||
|
||||
it('should render 4 sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it.each(sortOptions)('should render option: $text', ({ text }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText(text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options with unique keys', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// All options should be rendered (no key conflicts)
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render dropdown container with correct styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.firstChild as HTMLElement
|
||||
expect(container).toHaveClass('rounded-xl', 'shadow-lg')
|
||||
})
|
||||
|
||||
it('should render option items with hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.cursor-pointer')
|
||||
expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
// The component falls back to the first option (Most Popular) when sort values are invalid
|
||||
|
||||
it('should fallback to default option when sortBy is unknown', () => {
|
||||
mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Should fallback to first option "Most Popular"
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default option when sortBy is empty', () => {
|
||||
mockSort = { sortBy: '', sortOrder: 'DESC' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default option when sortOrder is unknown', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly when handleSortChange is a no-op', () => {
|
||||
mockHandleSortChange.mockImplementation(() => {})
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid toggle clicks', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Final state should be open (odd number of clicks)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple option selections', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
|
||||
// Click multiple options
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Context Integration Tests
|
||||
// ================================
|
||||
describe('Context Integration', () => {
|
||||
it('should read sort value from context', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call context handleSortChange on selection', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update display when context sort changes', () => {
|
||||
const { rerender } = render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
|
||||
// Simulate context change
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
rerender(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use selector pattern correctly', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Component should have called useMarketplaceContext with selector functions
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have cursor pointer on trigger', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have cursor pointer on options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have visible focus indicators via hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')
|
||||
expect(option).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Tests
|
||||
// ================================
|
||||
describe('Translations', () => {
|
||||
it('should call translation for sortBy label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should call translation for all sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.mostPopular', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<SortDropdown locale="pt-BR" />)
|
||||
|
||||
// Verify component renders with locale
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Component Integration Tests
|
||||
// ================================
|
||||
describe('Portal Component Integration', () => {
|
||||
it('should pass open state to PortalToFollowElem', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(wrapper).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should render trigger content inside PortalToFollowElemTrigger', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options inside PortalToFollowElemContent', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Visual Style Tests
|
||||
// ================================
|
||||
describe('Visual Styles', () => {
|
||||
it('should apply correct trigger container styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg')
|
||||
expect(triggerDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply secondary text color to sort by label', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const label = container.querySelector('.text-text-secondary')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('Sort by')
|
||||
})
|
||||
|
||||
it('should apply primary text color to selected option', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const selected = container.querySelector('.text-text-primary.system-sm-medium')
|
||||
expect(selected).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply tertiary text color to arrow icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrow = container.querySelector('.text-text-tertiary')
|
||||
expect(arrow).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply accent text color to check icon when option selected', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply blur backdrop to dropdown container', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.querySelector('.backdrop-blur-sm')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// All Sort Options Click Tests
|
||||
// ================================
|
||||
describe('All Sort Options Click Handlers', () => {
|
||||
const testCases = [
|
||||
{ text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' },
|
||||
{ text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' },
|
||||
{ text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' },
|
||||
{ text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' },
|
||||
]
|
||||
|
||||
it.each(testCases)(
|
||||
'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"',
|
||||
({ text, sortBy, sortOrder }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText(text))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder })
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
|
||||
type SortDropdownProps = {
|
||||
locale?: string
|
||||
@ -23,32 +23,32 @@ const SortDropdown = ({
|
||||
{
|
||||
value: 'install_count',
|
||||
order: 'DESC',
|
||||
text: t('plugin.marketplace.sortOption.mostPopular'),
|
||||
text: t('marketplace.sortOption.mostPopular', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'version_updated_at',
|
||||
order: 'DESC',
|
||||
text: t('plugin.marketplace.sortOption.recentlyUpdated'),
|
||||
text: t('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'created_at',
|
||||
order: 'DESC',
|
||||
text: t('plugin.marketplace.sortOption.newlyReleased'),
|
||||
text: t('marketplace.sortOption.newlyReleased', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'created_at',
|
||||
order: 'ASC',
|
||||
text: t('plugin.marketplace.sortOption.firstReleased'),
|
||||
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
|
||||
},
|
||||
]
|
||||
const sort = useMarketplaceContext(v => v.sort)
|
||||
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder)!
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='bottom-start'
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
@ -57,29 +57,29 @@ const SortDropdown = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className='flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3'>
|
||||
<span className='system-sm-regular mr-1 text-text-secondary'>
|
||||
{t('plugin.marketplace.sortBy')}
|
||||
<div className="flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3">
|
||||
<span className="system-sm-regular mr-1 text-text-secondary">
|
||||
{t('marketplace.sortBy', { ns: 'plugin' })}
|
||||
</span>
|
||||
<span className='system-sm-medium mr-1 text-text-primary'>
|
||||
<span className="system-sm-medium mr-1 text-text-primary">
|
||||
{selectedOption.text}
|
||||
</span>
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
<RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'>
|
||||
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={`${option.value}-${option.order}`}
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 pr-2 text-text-primary hover:bg-components-panel-on-panel-item-bg-hover'
|
||||
className="system-md-regular flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 pr-2 text-text-primary hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
onClick={() => handleSortChange({ sortBy: option.value, sortOrder: option.order })}
|
||||
>
|
||||
{option.text}
|
||||
{
|
||||
sort.sortBy === option.value && sort.sortOrder === option.order && (
|
||||
<RiCheckLine className='ml-2 h-4 w-4 text-text-accent' />
|
||||
<RiCheckLine className="ml-2 h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PluginTypeSwitch from './plugin-type-switch'
|
||||
import cn from '@/utils/classnames'
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
|
||||
type StickySearchAndSwitchWrapperProps = {
|
||||
locale?: string
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginsSearchParams,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import {
|
||||
APP_VERSION,
|
||||
IS_MARKETPLACE,
|
||||
MARKETPLACE_API_PREFIX,
|
||||
} from '@/config'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
|
||||
type MarketplaceFetchOptions = {
|
||||
signal?: AbortSignal
|
||||
@ -152,22 +151,3 @@ export const getMarketplaceListFilterType = (category: string) => {
|
||||
|
||||
return 'plugin'
|
||||
}
|
||||
|
||||
export const updateSearchParams = (pluginsSearchParams: PluginsSearchParams) => {
|
||||
const { query, category, tags } = pluginsSearchParams
|
||||
const url = new URL(window.location.href)
|
||||
const categoryChanged = url.searchParams.get('category') !== category
|
||||
if (query)
|
||||
url.searchParams.set('q', query)
|
||||
else
|
||||
url.searchParams.delete('q')
|
||||
if (category)
|
||||
url.searchParams.set('category', category)
|
||||
else
|
||||
url.searchParams.delete('category')
|
||||
if (tags && tags.length)
|
||||
url.searchParams.set('tags', tags.join(','))
|
||||
else
|
||||
url.searchParams.delete('tags')
|
||||
history[`${categoryChanged ? 'pushState' : 'replaceState'}`]({}, '', url)
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import ApiKeyModal from './api-key-modal'
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
|
||||
export type AddApiKeyButtonProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -29,7 +29,7 @@ const AddApiKeyButton = ({
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
variant={buttonVariant}
|
||||
onClick={() => setIsApiKeyModalOpen(true)}
|
||||
disabled={disabled}
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -5,26 +13,18 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import OAuthClientSettings from './oauth-client-settings'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { PluginPayload } from '../types'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
useGetPluginOAuthClientSchemaHook,
|
||||
useGetPluginOAuthUrlHook,
|
||||
} from '../hooks/use-credential'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import OAuthClientSettings from './oauth-client-settings'
|
||||
|
||||
export type AddOAuthButtonProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -88,37 +88,37 @@ const AddOAuthButton = ({
|
||||
|
||||
const renderCustomLabel = useCallback((item: FormSchema) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-4 flex rounded-xl bg-background-section-burn p-4'>
|
||||
<div className='mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg'>
|
||||
<RiInformation2Fill className='h-5 w-5 text-text-accent' />
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex rounded-xl bg-background-section-burn p-4">
|
||||
<div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg">
|
||||
<RiInformation2Fill className="h-5 w-5 text-text-accent" />
|
||||
</div>
|
||||
<div className='w-0 grow'>
|
||||
<div className='system-sm-regular mb-1.5'>
|
||||
{t('plugin.auth.clientInfo')}
|
||||
<div className="w-0 grow">
|
||||
<div className="system-sm-regular mb-1.5">
|
||||
{t('auth.clientInfo', { ns: 'plugin' })}
|
||||
</div>
|
||||
{
|
||||
redirect_uri && (
|
||||
<div className='system-sm-medium flex w-full py-0.5'>
|
||||
<div className='w-0 grow break-words break-all'>{redirect_uri}</div>
|
||||
<div className="system-sm-medium flex w-full py-0.5">
|
||||
<div className="w-0 grow break-words break-all">{redirect_uri}</div>
|
||||
<ActionButton
|
||||
className='shrink-0'
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(redirect_uri || '')
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className='h-4 w-4' />
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-sm-medium flex h-6 items-center text-text-secondary'>
|
||||
<div className="system-sm-medium flex h-6 items-center text-text-secondary">
|
||||
{renderI18nObject(item.label as Record<string, string>)}
|
||||
{
|
||||
item.required && (
|
||||
<span className='ml-1 text-text-destructive-secondary'>*</span>
|
||||
<span className="ml-1 text-text-destructive-secondary">*</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@ -136,15 +136,15 @@ const AddOAuthButton = ({
|
||||
if (is_system_oauth_params_exists) {
|
||||
result.unshift({
|
||||
name: '__oauth_client__',
|
||||
label: t('plugin.auth.oauthClient'),
|
||||
label: t('auth.oauthClient', { ns: 'plugin' }),
|
||||
type: FormTypeEnum.radio,
|
||||
options: [
|
||||
{
|
||||
label: t('plugin.auth.default'),
|
||||
label: t('auth.default', { ns: 'plugin' }),
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
label: t('plugin.auth.custom'),
|
||||
label: t('auth.custom', { ns: 'plugin' }),
|
||||
value: 'custom',
|
||||
},
|
||||
],
|
||||
@ -197,9 +197,10 @@ const AddOAuthButton = ({
|
||||
<div className={cn(
|
||||
'flex h-full w-0 grow items-center justify-center rounded-l-lg pl-0.5 hover:bg-components-button-primary-bg-hover',
|
||||
buttonLeftClassName,
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='truncate'
|
||||
className="truncate"
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
@ -212,7 +213,7 @@ const AddOAuthButton = ({
|
||||
buttonVariant === 'primary' && 'border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface',
|
||||
)}
|
||||
>
|
||||
{t('plugin.auth.custom')}
|
||||
{t('auth.custom', { ns: 'plugin' })}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@ -220,8 +221,11 @@ const AddOAuthButton = ({
|
||||
<div className={cn(
|
||||
'h-4 w-[1px] shrink-0 bg-text-primary-on-surface opacity-[0.15]',
|
||||
dividerClassName,
|
||||
)}></div>
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
data-testid="oauth-settings-button"
|
||||
className={cn(
|
||||
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
|
||||
buttonRightClassName,
|
||||
@ -231,7 +235,7 @@ const AddOAuthButton = ({
|
||||
setIsOAuthSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className='h-4 w-4' />
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
@ -242,10 +246,10 @@ const AddOAuthButton = ({
|
||||
variant={buttonVariant}
|
||||
onClick={() => setIsOAuthSettingsOpen(true)}
|
||||
disabled={disabled}
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
>
|
||||
<RiEqualizer2Line className='mr-0.5 h-4 w-4' />
|
||||
{t('plugin.auth.setupOAuth')}
|
||||
<RiEqualizer2Line className="mr-0.5 h-4 w-4" />
|
||||
{t('auth.setupOAuth', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -6,25 +11,20 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import type { PluginPayload } from '../types'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
useAddPluginCredentialHook,
|
||||
useGetPluginCredentialSchemaHook,
|
||||
useUpdatePluginCredentialHook,
|
||||
} from '../hooks/use-credential'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
|
||||
export type ApiKeyModalProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -64,7 +64,7 @@ const ApiKeyModal = ({
|
||||
{
|
||||
type: FormTypeEnum.textInput,
|
||||
name: '__name__',
|
||||
label: t('plugin.auth.authorizationName'),
|
||||
label: t('auth.authorizationName', { ns: 'plugin' }),
|
||||
required: false,
|
||||
},
|
||||
...mergedData,
|
||||
@ -115,7 +115,7 @@ const ApiKeyModal = ({
|
||||
}
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
|
||||
onClose?.()
|
||||
@ -128,9 +128,9 @@ const ApiKeyModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size='md'
|
||||
title={t('plugin.auth.useApiAuth')}
|
||||
subTitle={t('plugin.auth.useApiAuthDesc')}
|
||||
size="md"
|
||||
title={t('auth.useApiAuth', { ns: 'plugin' })}
|
||||
subTitle={t('auth.useApiAuthDesc', { ns: 'plugin' })}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
footerSlot={
|
||||
@ -142,14 +142,14 @@ const ApiKeyModal = ({
|
||||
onExtraButtonClick={onRemove}
|
||||
disabled={disabled || isLoading || doingAction}
|
||||
clickOutsideNotClose={true}
|
||||
wrapperClassName='!z-[101]'
|
||||
wrapperClassName="!z-[101]"
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
{
|
||||
isLoading && (
|
||||
<div className='flex h-40 items-center justify-center'>
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
786
web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
Normal file
786
web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
Normal file
@ -0,0 +1,786 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginPayload } from '../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
import Authorize from './index'
|
||||
|
||||
// Create a wrapper with QueryClientProvider for real component testing
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock API hooks - only mock network-related hooks
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useGetPluginOAuthUrlHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }),
|
||||
}),
|
||||
useGetPluginOAuthClientSchemaHook: () => ({
|
||||
data: mockGetPluginOAuthClientSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSetPluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useDeletePluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useInvalidPluginOAuthClientSchemaHook: () => vi.fn(),
|
||||
useAddPluginCredentialHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useUpdatePluginCredentialHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useGetPluginCredentialSchemaHook: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock openOAuthPopup - window operations
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock service/use-triggers - API service
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({
|
||||
data: { options: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useTriggerPluginDynamicOptionsInfo: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Factory function for creating test PluginPayload
|
||||
const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Authorize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetPluginOAuthClientSchema.mockReturnValue({
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// No buttons should be rendered
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
// Container should only have wrapper element
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// OAuth button should exist (either configured or setup button)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only API Key button when canApiKey is true and canOAuth is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both OAuth and API Key buttons when both are true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render divider when showDivider is true and both buttons are shown', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDivider is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when only one button type is shown', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider by default (showDivider defaults to true)', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props Testing', () => {
|
||||
describe('theme prop', () => {
|
||||
it('should render buttons with secondary theme variant when theme is secondary', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
theme="secondary"
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.className).toContain('btn-secondary')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled prop', () => {
|
||||
it('should disable OAuth button when disabled is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when disabled is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable buttons when disabled is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('notAllowCustomCredential prop', () => {
|
||||
it('should disable OAuth button when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add opacity class when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const wrappers = container.querySelectorAll('.opacity-50')
|
||||
expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Button Text Variations ====================
|
||||
describe('Button Text Variations', () => {
|
||||
it('should show correct OAuth text based on canApiKey', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
// When canApiKey is false, should show "useOAuthAuth"
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('plugin.auth')
|
||||
|
||||
// When canApiKey is true, button text changes
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization Dependencies ====================
|
||||
describe('Memoization and Re-rendering', () => {
|
||||
it('should maintain stable props across re-renders with same dependencies', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialButtonCount = screen.getAllByRole('button').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(initialButtonCount)
|
||||
})
|
||||
|
||||
it('should update when canApiKey changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update when canOAuth changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update button variant when theme changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttonPrimary = screen.getByRole('button')
|
||||
// Primary theme with canOAuth=false should have primary variant
|
||||
expect(buttonPrimary.className).toContain('btn-primary')
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
theme="secondary"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined pluginPayload properties gracefully', () => {
|
||||
const pluginPayload: PluginPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
providerType: undefined,
|
||||
detail: undefined,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle all auth categories', () => {
|
||||
const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const pluginPayload = createPluginPayload({ category })
|
||||
|
||||
const { unmount } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty string provider', () => {
|
||||
const pluginPayload = createPluginPayload({ provider: '' })
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle both disabled and notAllowCustomCredential together', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Memoization ====================
|
||||
describe('Component Memoization', () => {
|
||||
it('should be a memoized component (exported with memo)', async () => {
|
||||
const AuthorizeDefault = (await import('./index')).default
|
||||
expect(AuthorizeDefault).toBeDefined()
|
||||
// memo wrapped components are React elements with $$typeof
|
||||
expect(typeof AuthorizeDefault).toBe('object')
|
||||
})
|
||||
|
||||
it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialOpacityElements = container.querySelectorAll('.opacity-50').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
|
||||
})
|
||||
|
||||
it('should update wrapper when notAllowCustomCredential changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(0)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Integration with pluginPayload ====================
|
||||
describe('pluginPayload Integration', () => {
|
||||
it('should pass pluginPayload to OAuth button', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
provider: 'special-provider',
|
||||
category: AuthCategory.model,
|
||||
})
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass pluginPayload to API Key button', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
provider: 'another-provider',
|
||||
category: AuthCategory.datasource,
|
||||
})
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pluginPayload with detail property', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
detail: {
|
||||
plugin_id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
} as PluginPayload['detail'],
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Conditional Rendering Scenarios ====================
|
||||
describe('Conditional Rendering Scenarios', () => {
|
||||
it('should handle rapid prop changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={true} />)
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={false} />)
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={false} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should correctly toggle divider visibility based on button combinations', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
showDivider={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Accessibility ====================
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible button elements', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should indicate disabled state for accessibility', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,15 +1,15 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { AddApiKeyButtonProps } from './add-api-key-button'
|
||||
import type { AddOAuthButtonProps } from './add-oauth-button'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddOAuthButton from './add-oauth-button'
|
||||
import type { AddOAuthButtonProps } from './add-oauth-button'
|
||||
import AddApiKeyButton from './add-api-key-button'
|
||||
import type { AddApiKeyButtonProps } from './add-api-key-button'
|
||||
import type { PluginPayload } from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AddApiKeyButton from './add-api-key-button'
|
||||
import AddOAuthButton from './add-oauth-button'
|
||||
|
||||
type AuthorizeProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -35,7 +35,7 @@ const Authorize = ({
|
||||
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
|
||||
if (theme === 'secondary') {
|
||||
return {
|
||||
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
|
||||
buttonText: !canApiKey ? t('auth.useOAuthAuth', { ns: 'plugin' }) : t('auth.addOAuth', { ns: 'plugin' }),
|
||||
buttonVariant: 'secondary',
|
||||
className: 'hover:bg-components-button-secondary-bg',
|
||||
buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover',
|
||||
@ -46,7 +46,7 @@ const Authorize = ({
|
||||
}
|
||||
|
||||
return {
|
||||
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
|
||||
buttonText: !canApiKey ? t('auth.useOAuthAuth', { ns: 'plugin' }) : t('auth.addOAuth', { ns: 'plugin' }),
|
||||
pluginPayload,
|
||||
}
|
||||
}, [canApiKey, theme, pluginPayload, t])
|
||||
@ -56,12 +56,12 @@ const Authorize = ({
|
||||
return {
|
||||
pluginPayload,
|
||||
buttonVariant: 'secondary',
|
||||
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
|
||||
buttonText: !canOAuth ? t('auth.useApiAuth', { ns: 'plugin' }) : t('auth.addApi', { ns: 'plugin' }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
pluginPayload,
|
||||
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
|
||||
buttonText: !canOAuth ? t('auth.useApiAuth', { ns: 'plugin' }) : t('auth.addApi', { ns: 'plugin' }),
|
||||
buttonVariant: !canOAuth ? 'primary' : 'secondary-accent',
|
||||
}
|
||||
}, [canOAuth, theme, pluginPayload, t])
|
||||
@ -79,7 +79,7 @@ const Authorize = ({
|
||||
|
||||
if (notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.credentialUnavailable')}>
|
||||
<Tooltip popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
@ -100,7 +100,7 @@ const Authorize = ({
|
||||
|
||||
if (notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.credentialUnavailable')}>
|
||||
<Tooltip popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
@ -110,7 +110,7 @@ const Authorize = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center space-x-1.5'>
|
||||
<div className="flex items-center space-x-1.5">
|
||||
{
|
||||
canOAuth && (
|
||||
OAuthButton
|
||||
@ -118,10 +118,10 @@ const Authorize = ({
|
||||
}
|
||||
{
|
||||
showDivider && canOAuth && canApiKey && (
|
||||
<div className='system-2xs-medium-uppercase flex shrink-0 flex-col items-center justify-between text-text-tertiary'>
|
||||
<div className='h-2 w-[1px] bg-divider-subtle'></div>
|
||||
<div className="system-2xs-medium-uppercase flex shrink-0 flex-col items-center justify-between text-text-tertiary">
|
||||
<div className="h-2 w-[1px] bg-divider-subtle"></div>
|
||||
or
|
||||
<div className='h-2 w-[1px] bg-divider-subtle'></div>
|
||||
<div className="h-2 w-[1px] bg-divider-subtle"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,30 +1,30 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import {
|
||||
useForm,
|
||||
useStore,
|
||||
} from '@tanstack/react-form'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useForm,
|
||||
useStore,
|
||||
} from '@tanstack/react-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
useDeletePluginOAuthCustomClientHook,
|
||||
useInvalidPluginOAuthClientSchemaHook,
|
||||
useSetPluginOAuthCustomClientHook,
|
||||
} from '../hooks/use-credential'
|
||||
import type { PluginPayload } from '../types'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
|
||||
type OAuthClientSettingsProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -88,7 +88,7 @@ const OAuthClientSettings = ({
|
||||
})
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
|
||||
onClose?.()
|
||||
@ -115,7 +115,7 @@ const OAuthClientSettings = ({
|
||||
await deletePluginOAuthCustomClient()
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
@ -131,12 +131,12 @@ const OAuthClientSettings = ({
|
||||
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
||||
return (
|
||||
<Modal
|
||||
title={t('plugin.auth.oauthClientSettings')}
|
||||
confirmButtonText={t('plugin.auth.saveAndAuth')}
|
||||
cancelButtonText={t('plugin.auth.saveOnly')}
|
||||
extraButtonText={t('common.operation.cancel')}
|
||||
title={t('auth.oauthClientSettings', { ns: 'plugin' })}
|
||||
confirmButtonText={t('auth.saveAndAuth', { ns: 'plugin' })}
|
||||
cancelButtonText={t('auth.saveOnly', { ns: 'plugin' })}
|
||||
extraButtonText={t('operation.cancel', { ns: 'common' })}
|
||||
showExtraButton
|
||||
extraButtonVariant='secondary'
|
||||
extraButtonVariant="secondary"
|
||||
onExtraButtonClick={onClose}
|
||||
onClose={onClose}
|
||||
onCancel={handleConfirm}
|
||||
@ -144,20 +144,20 @@ const OAuthClientSettings = ({
|
||||
disabled={disabled || doingAction}
|
||||
footerSlot={
|
||||
__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||
<div className='grow'>
|
||||
<div className="grow">
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='text-components-button-destructive-secondary-text'
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
disabled={disabled || doingAction || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('common.operation.remove')}
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
containerClassName='pt-0'
|
||||
wrapperClassName='!z-[101]'
|
||||
containerClassName="pt-0"
|
||||
wrapperClassName="!z-[101]"
|
||||
clickOutsideNotClose={true}
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type AuthorizedInDataSourceNodeProps = {
|
||||
authorizationsNum: number
|
||||
@ -19,17 +19,17 @@ const AuthorizedInDataSourceNode = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
size="small"
|
||||
onClick={onJumpToDataSourcePage}
|
||||
>
|
||||
<Indicator
|
||||
className='mr-1.5'
|
||||
color='green'
|
||||
className="mr-1.5"
|
||||
color="green"
|
||||
/>
|
||||
{
|
||||
authorizationsNum > 1
|
||||
? t('plugin.auth.authorizations')
|
||||
: t('plugin.auth.authorization')
|
||||
? t('auth.authorizations', { ns: 'plugin' })
|
||||
: t('auth.authorization', { ns: 'plugin' })
|
||||
}
|
||||
<RiEqualizer2Line
|
||||
className={cn(
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
} from './types'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
} from './types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
Authorized,
|
||||
usePluginAuth,
|
||||
@ -44,7 +44,7 @@ const AuthorizedInNode = ({
|
||||
let color = 'green'
|
||||
let defaultUnavailable = false
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
label = t('auth.workspaceDefault', { ns: 'plugin' })
|
||||
|
||||
const defaultCredential = credentials.find(c => c.is_default)
|
||||
|
||||
@ -55,7 +55,7 @@ const AuthorizedInNode = ({
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
label = credential ? credential.name : t('auth.authRemoved', { ns: 'plugin' })
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
|
||||
@ -66,7 +66,7 @@ const AuthorizedInNode = ({
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
size="small"
|
||||
className={cn(
|
||||
open && !removed && 'bg-components-button-ghost-bg-hover',
|
||||
removed && 'bg-transparent text-text-destructive',
|
||||
@ -74,7 +74,7 @@ const AuthorizedInNode = ({
|
||||
variant={(defaultUnavailable || unavailable) ? 'ghost' : 'secondary'}
|
||||
>
|
||||
<Indicator
|
||||
className='mr-1.5'
|
||||
className="mr-1.5"
|
||||
color={color as any}
|
||||
/>
|
||||
{label}
|
||||
@ -82,7 +82,7 @@ const AuthorizedInNode = ({
|
||||
(unavailable || defaultUnavailable) && (
|
||||
<>
|
||||
|
||||
{t('plugin.auth.unavailable')}
|
||||
{t('auth.unavailable', { ns: 'plugin' })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -99,7 +99,7 @@ const AuthorizedInNode = ({
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
name: t('auth.workspaceDefault', { ns: 'plugin' }),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
@ -124,9 +124,9 @@ const AuthorizedInNode = ({
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={4}
|
||||
placement='bottom-end'
|
||||
placement="bottom-end"
|
||||
triggerPopupSameWidth={false}
|
||||
popupClassName='w-[360px]'
|
||||
popupClassName="w-[360px]"
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
|
||||
@ -1,37 +1,36 @@
|
||||
import type { Credential, PluginPayload } from '../types'
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Authorize from '../authorize'
|
||||
import type { Credential } from '../types'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import ApiKeyModal from '../authorize/api-key-modal'
|
||||
import Item from './item'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import type { PluginPayload } from '../types'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Authorize from '../authorize'
|
||||
import ApiKeyModal from '../authorize/api-key-modal'
|
||||
import {
|
||||
useDeletePluginCredentialHook,
|
||||
useSetPluginDefaultCredentialHook,
|
||||
useUpdatePluginCredentialHook,
|
||||
} from '../hooks/use-credential'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import Item from './item'
|
||||
|
||||
type AuthorizedProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -118,7 +117,7 @@ const Authorized = ({
|
||||
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onUpdate?.()
|
||||
setDeleteCredentialId(null)
|
||||
@ -145,7 +144,7 @@ const Authorized = ({
|
||||
await setPluginDefaultCredential(id)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
@ -165,7 +164,7 @@ const Authorized = ({
|
||||
await updatePluginCredential(payload)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
@ -193,37 +192,40 @@ const Authorized = ({
|
||||
renderTrigger
|
||||
? renderTrigger(mergedIsOpen)
|
||||
: (
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full',
|
||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||
)}>
|
||||
<Indicator className='mr-2' color={unavailableCredential ? 'gray' : 'green'} />
|
||||
{credentials.length}
|
||||
{
|
||||
credentials.length > 1
|
||||
? t('plugin.auth.authorizations')
|
||||
: t('plugin.auth.authorization')
|
||||
}
|
||||
{
|
||||
!!unavailableCredentials.length && (
|
||||
` (${unavailableCredentials.length} ${t('plugin.auth.unavailable')})`
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full',
|
||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
|
||||
{credentials.length}
|
||||
|
||||
{
|
||||
credentials.length > 1
|
||||
? t('auth.authorizations', { ns: 'plugin' })
|
||||
: t('auth.authorization', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
!!unavailableCredentials.length && (
|
||||
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<div className={cn(
|
||||
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
|
||||
popupClassName,
|
||||
)}>
|
||||
<div className='py-1'>
|
||||
)}
|
||||
>
|
||||
<div className="py-1">
|
||||
{
|
||||
!!extraAuthorizationItems?.length && (
|
||||
<div className='p-1'>
|
||||
<div className="p-1">
|
||||
{
|
||||
extraAuthorizationItems.map(credential => (
|
||||
<Item
|
||||
@ -245,11 +247,12 @@ const Authorized = ({
|
||||
}
|
||||
{
|
||||
!!oAuthCredentials.length && (
|
||||
<div className='p-1'>
|
||||
<div className="p-1">
|
||||
<div className={cn(
|
||||
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
|
||||
showItemSelectedIcon && 'pl-7',
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
OAuth
|
||||
</div>
|
||||
{
|
||||
@ -274,11 +277,12 @@ const Authorized = ({
|
||||
}
|
||||
{
|
||||
!!apiKeyCredentials.length && (
|
||||
<div className='p-1'>
|
||||
<div className="p-1">
|
||||
<div className={cn(
|
||||
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
|
||||
showItemSelectedIcon && 'pl-7',
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
API Keys
|
||||
</div>
|
||||
{
|
||||
@ -306,11 +310,11 @@ const Authorized = ({
|
||||
{
|
||||
!notAllowCustomCredential && (
|
||||
<>
|
||||
<div className='h-[1px] bg-divider-subtle'></div>
|
||||
<div className='p-2'>
|
||||
<div className="h-[1px] bg-divider-subtle"></div>
|
||||
<div className="p-2">
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
theme='secondary'
|
||||
theme="secondary"
|
||||
showDivider={false}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
@ -328,7 +332,7 @@ const Authorized = ({
|
||||
deleteCredentialId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('datasetDocuments.list.delete.title')}
|
||||
title={t('list.delete.title', { ns: 'datasetDocuments' })}
|
||||
isDisabled={doingAction}
|
||||
onCancel={closeConfirm}
|
||||
onConfirm={handleConfirm}
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Credential } from '../types'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Credential } from '../types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
|
||||
type ItemProps = {
|
||||
@ -77,18 +77,18 @@ const Item = ({
|
||||
>
|
||||
{
|
||||
renaming && (
|
||||
<div className='flex w-full items-center space-x-1'>
|
||||
<div className="flex w-full items-center space-x-1">
|
||||
<Input
|
||||
wrapperClassName='grow rounded-[6px]'
|
||||
className='h-6'
|
||||
wrapperClassName="grow rounded-[6px]"
|
||||
className="h-6"
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
placeholder={t('common.placeholder.input')}
|
||||
placeholder={t('placeholder.input', { ns: 'common' })}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
variant='primary'
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRename?.({
|
||||
@ -98,48 +98,48 @@ const Item = ({
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!renaming && (
|
||||
<div className='flex w-0 grow items-center space-x-1.5'>
|
||||
<div className="flex w-0 grow items-center space-x-1.5">
|
||||
{
|
||||
showSelectedIcon && (
|
||||
<div className='h-4 w-4'>
|
||||
<div className="h-4 w-4">
|
||||
{
|
||||
selectedCredentialId === credential.id && (
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Indicator
|
||||
className='ml-2 mr-1.5 shrink-0'
|
||||
className="ml-2 mr-1.5 shrink-0"
|
||||
color={credential.not_allowed_to_use ? 'gray' : 'green'}
|
||||
/>
|
||||
<div
|
||||
className='system-md-regular truncate text-text-secondary'
|
||||
className="system-md-regular truncate text-text-secondary"
|
||||
title={credential.name}
|
||||
>
|
||||
{credential.name}
|
||||
</div>
|
||||
{
|
||||
credential.is_default && (
|
||||
<Badge className='shrink-0'>
|
||||
{t('plugin.auth.default')}
|
||||
<Badge className="shrink-0">
|
||||
{t('auth.default', { ns: 'plugin' })}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@ -148,31 +148,31 @@ const Item = ({
|
||||
}
|
||||
{
|
||||
credential.from_enterprise && (
|
||||
<Badge className='shrink-0'>
|
||||
<Badge className="shrink-0">
|
||||
Enterprise
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
{
|
||||
showAction && !renaming && (
|
||||
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
|
||||
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
|
||||
{
|
||||
!credential.is_default && !disableSetDefault && !credential.not_allowed_to_use && (
|
||||
<Button
|
||||
size='small'
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSetDefault?.(credential.id)
|
||||
}}
|
||||
>
|
||||
{t('plugin.auth.setDefault')}
|
||||
{t('auth.setDefault', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableRename && !credential.from_enterprise && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('common.operation.rename')}>
|
||||
<Tooltip popupContent={t('operation.rename', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
@ -181,14 +181,14 @@ const Item = ({
|
||||
setRenameValue(credential.name)
|
||||
}}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4 text-text-tertiary' />
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isOAuth && !disableEdit && !credential.from_enterprise && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('common.operation.edit')}>
|
||||
<Tooltip popupContent={t('operation.edit', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
@ -203,23 +203,23 @@ const Item = ({
|
||||
)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableDelete && !credential.from_enterprise && (
|
||||
<Tooltip popupContent={t('common.operation.delete')}>
|
||||
<Tooltip popupContent={t('operation.delete', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
className='hover:bg-transparent'
|
||||
className="hover:bg-transparent"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential.id)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary hover:text-text-destructive' />
|
||||
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary hover:text-text-destructive" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
@ -232,7 +232,7 @@ const Item = ({
|
||||
|
||||
if (credential.not_allowed_to_use) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.customCredentialUnavailable')}>
|
||||
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
|
||||
{CredentialItem}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { CredentialTypeEnum, PluginPayload } from '../types'
|
||||
import {
|
||||
useAddPluginCredential,
|
||||
useDeletePluginCredential,
|
||||
@ -12,10 +13,8 @@ import {
|
||||
useSetPluginOAuthCustomClient,
|
||||
useUpdatePluginCredential,
|
||||
} from '@/service/use-plugins-auth'
|
||||
import { useGetApi } from './use-get-api'
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { CredentialTypeEnum } from '../types'
|
||||
import { useInvalidToolsByType } from '@/service/use-tools'
|
||||
import { useGetApi } from './use-get-api'
|
||||
|
||||
export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import {
|
||||
AuthCategory,
|
||||
} from '../types'
|
||||
import type {
|
||||
CredentialTypeEnum,
|
||||
PluginPayload,
|
||||
} from '../types'
|
||||
import {
|
||||
AuthCategory,
|
||||
} from '../types'
|
||||
|
||||
export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayload) => {
|
||||
if (category === AuthCategory.tool) {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
@ -5,7 +6,6 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import type { PluginPayload } from '../types'
|
||||
import {
|
||||
useDeletePluginCredentialHook,
|
||||
useSetPluginDefaultCredentialHook,
|
||||
@ -50,7 +50,7 @@ export const usePluginAuthAction = (
|
||||
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onUpdate?.()
|
||||
setDeleteCredentialId(null)
|
||||
@ -77,7 +77,7 @@ export const usePluginAuthAction = (
|
||||
await setPluginDefaultCredential(id)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
@ -97,7 +97,7 @@ export const usePluginAuthAction = (
|
||||
await updatePluginCredential(payload)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
message: t('api.actionSuccess', { ns: 'common' }),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import {
|
||||
useGetPluginCredentialInfoHook,
|
||||
useInvalidPluginCredentialInfoHook,
|
||||
} from './use-credential'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import type { PluginPayload } from '../types'
|
||||
|
||||
export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) => {
|
||||
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable)
|
||||
|
||||
2035
web/app/components/plugins/plugin-auth/index.spec.tsx
Normal file
2035
web/app/components/plugins/plugin-auth/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,12 @@
|
||||
export { default as PluginAuth } from './plugin-auth'
|
||||
export { default as Authorized } from './authorized'
|
||||
export { default as AuthorizedInNode } from './authorized-in-node'
|
||||
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
|
||||
export { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
|
||||
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'
|
||||
export { default as AddOAuthButton } from './authorize/add-oauth-button'
|
||||
export { default as AddApiKeyButton } from './authorize/add-api-key-button'
|
||||
export { default as AddOAuthButton } from './authorize/add-oauth-button'
|
||||
export { default as ApiKeyModal } from './authorize/api-key-modal'
|
||||
export { default as Authorized } from './authorized'
|
||||
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'
|
||||
export { default as AuthorizedInNode } from './authorized-in-node'
|
||||
export { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
export * from './hooks/use-plugin-auth-action'
|
||||
export { default as PluginAuth } from './plugin-auth'
|
||||
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
|
||||
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
|
||||
export * from './types'
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
} from './types'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
} from './types'
|
||||
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
|
||||
type PluginAuthInAgentProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -41,7 +41,7 @@ const PluginAuthInAgent = ({
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
name: t('auth.workspaceDefault', { ns: 'plugin' }),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
@ -62,11 +62,11 @@ const PluginAuthInAgent = ({
|
||||
let unavailable = false
|
||||
let color = 'green'
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
label = t('auth.workspaceDefault', { ns: 'plugin' })
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
label = credential ? credential.name : t('auth.authRemoved', { ns: 'plugin' })
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
if (removed)
|
||||
@ -80,16 +80,17 @@ const PluginAuthInAgent = ({
|
||||
'w-full',
|
||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||
removed && 'text-text-destructive',
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<Indicator
|
||||
className='mr-2'
|
||||
className="mr-2"
|
||||
color={color as any}
|
||||
/>
|
||||
{label}
|
||||
{
|
||||
unavailable && t('plugin.auth.unavailable')
|
||||
unavailable && t('auth.unavailable', { ns: 'plugin' })
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user