mirror of
https://github.com/langgenius/dify.git
synced 2026-04-25 09:36:40 +08:00
refactor: plugin detail panel components for better maintainability and code organization. (#31870)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
778aabb485
commit
64e769f96e
@ -1,27 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FileUpload } from '@/app/components/base/features/types'
|
|
||||||
import type { App } from '@/types/app'
|
import type { App } from '@/types/app'
|
||||||
import * as React from 'react'
|
import { useRef } from 'react'
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
|
||||||
import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form'
|
import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form'
|
||||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
import { useAppInputsFormSchema } from '@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema'
|
||||||
import { useAppDetail } from '@/service/use-apps'
|
|
||||||
import { useFileUploadConfig } from '@/service/use-common'
|
|
||||||
import { useAppWorkflow } from '@/service/use-workflow'
|
|
||||||
import { AppModeEnum, Resolution } from '@/types/app'
|
|
||||||
|
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value?: {
|
value?: {
|
||||||
app_id: string
|
app_id: string
|
||||||
inputs: Record<string, any>
|
inputs: Record<string, unknown>
|
||||||
}
|
}
|
||||||
appDetail: App
|
appDetail: App
|
||||||
onFormChange: (value: Record<string, any>) => void
|
onFormChange: (value: Record<string, unknown>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppInputsPanel = ({
|
const AppInputsPanel = ({
|
||||||
@ -30,155 +22,33 @@ const AppInputsPanel = ({
|
|||||||
onFormChange,
|
onFormChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const inputsRef = useRef<any>(value?.inputs || {})
|
const inputsRef = useRef<Record<string, unknown>>(value?.inputs || {})
|
||||||
const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW
|
|
||||||
const { data: fileUploadConfig } = useFileUploadConfig()
|
|
||||||
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
|
|
||||||
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id)
|
|
||||||
const isLoading = isAppLoading || isWorkflowLoading
|
|
||||||
|
|
||||||
const basicAppFileConfig = useMemo(() => {
|
const { inputFormSchema, isLoading } = useAppInputsFormSchema({ appDetail })
|
||||||
let fileConfig: FileUpload
|
|
||||||
if (isBasicApp)
|
|
||||||
fileConfig = currentApp?.model_config?.file_upload as FileUpload
|
|
||||||
else
|
|
||||||
fileConfig = currentWorkflow?.features?.file_upload as FileUpload
|
|
||||||
return {
|
|
||||||
image: {
|
|
||||||
detail: fileConfig?.image?.detail || Resolution.high,
|
|
||||||
enabled: !!fileConfig?.image?.enabled,
|
|
||||||
number_limits: fileConfig?.image?.number_limits || 3,
|
|
||||||
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
|
||||||
},
|
|
||||||
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
|
|
||||||
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
|
|
||||||
allowed_file_extensions: fileConfig?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
|
|
||||||
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods || fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
|
||||||
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
|
|
||||||
}
|
|
||||||
}, [currentApp?.model_config?.file_upload, currentWorkflow?.features?.file_upload, isBasicApp])
|
|
||||||
|
|
||||||
const inputFormSchema = useMemo(() => {
|
const handleFormChange = (newValue: Record<string, unknown>) => {
|
||||||
if (!currentApp)
|
inputsRef.current = newValue
|
||||||
return []
|
onFormChange(newValue)
|
||||||
let inputFormSchema = []
|
|
||||||
if (isBasicApp) {
|
|
||||||
inputFormSchema = currentApp.model_config?.user_input_form?.filter((item: any) => !item.external_data_tool).map((item: any) => {
|
|
||||||
if (item.paragraph) {
|
|
||||||
return {
|
|
||||||
...item.paragraph,
|
|
||||||
type: 'paragraph',
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.number) {
|
|
||||||
return {
|
|
||||||
...item.number,
|
|
||||||
type: 'number',
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.checkbox) {
|
|
||||||
return {
|
|
||||||
...item.checkbox,
|
|
||||||
type: 'checkbox',
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.select) {
|
|
||||||
return {
|
|
||||||
...item.select,
|
|
||||||
type: 'select',
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item['file-list']) {
|
|
||||||
return {
|
|
||||||
...item['file-list'],
|
|
||||||
type: 'file-list',
|
|
||||||
required: false,
|
|
||||||
fileUploadConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.file) {
|
|
||||||
return {
|
|
||||||
...item.file,
|
|
||||||
type: 'file',
|
|
||||||
required: false,
|
|
||||||
fileUploadConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.json_object) {
|
|
||||||
return {
|
|
||||||
...item.json_object,
|
|
||||||
type: 'json_object',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item['text-input'],
|
|
||||||
type: 'text-input',
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
}) || []
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const startNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start) as any
|
|
||||||
inputFormSchema = startNode?.data.variables.map((variable: any) => {
|
|
||||||
if (variable.type === InputVarType.multiFiles) {
|
|
||||||
return {
|
|
||||||
...variable,
|
|
||||||
required: false,
|
|
||||||
fileUploadConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variable.type === InputVarType.singleFile) {
|
|
||||||
return {
|
|
||||||
...variable,
|
|
||||||
required: false,
|
|
||||||
fileUploadConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...variable,
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
}) || []
|
|
||||||
}
|
|
||||||
if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) {
|
|
||||||
inputFormSchema.push({
|
|
||||||
label: 'Image Upload',
|
|
||||||
variable: '#image#',
|
|
||||||
type: InputVarType.singleFile,
|
|
||||||
required: false,
|
|
||||||
...basicAppFileConfig,
|
|
||||||
fileUploadConfig,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return inputFormSchema || []
|
|
||||||
}, [basicAppFileConfig, currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
|
|
||||||
|
|
||||||
const handleFormChange = (value: Record<string, any>) => {
|
|
||||||
inputsRef.current = value
|
|
||||||
onFormChange(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasInputs = inputFormSchema.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4')}>
|
<div className={cn('flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4')}>
|
||||||
{isLoading && <div className="pt-3"><Loading type="app" /></div>}
|
{isLoading && <div className="pt-3"><Loading type="app" /></div>}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<div className="system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary">{t('appSelector.params', { ns: 'app' })}</div>
|
<div className="system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary">
|
||||||
)}
|
{t('appSelector.params', { ns: 'app' })}
|
||||||
{!isLoading && !inputFormSchema.length && (
|
|
||||||
<div className="flex h-16 flex-col items-center justify-center">
|
|
||||||
<div className="system-sm-regular text-text-tertiary">{t('appSelector.noParams', { ns: 'app' })}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && !!inputFormSchema.length && (
|
{!isLoading && !hasInputs && (
|
||||||
|
<div className="flex h-16 flex-col items-center justify-center">
|
||||||
|
<div className="system-sm-regular text-text-tertiary">
|
||||||
|
{t('appSelector.noParams', { ns: 'app' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && hasInputs && (
|
||||||
<div className="grow overflow-y-auto">
|
<div className="grow overflow-y-auto">
|
||||||
<AppInputsForm
|
<AppInputsForm
|
||||||
inputs={value?.inputs || {}}
|
inputs={value?.inputs || {}}
|
||||||
|
|||||||
@ -0,0 +1,211 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FileUpload } from '@/app/components/base/features/types'
|
||||||
|
import type { FileUploadConfigResponse } from '@/models/common'
|
||||||
|
import type { App } from '@/types/app'
|
||||||
|
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||||
|
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||||
|
import { useAppDetail } from '@/service/use-apps'
|
||||||
|
import { useFileUploadConfig } from '@/service/use-common'
|
||||||
|
import { useAppWorkflow } from '@/service/use-workflow'
|
||||||
|
import { AppModeEnum, Resolution } from '@/types/app'
|
||||||
|
|
||||||
|
const BASIC_INPUT_TYPE_MAP: Record<string, string> = {
|
||||||
|
'paragraph': 'paragraph',
|
||||||
|
'number': 'number',
|
||||||
|
'checkbox': 'checkbox',
|
||||||
|
'select': 'select',
|
||||||
|
'file-list': 'file-list',
|
||||||
|
'file': 'file',
|
||||||
|
'json_object': 'json_object',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_INPUT_TYPES = new Set(['file-list', 'file'])
|
||||||
|
|
||||||
|
const WORKFLOW_FILE_VAR_TYPES = new Set([InputVarType.multiFiles, InputVarType.singleFile])
|
||||||
|
|
||||||
|
type InputSchemaItem = {
|
||||||
|
label?: string
|
||||||
|
variable?: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBasicAppMode(mode: string): boolean {
|
||||||
|
return mode !== AppModeEnum.ADVANCED_CHAT && mode !== AppModeEnum.WORKFLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsImageUpload(mode: string): boolean {
|
||||||
|
return mode === AppModeEnum.COMPLETION || mode === AppModeEnum.WORKFLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileConfig(fileConfig: FileUpload | undefined) {
|
||||||
|
return {
|
||||||
|
image: {
|
||||||
|
detail: fileConfig?.image?.detail || Resolution.high,
|
||||||
|
enabled: !!fileConfig?.image?.enabled,
|
||||||
|
number_limits: fileConfig?.image?.number_limits || 3,
|
||||||
|
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||||
|
},
|
||||||
|
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
|
||||||
|
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||||
|
allowed_file_extensions: fileConfig?.allowed_file_extensions
|
||||||
|
|| [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
|
||||||
|
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods
|
||||||
|
|| fileConfig?.image?.transfer_methods
|
||||||
|
|| ['local_file', 'remote_url'],
|
||||||
|
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBasicAppInputItem(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse,
|
||||||
|
): InputSchemaItem | null {
|
||||||
|
for (const [key, type] of Object.entries(BASIC_INPUT_TYPE_MAP)) {
|
||||||
|
if (!item[key])
|
||||||
|
continue
|
||||||
|
|
||||||
|
const inputData = item[key] as Record<string, unknown>
|
||||||
|
const needsFileConfig = FILE_INPUT_TYPES.has(key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...inputData,
|
||||||
|
type,
|
||||||
|
required: false,
|
||||||
|
...(needsFileConfig && { fileUploadConfig }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textInput = item['text-input'] as Record<string, unknown> | undefined
|
||||||
|
if (!textInput)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...textInput,
|
||||||
|
type: 'text-input',
|
||||||
|
required: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWorkflowVariable(
|
||||||
|
variable: Record<string, unknown>,
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse,
|
||||||
|
): InputSchemaItem {
|
||||||
|
const needsFileConfig = WORKFLOW_FILE_VAR_TYPES.has(variable.type as InputVarType)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...variable,
|
||||||
|
type: variable.type as string,
|
||||||
|
required: false,
|
||||||
|
...(needsFileConfig && { fileUploadConfig }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImageUploadSchema(
|
||||||
|
basicFileConfig: ReturnType<typeof buildFileConfig>,
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse,
|
||||||
|
): InputSchemaItem {
|
||||||
|
return {
|
||||||
|
label: 'Image Upload',
|
||||||
|
variable: '#image#',
|
||||||
|
type: InputVarType.singleFile,
|
||||||
|
required: false,
|
||||||
|
...basicFileConfig,
|
||||||
|
fileUploadConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBasicAppSchema(
|
||||||
|
currentApp: App,
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse,
|
||||||
|
): InputSchemaItem[] {
|
||||||
|
const userInputForm = currentApp.model_config?.user_input_form as Array<Record<string, unknown>> | undefined
|
||||||
|
if (!userInputForm)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return userInputForm
|
||||||
|
.filter((item: Record<string, unknown>) => !item.external_data_tool)
|
||||||
|
.map((item: Record<string, unknown>) => mapBasicAppInputItem(item, fileUploadConfig))
|
||||||
|
.filter((item): item is InputSchemaItem => item !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowSchema(
|
||||||
|
workflow: FetchWorkflowDraftResponse,
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse,
|
||||||
|
): InputSchemaItem[] {
|
||||||
|
const startNode = workflow.graph?.nodes.find(
|
||||||
|
node => node.data.type === BlockEnum.Start,
|
||||||
|
) as { data: { variables: Array<Record<string, unknown>> } } | undefined
|
||||||
|
|
||||||
|
if (!startNode?.data.variables)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return startNode.data.variables.map(
|
||||||
|
variable => mapWorkflowVariable(variable, fileUploadConfig),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseAppInputsFormSchemaParams = {
|
||||||
|
appDetail: App
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseAppInputsFormSchemaResult = {
|
||||||
|
inputFormSchema: InputSchemaItem[]
|
||||||
|
isLoading: boolean
|
||||||
|
fileUploadConfig?: FileUploadConfigResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppInputsFormSchema({
|
||||||
|
appDetail,
|
||||||
|
}: UseAppInputsFormSchemaParams): UseAppInputsFormSchemaResult {
|
||||||
|
const isBasicApp = isBasicAppMode(appDetail.mode)
|
||||||
|
|
||||||
|
const { data: fileUploadConfig } = useFileUploadConfig()
|
||||||
|
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
|
||||||
|
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(
|
||||||
|
isBasicApp ? '' : appDetail.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLoading = isAppLoading || isWorkflowLoading
|
||||||
|
|
||||||
|
const inputFormSchema = useMemo(() => {
|
||||||
|
if (!currentApp)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if (!isBasicApp && !currentWorkflow)
|
||||||
|
return []
|
||||||
|
|
||||||
|
// Build base schema based on app type
|
||||||
|
// Note: currentWorkflow is guaranteed to be defined here due to the early return above
|
||||||
|
const baseSchema = isBasicApp
|
||||||
|
? buildBasicAppSchema(currentApp, fileUploadConfig)
|
||||||
|
: buildWorkflowSchema(currentWorkflow!, fileUploadConfig)
|
||||||
|
|
||||||
|
if (!supportsImageUpload(currentApp.mode))
|
||||||
|
return baseSchema
|
||||||
|
|
||||||
|
const rawFileConfig = isBasicApp
|
||||||
|
? currentApp.model_config?.file_upload as FileUpload
|
||||||
|
: currentWorkflow?.features?.file_upload as FileUpload
|
||||||
|
|
||||||
|
const basicFileConfig = buildFileConfig(rawFileConfig)
|
||||||
|
|
||||||
|
if (!basicFileConfig.enabled)
|
||||||
|
return baseSchema
|
||||||
|
|
||||||
|
return [
|
||||||
|
...baseSchema,
|
||||||
|
createImageUploadSchema(basicFileConfig, fileUploadConfig),
|
||||||
|
]
|
||||||
|
}, [currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputFormSchema,
|
||||||
|
isLoading,
|
||||||
|
fileUploadConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,6 @@ import Toast from '@/app/components/base/toast'
|
|||||||
import { PluginSource } from '../types'
|
import { PluginSource } from '../types'
|
||||||
import DetailHeader from './detail-header'
|
import DetailHeader from './detail-header'
|
||||||
|
|
||||||
// Use vi.hoisted for mock functions used in vi.mock factories
|
|
||||||
const {
|
const {
|
||||||
mockSetShowUpdatePluginModal,
|
mockSetShowUpdatePluginModal,
|
||||||
mockRefreshModelProviders,
|
mockRefreshModelProviders,
|
||||||
|
|||||||
@ -1,416 +1,2 @@
|
|||||||
import type { PluginDetail } from '../types'
|
// Re-export from refactored module for backward compatibility
|
||||||
import {
|
export { default } from './detail-header/index'
|
||||||
RiArrowLeftRightLine,
|
|
||||||
RiBugLine,
|
|
||||||
RiCloseLine,
|
|
||||||
RiHardDrive3Line,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import { useBoolean } from 'ahooks'
|
|
||||||
import * as React from 'react'
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import ActionButton from '@/app/components/base/action-button'
|
|
||||||
import { trackEvent } from '@/app/components/base/amplitude'
|
|
||||||
import Badge from '@/app/components/base/badge'
|
|
||||||
import Button from '@/app/components/base/button'
|
|
||||||
import Confirm from '@/app/components/base/confirm'
|
|
||||||
import { Github } from '@/app/components/base/icons/src/public/common'
|
|
||||||
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
|
||||||
import Toast from '@/app/components/base/toast'
|
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
|
||||||
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
|
||||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
|
||||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
|
||||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
|
||||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
|
||||||
import { API_PREFIX } from '@/config'
|
|
||||||
import { useAppContext } from '@/context/app-context'
|
|
||||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
|
||||||
import { useGetLanguage, useLocale } from '@/context/i18n'
|
|
||||||
import { useModalContext } from '@/context/modal-context'
|
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
|
||||||
import useTheme from '@/hooks/use-theme'
|
|
||||||
import { uninstallPlugin } from '@/service/plugins'
|
|
||||||
import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools'
|
|
||||||
import { cn } from '@/utils/classnames'
|
|
||||||
import { getMarketplaceUrl } from '@/utils/var'
|
|
||||||
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
|
|
||||||
import Verified from '../base/badges/verified'
|
|
||||||
import DeprecationNotice from '../base/deprecation-notice'
|
|
||||||
import Icon from '../card/base/card-icon'
|
|
||||||
import Description from '../card/base/description'
|
|
||||||
import OrgInfo from '../card/base/org-info'
|
|
||||||
import Title from '../card/base/title'
|
|
||||||
import { useGitHubReleases } from '../install-plugin/hooks'
|
|
||||||
import useReferenceSetting from '../plugin-page/use-reference-setting'
|
|
||||||
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
|
|
||||||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
|
|
||||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
|
||||||
|
|
||||||
const i18nPrefix = 'action'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
detail: PluginDetail
|
|
||||||
isReadmeView?: boolean
|
|
||||||
onHide?: () => void
|
|
||||||
onUpdate?: (isDelete?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DetailHeader = ({
|
|
||||||
detail,
|
|
||||||
isReadmeView = false,
|
|
||||||
onHide,
|
|
||||||
onUpdate,
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { userProfile: { timezone } } = useAppContext()
|
|
||||||
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const locale = useGetLanguage()
|
|
||||||
const currentLocale = useLocale()
|
|
||||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
|
||||||
const { setShowUpdatePluginModal } = useModalContext()
|
|
||||||
const { refreshModelProviders } = useProviderContext()
|
|
||||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
|
||||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
|
||||||
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
source,
|
|
||||||
tenant_id,
|
|
||||||
version,
|
|
||||||
latest_unique_identifier,
|
|
||||||
latest_version,
|
|
||||||
meta,
|
|
||||||
plugin_id,
|
|
||||||
status,
|
|
||||||
deprecated_reason,
|
|
||||||
alternative_plugin_id,
|
|
||||||
} = detail
|
|
||||||
|
|
||||||
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
|
||||||
const isTool = category === PluginCategoryEnum.tool
|
|
||||||
const providerBriefInfo = tool?.identity
|
|
||||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
|
||||||
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
|
||||||
const provider = useMemo(() => {
|
|
||||||
return collectionList.find(collection => collection.name === providerKey)
|
|
||||||
}, [collectionList, providerKey])
|
|
||||||
const isFromGitHub = source === PluginSource.github
|
|
||||||
const isFromMarketplace = source === PluginSource.marketplace
|
|
||||||
|
|
||||||
const [isShow, setIsShow] = useState(false)
|
|
||||||
const [targetVersion, setTargetVersion] = useState({
|
|
||||||
version: latest_version,
|
|
||||||
unique_identifier: latest_unique_identifier,
|
|
||||||
})
|
|
||||||
const hasNewVersion = useMemo(() => {
|
|
||||||
if (isFromMarketplace)
|
|
||||||
return !!latest_version && latest_version !== version
|
|
||||||
|
|
||||||
return false
|
|
||||||
}, [isFromMarketplace, latest_version, version])
|
|
||||||
|
|
||||||
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
|
|
||||||
const iconSrc = iconFileName
|
|
||||||
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const detailUrl = useMemo(() => {
|
|
||||||
if (isFromGitHub)
|
|
||||||
return `https://github.com/${meta!.repo}`
|
|
||||||
if (isFromMarketplace)
|
|
||||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
|
|
||||||
return ''
|
|
||||||
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
|
|
||||||
|
|
||||||
const [isShowUpdateModal, {
|
|
||||||
setTrue: showUpdateModal,
|
|
||||||
setFalse: hideUpdateModal,
|
|
||||||
}] = useBoolean(false)
|
|
||||||
|
|
||||||
const { referenceSetting } = useReferenceSetting()
|
|
||||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
|
||||||
const isAutoUpgradeEnabled = useMemo(() => {
|
|
||||||
if (!enable_marketplace)
|
|
||||||
return false
|
|
||||||
if (!autoUpgradeInfo || !isFromMarketplace)
|
|
||||||
return false
|
|
||||||
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
|
||||||
return false
|
|
||||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
|
||||||
return true
|
|
||||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
|
||||||
return true
|
|
||||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
|
|
||||||
|
|
||||||
const [isDowngrade, setIsDowngrade] = useState(false)
|
|
||||||
const handleUpdate = async (isDowngrade?: boolean) => {
|
|
||||||
if (isFromMarketplace) {
|
|
||||||
setIsDowngrade(!!isDowngrade)
|
|
||||||
showUpdateModal()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const owner = meta!.repo.split('/')[0] || author
|
|
||||||
const repo = meta!.repo.split('/')[1] || name
|
|
||||||
const fetchedReleases = await fetchReleases(owner, repo)
|
|
||||||
if (fetchedReleases.length === 0)
|
|
||||||
return
|
|
||||||
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version)
|
|
||||||
Toast.notify(toastProps)
|
|
||||||
if (needUpdate) {
|
|
||||||
setShowUpdatePluginModal({
|
|
||||||
onSaveCallback: () => {
|
|
||||||
onUpdate?.()
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
type: PluginSource.github,
|
|
||||||
category: detail.declaration.category,
|
|
||||||
github: {
|
|
||||||
originalPackageInfo: {
|
|
||||||
id: detail.plugin_unique_identifier,
|
|
||||||
repo: meta!.repo,
|
|
||||||
version: meta!.version,
|
|
||||||
package: meta!.package,
|
|
||||||
releases: fetchedReleases,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdatedFromMarketplace = () => {
|
|
||||||
onUpdate?.()
|
|
||||||
hideUpdateModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isShowPluginInfo, {
|
|
||||||
setTrue: showPluginInfo,
|
|
||||||
setFalse: hidePluginInfo,
|
|
||||||
}] = useBoolean(false)
|
|
||||||
|
|
||||||
const [isShowDeleteConfirm, {
|
|
||||||
setTrue: showDeleteConfirm,
|
|
||||||
setFalse: hideDeleteConfirm,
|
|
||||||
}] = useBoolean(false)
|
|
||||||
|
|
||||||
const [deleting, {
|
|
||||||
setTrue: showDeleting,
|
|
||||||
setFalse: hideDeleting,
|
|
||||||
}] = useBoolean(false)
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
showDeleting()
|
|
||||||
const res = await uninstallPlugin(id)
|
|
||||||
hideDeleting()
|
|
||||||
if (res.success) {
|
|
||||||
hideDeleteConfirm()
|
|
||||||
onUpdate?.(true)
|
|
||||||
if (PluginCategoryEnum.model.includes(category))
|
|
||||||
refreshModelProviders()
|
|
||||||
if (PluginCategoryEnum.tool.includes(category))
|
|
||||||
invalidateAllToolProviders()
|
|
||||||
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
|
||||||
}
|
|
||||||
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders, plugin_id, name])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
|
||||||
<div className="flex">
|
|
||||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
|
||||||
<Icon src={iconSrc} />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 w-0 grow">
|
|
||||||
<div className="flex h-5 items-center">
|
|
||||||
<Title title={label[locale]} />
|
|
||||||
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
|
||||||
{!!version && (
|
|
||||||
<PluginVersionPicker
|
|
||||||
disabled={!isFromMarketplace || isReadmeView}
|
|
||||||
isShow={isShow}
|
|
||||||
onShowChange={setIsShow}
|
|
||||||
pluginID={plugin_id}
|
|
||||||
currentVersion={version}
|
|
||||||
onSelect={(state) => {
|
|
||||||
setTargetVersion(state)
|
|
||||||
handleUpdate(state.isDowngrade)
|
|
||||||
}}
|
|
||||||
trigger={(
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
'mx-1',
|
|
||||||
isShow && 'bg-state-base-hover',
|
|
||||||
(isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
uppercase={false}
|
|
||||||
text={(
|
|
||||||
<>
|
|
||||||
<div>{isFromGitHub ? meta!.version : version}</div>
|
|
||||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
hasRedCornerMark={hasNewVersion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Auto update info */}
|
|
||||||
{isAutoUpgradeEnabled && !isReadmeView && (
|
|
||||||
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
|
||||||
{/* add a a div to fix tooltip hover not show problem */}
|
|
||||||
<div>
|
|
||||||
<Badge className="mr-1 cursor-pointer px-1">
|
|
||||||
<AutoUpdateLine className="size-3" />
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(hasNewVersion || isFromGitHub) && (
|
|
||||||
<Button
|
|
||||||
variant="secondary-accent"
|
|
||||||
size="small"
|
|
||||||
className="!h-5"
|
|
||||||
onClick={() => {
|
|
||||||
if (isFromMarketplace) {
|
|
||||||
setTargetVersion({
|
|
||||||
version: latest_version,
|
|
||||||
unique_identifier: latest_unique_identifier,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
handleUpdate()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mb-1 flex h-4 items-center justify-between">
|
|
||||||
<div className="mt-0.5 flex items-center">
|
|
||||||
<OrgInfo
|
|
||||||
packageNameClassName="w-auto"
|
|
||||||
orgName={author}
|
|
||||||
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
|
||||||
/>
|
|
||||||
{!!source && (
|
|
||||||
<>
|
|
||||||
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
|
|
||||||
{source === PluginSource.marketplace && (
|
|
||||||
<Tooltip popupContent={t('detailPanel.categoryTip.marketplace', { ns: 'plugin' })}>
|
|
||||||
<div><BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" /></div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{source === PluginSource.github && (
|
|
||||||
<Tooltip popupContent={t('detailPanel.categoryTip.github', { ns: 'plugin' })}>
|
|
||||||
<div><Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" /></div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{source === PluginSource.local && (
|
|
||||||
<Tooltip popupContent={t('detailPanel.categoryTip.local', { ns: 'plugin' })}>
|
|
||||||
<div><RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" /></div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{source === PluginSource.debugging && (
|
|
||||||
<Tooltip popupContent={t('detailPanel.categoryTip.debugging', { ns: 'plugin' })}>
|
|
||||||
<div><RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" /></div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isReadmeView && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<OperationDropdown
|
|
||||||
source={source}
|
|
||||||
onInfo={showPluginInfo}
|
|
||||||
onCheckVersion={handleUpdate}
|
|
||||||
onRemove={showDeleteConfirm}
|
|
||||||
detailUrl={detailUrl}
|
|
||||||
/>
|
|
||||||
<ActionButton onClick={onHide}>
|
|
||||||
<RiCloseLine className="h-4 w-4" />
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isFromMarketplace && (
|
|
||||||
<DeprecationNotice
|
|
||||||
status={status}
|
|
||||||
deprecatedReason={deprecated_reason}
|
|
||||||
alternativePluginId={alternative_plugin_id}
|
|
||||||
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
|
||||||
className="mt-3"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2}></Description>}
|
|
||||||
{
|
|
||||||
category === PluginCategoryEnum.tool && !isReadmeView && (
|
|
||||||
<PluginAuth
|
|
||||||
pluginPayload={{
|
|
||||||
provider: provider?.name || '',
|
|
||||||
category: AuthCategory.tool,
|
|
||||||
providerType: provider?.type || '',
|
|
||||||
detail,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{isShowPluginInfo && (
|
|
||||||
<PluginInfo
|
|
||||||
repository={isFromGitHub ? meta?.repo : ''}
|
|
||||||
release={version}
|
|
||||||
packageName={meta?.package || ''}
|
|
||||||
onHide={hidePluginInfo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isShowDeleteConfirm && (
|
|
||||||
<Confirm
|
|
||||||
isShow
|
|
||||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
|
||||||
content={(
|
|
||||||
<div>
|
|
||||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
|
||||||
<span className="system-md-semibold">{label[locale]}</span>
|
|
||||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
onCancel={hideDeleteConfirm}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
isLoading={deleting}
|
|
||||||
isDisabled={deleting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{
|
|
||||||
isShowUpdateModal && (
|
|
||||||
<UpdateFromMarketplace
|
|
||||||
pluginId={plugin_id}
|
|
||||||
payload={{
|
|
||||||
category: detail.declaration.category,
|
|
||||||
originalPackageInfo: {
|
|
||||||
id: detail.plugin_unique_identifier,
|
|
||||||
payload: detail.declaration,
|
|
||||||
},
|
|
||||||
targetPackageInfo: {
|
|
||||||
id: targetVersion.unique_identifier,
|
|
||||||
version: targetVersion.version,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onCancel={hideUpdateModal}
|
|
||||||
onSave={handleUpdatedFromMarketplace}
|
|
||||||
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DetailHeader
|
|
||||||
|
|||||||
@ -0,0 +1,539 @@
|
|||||||
|
import type { PluginDetail } from '../../../types'
|
||||||
|
import type { ModalStates, VersionTarget } from '../hooks'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
import HeaderModals from './header-modals'
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/i18n', () => ({
|
||||||
|
useGetLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/confirm', () => ({
|
||||||
|
default: ({ isShow, title, onCancel, onConfirm, isLoading }: {
|
||||||
|
isShow: boolean
|
||||||
|
title: string
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
isLoading: boolean
|
||||||
|
}) => isShow
|
||||||
|
? (
|
||||||
|
<div data-testid="delete-confirm">
|
||||||
|
<div data-testid="delete-title">{title}</div>
|
||||||
|
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||||
|
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({
|
||||||
|
default: ({ repository, release, packageName, onHide }: {
|
||||||
|
repository: string
|
||||||
|
release: string
|
||||||
|
packageName: string
|
||||||
|
onHide: () => void
|
||||||
|
}) => (
|
||||||
|
<div data-testid="plugin-info">
|
||||||
|
<div data-testid="plugin-info-repo">{repository}</div>
|
||||||
|
<div data-testid="plugin-info-release">{release}</div>
|
||||||
|
<div data-testid="plugin-info-package">{packageName}</div>
|
||||||
|
<button data-testid="plugin-info-close" onClick={onHide}>Close</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({
|
||||||
|
default: ({ pluginId, onSave, onCancel, isShowDowngradeWarningModal }: {
|
||||||
|
pluginId: string
|
||||||
|
onSave: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
isShowDowngradeWarningModal: boolean
|
||||||
|
}) => (
|
||||||
|
<div data-testid="update-modal">
|
||||||
|
<div data-testid="update-plugin-id">{pluginId}</div>
|
||||||
|
<div data-testid="update-downgrade-warning">{String(isShowDowngradeWarningModal)}</div>
|
||||||
|
<button data-testid="update-modal-save" onClick={onSave}>Save</button>
|
||||||
|
<button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||||
|
id: 'test-id',
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
updated_at: '2024-01-02',
|
||||||
|
name: 'Test Plugin',
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
plugin_unique_identifier: 'test-uid',
|
||||||
|
declaration: {
|
||||||
|
author: 'test-author',
|
||||||
|
name: 'test-plugin-name',
|
||||||
|
category: 'tool',
|
||||||
|
label: { en_US: 'Test Plugin Label' },
|
||||||
|
description: { en_US: 'Test description' },
|
||||||
|
icon: 'icon.png',
|
||||||
|
verified: true,
|
||||||
|
} as unknown as PluginDetail['declaration'],
|
||||||
|
installation_id: 'install-1',
|
||||||
|
tenant_id: 'tenant-1',
|
||||||
|
endpoints_setups: 0,
|
||||||
|
endpoints_active: 0,
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '2.0.0',
|
||||||
|
latest_unique_identifier: 'new-uid',
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
meta: undefined,
|
||||||
|
status: 'active',
|
||||||
|
deprecated_reason: '',
|
||||||
|
alternative_plugin_id: '',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createModalStatesMock = (overrides: Partial<ModalStates> = {}): ModalStates => ({
|
||||||
|
isShowUpdateModal: false,
|
||||||
|
showUpdateModal: vi.fn<() => void>(),
|
||||||
|
hideUpdateModal: vi.fn<() => void>(),
|
||||||
|
isShowPluginInfo: false,
|
||||||
|
showPluginInfo: vi.fn<() => void>(),
|
||||||
|
hidePluginInfo: vi.fn<() => void>(),
|
||||||
|
isShowDeleteConfirm: false,
|
||||||
|
showDeleteConfirm: vi.fn<() => void>(),
|
||||||
|
hideDeleteConfirm: vi.fn<() => void>(),
|
||||||
|
deleting: false,
|
||||||
|
showDeleting: vi.fn<() => void>(),
|
||||||
|
hideDeleting: vi.fn<() => void>(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTargetVersion = (overrides: Partial<VersionTarget> = {}): VersionTarget => ({
|
||||||
|
version: '2.0.0',
|
||||||
|
unique_identifier: 'new-uid',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HeaderModals', () => {
|
||||||
|
let mockOnUpdatedFromMarketplace: () => void
|
||||||
|
let mockOnDelete: () => void
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockOnUpdatedFromMarketplace = vi.fn<() => void>()
|
||||||
|
mockOnDelete = vi.fn<() => void>()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Plugin Info Modal', () => {
|
||||||
|
it('should not render plugin info modal when isShowPluginInfo is false', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowPluginInfo: false })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render plugin info modal when isShowPluginInfo is true', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass GitHub repo to plugin info for GitHub source', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' },
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={detail}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('owner/repo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass empty string for repo for non-GitHub source', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail({ source: PluginSource.marketplace })}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call hidePluginInfo when close button is clicked', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('plugin-info-close'))
|
||||||
|
|
||||||
|
expect(modalStates.hidePluginInfo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Delete Confirm Modal', () => {
|
||||||
|
it('should not render delete confirm when isShowDeleteConfirm is false', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowDeleteConfirm: false })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render delete confirm when isShowDeleteConfirm is true', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show correct delete title', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call hideDeleteConfirm when cancel is clicked', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||||
|
|
||||||
|
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onDelete when confirm is clicked', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||||
|
|
||||||
|
expect(mockOnDelete).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable confirm button when deleting', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true, deleting: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Update Modal', () => {
|
||||||
|
it('should not render update modal when isShowUpdateModal is false', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: false })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render update modal when isShowUpdateModal is true', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass plugin id to update modal', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail({ plugin_id: 'my-plugin-id' })}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('update-plugin-id')).toHaveTextContent('my-plugin-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onUpdatedFromMarketplace when save is clicked', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('update-modal-save'))
|
||||||
|
|
||||||
|
expect(mockOnUpdatedFromMarketplace).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call hideUpdateModal when cancel is clicked', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('update-modal-cancel'))
|
||||||
|
|
||||||
|
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show downgrade warning when isDowngrade and isAutoUpgradeEnabled are true', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={true}
|
||||||
|
isAutoUpgradeEnabled={true}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show downgrade warning when only isDowngrade is true', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={true}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show downgrade warning when only isAutoUpgradeEnabled is true', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={true}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Multiple Modals', () => {
|
||||||
|
it('should render multiple modals when multiple are open', () => {
|
||||||
|
const modalStates = createModalStatesMock({
|
||||||
|
isShowPluginInfo: true,
|
||||||
|
isShowDeleteConfirm: true,
|
||||||
|
isShowUpdateModal: true,
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle undefined target version values', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={createPluginDetail()}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={{ version: undefined, unique_identifier: undefined }}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty meta for GitHub source', () => {
|
||||||
|
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: undefined,
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<HeaderModals
|
||||||
|
detail={detail}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={createTargetVersion()}
|
||||||
|
isDowngrade={false}
|
||||||
|
isAutoUpgradeEnabled={false}
|
||||||
|
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
|
||||||
|
expect(screen.getByTestId('plugin-info-package')).toHaveTextContent('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { PluginDetail } from '../../../types'
|
||||||
|
import type { ModalStates, VersionTarget } from '../hooks'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Confirm from '@/app/components/base/confirm'
|
||||||
|
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||||
|
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||||
|
import { useGetLanguage } from '@/context/i18n'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
|
||||||
|
const i18nPrefix = 'action'
|
||||||
|
|
||||||
|
type HeaderModalsProps = {
|
||||||
|
detail: PluginDetail
|
||||||
|
modalStates: ModalStates
|
||||||
|
targetVersion: VersionTarget
|
||||||
|
isDowngrade: boolean
|
||||||
|
isAutoUpgradeEnabled: boolean
|
||||||
|
onUpdatedFromMarketplace: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderModals: FC<HeaderModalsProps> = ({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
targetVersion,
|
||||||
|
isDowngrade,
|
||||||
|
isAutoUpgradeEnabled,
|
||||||
|
onUpdatedFromMarketplace,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const locale = useGetLanguage()
|
||||||
|
|
||||||
|
const { source, version, meta } = detail
|
||||||
|
const { label } = detail.declaration || detail
|
||||||
|
const isFromGitHub = source === PluginSource.github
|
||||||
|
|
||||||
|
const {
|
||||||
|
isShowUpdateModal,
|
||||||
|
hideUpdateModal,
|
||||||
|
isShowPluginInfo,
|
||||||
|
hidePluginInfo,
|
||||||
|
isShowDeleteConfirm,
|
||||||
|
hideDeleteConfirm,
|
||||||
|
deleting,
|
||||||
|
} = modalStates
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Plugin Info Modal */}
|
||||||
|
{isShowPluginInfo && (
|
||||||
|
<PluginInfo
|
||||||
|
repository={isFromGitHub ? meta?.repo : ''}
|
||||||
|
release={version}
|
||||||
|
packageName={meta?.package || ''}
|
||||||
|
onHide={hidePluginInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirm Modal */}
|
||||||
|
{isShowDeleteConfirm && (
|
||||||
|
<Confirm
|
||||||
|
isShow
|
||||||
|
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||||
|
content={(
|
||||||
|
<div>
|
||||||
|
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||||
|
<span className="system-md-semibold">{label[locale]}</span>
|
||||||
|
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
onCancel={hideDeleteConfirm}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
isLoading={deleting}
|
||||||
|
isDisabled={deleting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Update from Marketplace Modal */}
|
||||||
|
{isShowUpdateModal && (
|
||||||
|
<UpdateFromMarketplace
|
||||||
|
pluginId={detail.plugin_id}
|
||||||
|
payload={{
|
||||||
|
category: detail.declaration?.category ?? '',
|
||||||
|
originalPackageInfo: {
|
||||||
|
id: detail.plugin_unique_identifier,
|
||||||
|
payload: detail.declaration ?? undefined,
|
||||||
|
},
|
||||||
|
targetPackageInfo: {
|
||||||
|
id: targetVersion.unique_identifier || '',
|
||||||
|
version: targetVersion.version || '',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onCancel={hideUpdateModal}
|
||||||
|
onSave={onUpdatedFromMarketplace}
|
||||||
|
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HeaderModals
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { default as HeaderModals } from './header-modals'
|
||||||
|
export { default as PluginSourceBadge } from './plugin-source-badge'
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
import PluginSourceBadge from './plugin-source-badge'
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/tooltip', () => ({
|
||||||
|
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||||
|
<div data-testid="tooltip" data-content={popupContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('PluginSourceBadge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Source Icon Rendering', () => {
|
||||||
|
it('should render marketplace source badge', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
expect(tooltip).toBeInTheDocument()
|
||||||
|
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render github source badge', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
expect(tooltip).toBeInTheDocument()
|
||||||
|
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render local source badge', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
expect(tooltip).toBeInTheDocument()
|
||||||
|
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render debugging source badge', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
expect(tooltip).toBeInTheDocument()
|
||||||
|
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Separator Rendering', () => {
|
||||||
|
it('should render separator dot before marketplace badge', () => {
|
||||||
|
const { container } = render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||||
|
|
||||||
|
const separator = container.querySelector('.text-text-quaternary')
|
||||||
|
expect(separator).toBeInTheDocument()
|
||||||
|
expect(separator?.textContent).toBe('·')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render separator dot before github badge', () => {
|
||||||
|
const { container } = render(<PluginSourceBadge source={PluginSource.github} />)
|
||||||
|
|
||||||
|
const separator = container.querySelector('.text-text-quaternary')
|
||||||
|
expect(separator).toBeInTheDocument()
|
||||||
|
expect(separator?.textContent).toBe('·')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render separator dot before local badge', () => {
|
||||||
|
const { container } = render(<PluginSourceBadge source={PluginSource.local} />)
|
||||||
|
|
||||||
|
const separator = container.querySelector('.text-text-quaternary')
|
||||||
|
expect(separator).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render separator dot before debugging badge', () => {
|
||||||
|
const { container } = render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||||
|
|
||||||
|
const separator = container.querySelector('.text-text-quaternary')
|
||||||
|
expect(separator).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tooltip Content', () => {
|
||||||
|
it('should show marketplace tooltip', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||||
|
'data-content',
|
||||||
|
'detailPanel.categoryTip.marketplace',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show github tooltip', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||||
|
'data-content',
|
||||||
|
'detailPanel.categoryTip.github',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show local tooltip', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||||
|
'data-content',
|
||||||
|
'detailPanel.categoryTip.local',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show debugging tooltip', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||||
|
'data-content',
|
||||||
|
'detailPanel.categoryTip.debugging',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Icon Element Structure', () => {
|
||||||
|
it('should render icon inside tooltip for marketplace', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
const iconWrapper = tooltip.querySelector('div')
|
||||||
|
expect(iconWrapper).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render icon inside tooltip for github', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
const iconWrapper = tooltip.querySelector('div')
|
||||||
|
expect(iconWrapper).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render icon inside tooltip for local', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
const iconWrapper = tooltip.querySelector('div')
|
||||||
|
expect(iconWrapper).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render icon inside tooltip for debugging', () => {
|
||||||
|
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip')
|
||||||
|
const iconWrapper = tooltip.querySelector('div')
|
||||||
|
expect(iconWrapper).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Lookup Table Coverage', () => {
|
||||||
|
it('should handle all PluginSource enum values', () => {
|
||||||
|
const allSources = Object.values(PluginSource)
|
||||||
|
|
||||||
|
allSources.forEach((source) => {
|
||||||
|
const { container } = render(<PluginSourceBadge source={source} />)
|
||||||
|
// Should render either tooltip or nothing
|
||||||
|
expect(container).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Invalid Source Handling', () => {
|
||||||
|
it('should return null for unknown source type', () => {
|
||||||
|
// Use type assertion to test invalid source value
|
||||||
|
const invalidSource = 'unknown_source' as PluginSource
|
||||||
|
const { container } = render(<PluginSourceBadge source={invalidSource} />)
|
||||||
|
|
||||||
|
// Should render nothing (empty container)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render separator for invalid source', () => {
|
||||||
|
const invalidSource = 'invalid' as PluginSource
|
||||||
|
const { container } = render(<PluginSourceBadge source={invalidSource} />)
|
||||||
|
|
||||||
|
const separator = container.querySelector('.text-text-quaternary')
|
||||||
|
expect(separator).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render tooltip for invalid source', () => {
|
||||||
|
const invalidSource = '' as PluginSource
|
||||||
|
render(<PluginSourceBadge source={invalidSource} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
RiBugLine,
|
||||||
|
RiHardDrive3Line,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Github } from '@/app/components/base/icons/src/public/common'
|
||||||
|
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
||||||
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
|
||||||
|
type SourceConfig = {
|
||||||
|
icon: ReactNode
|
||||||
|
tipKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginSourceBadgeProps = {
|
||||||
|
source: PluginSource
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_CONFIG_MAP: Record<PluginSource, SourceConfig | null> = {
|
||||||
|
[PluginSource.marketplace]: {
|
||||||
|
icon: <BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" />,
|
||||||
|
tipKey: 'detailPanel.categoryTip.marketplace',
|
||||||
|
},
|
||||||
|
[PluginSource.github]: {
|
||||||
|
icon: <Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" />,
|
||||||
|
tipKey: 'detailPanel.categoryTip.github',
|
||||||
|
},
|
||||||
|
[PluginSource.local]: {
|
||||||
|
icon: <RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" />,
|
||||||
|
tipKey: 'detailPanel.categoryTip.local',
|
||||||
|
},
|
||||||
|
[PluginSource.debugging]: {
|
||||||
|
icon: <RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" />,
|
||||||
|
tipKey: 'detailPanel.categoryTip.debugging',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const config = SOURCE_CONFIG_MAP[source]
|
||||||
|
if (!config)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
|
||||||
|
<Tooltip popupContent={t(config.tipKey as never, { ns: 'plugin' })}>
|
||||||
|
<div>{config.icon}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginSourceBadge
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export { useDetailHeaderState } from './use-detail-header-state'
|
||||||
|
export type { ModalStates, UseDetailHeaderStateReturn, VersionPickerState, VersionTarget } from './use-detail-header-state'
|
||||||
|
export { usePluginOperations } from './use-plugin-operations'
|
||||||
@ -0,0 +1,409 @@
|
|||||||
|
import type { PluginDetail } from '../../../types'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
import { useDetailHeaderState } from './use-detail-header-state'
|
||||||
|
|
||||||
|
let mockEnableMarketplace = true
|
||||||
|
vi.mock('@/context/global-public-context', () => ({
|
||||||
|
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
|
||||||
|
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let mockAutoUpgradeInfo: {
|
||||||
|
strategy_setting: string
|
||||||
|
upgrade_mode: string
|
||||||
|
include_plugins: string[]
|
||||||
|
exclude_plugins: string[]
|
||||||
|
upgrade_time_of_day: number
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
vi.mock('../../../plugin-page/use-reference-setting', () => ({
|
||||||
|
default: () => ({
|
||||||
|
referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({
|
||||||
|
AUTO_UPDATE_MODE: {
|
||||||
|
update_all: 'update_all',
|
||||||
|
partial: 'partial',
|
||||||
|
exclude: 'exclude',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||||
|
id: 'test-id',
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
updated_at: '2024-01-02',
|
||||||
|
name: 'Test Plugin',
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
plugin_unique_identifier: 'test-uid',
|
||||||
|
declaration: {
|
||||||
|
author: 'test-author',
|
||||||
|
name: 'test-plugin-name',
|
||||||
|
category: 'tool',
|
||||||
|
label: { en_US: 'Test Plugin Label' },
|
||||||
|
description: { en_US: 'Test description' },
|
||||||
|
icon: 'icon.png',
|
||||||
|
verified: true,
|
||||||
|
} as unknown as PluginDetail['declaration'],
|
||||||
|
installation_id: 'install-1',
|
||||||
|
tenant_id: 'tenant-1',
|
||||||
|
endpoints_setups: 0,
|
||||||
|
endpoints_active: 0,
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '1.0.0',
|
||||||
|
latest_unique_identifier: 'test-uid',
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
meta: undefined,
|
||||||
|
status: 'active',
|
||||||
|
deprecated_reason: '',
|
||||||
|
alternative_plugin_id: '',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDetailHeaderState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockAutoUpgradeInfo = null
|
||||||
|
mockEnableMarketplace = true
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Source Type Detection', () => {
|
||||||
|
it('should detect marketplace source', () => {
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isFromMarketplace).toBe(true)
|
||||||
|
expect(result.current.isFromGitHub).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect GitHub source', () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isFromGitHub).toBe(true)
|
||||||
|
expect(result.current.isFromMarketplace).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect local source', () => {
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.local })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isFromGitHub).toBe(false)
|
||||||
|
expect(result.current.isFromMarketplace).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Version State', () => {
|
||||||
|
it('should detect new version available for marketplace plugin', () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '2.0.0',
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.hasNewVersion).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect new version when versions match', () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '1.0.0',
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.hasNewVersion).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect new version for non-marketplace source', () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '2.0.0',
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.hasNewVersion).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect new version when latest_version is empty', () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '',
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.hasNewVersion).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Version Picker State', () => {
|
||||||
|
it('should initialize version picker as hidden', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.versionPicker.isShow).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle version picker visibility', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.versionPicker.setIsShow(true)
|
||||||
|
})
|
||||||
|
expect(result.current.versionPicker.isShow).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.versionPicker.setIsShow(false)
|
||||||
|
})
|
||||||
|
expect(result.current.versionPicker.isShow).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update target version', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.versionPicker.setTargetVersion({
|
||||||
|
version: '2.0.0',
|
||||||
|
unique_identifier: 'new-uid',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.versionPicker.targetVersion.version).toBe('2.0.0')
|
||||||
|
expect(result.current.versionPicker.targetVersion.unique_identifier).toBe('new-uid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set isDowngrade when provided in target version', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.versionPicker.setTargetVersion({
|
||||||
|
version: '0.5.0',
|
||||||
|
unique_identifier: 'old-uid',
|
||||||
|
isDowngrade: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.versionPicker.isDowngrade).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Modal States', () => {
|
||||||
|
it('should initialize all modals as hidden', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
|
||||||
|
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
|
||||||
|
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
|
||||||
|
expect(result.current.modalStates.deleting).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle update modal', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.showUpdateModal()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.isShowUpdateModal).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.hideUpdateModal()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle plugin info modal', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.showPluginInfo()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.isShowPluginInfo).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.hidePluginInfo()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle delete confirm modal', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.showDeleteConfirm()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.isShowDeleteConfirm).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.hideDeleteConfirm()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle deleting state', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.showDeleting()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.deleting).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.modalStates.hideDeleting()
|
||||||
|
})
|
||||||
|
expect(result.current.modalStates.deleting).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auto Upgrade Detection', () => {
|
||||||
|
it('should disable auto upgrade when marketplace is disabled', () => {
|
||||||
|
mockEnableMarketplace = false
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'update_all',
|
||||||
|
include_plugins: [],
|
||||||
|
exclude_plugins: [],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable auto upgrade when strategy is disabled', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'disabled',
|
||||||
|
upgrade_mode: 'update_all',
|
||||||
|
include_plugins: [],
|
||||||
|
exclude_plugins: [],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable auto upgrade for update_all mode', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'update_all',
|
||||||
|
include_plugins: [],
|
||||||
|
exclude_plugins: [],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable auto upgrade for partial mode when plugin is included', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'partial',
|
||||||
|
include_plugins: ['test-plugin'],
|
||||||
|
exclude_plugins: [],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable auto upgrade for partial mode when plugin is not included', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'partial',
|
||||||
|
include_plugins: ['other-plugin'],
|
||||||
|
exclude_plugins: [],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable auto upgrade for exclude mode when plugin is not excluded', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'exclude',
|
||||||
|
include_plugins: [],
|
||||||
|
exclude_plugins: ['other-plugin'],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable auto upgrade for exclude mode when plugin is excluded', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'exclude',
|
||||||
|
include_plugins: [],
|
||||||
|
exclude_plugins: ['test-plugin'],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable auto upgrade for non-marketplace source', () => {
|
||||||
|
mockAutoUpgradeInfo = {
|
||||||
|
strategy_setting: 'enabled',
|
||||||
|
upgrade_mode: 'update_all',
|
||||||
|
include_plugins: [],
|
||||||
|
exclude_plugins: [],
|
||||||
|
upgrade_time_of_day: 36000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable auto upgrade when no auto upgrade info', () => {
|
||||||
|
mockAutoUpgradeInfo = null
|
||||||
|
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||||
|
|
||||||
|
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PluginDetail } from '../../../types'
|
||||||
|
import { useBoolean } from 'ahooks'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import useReferenceSetting from '../../../plugin-page/use-reference-setting'
|
||||||
|
import { AUTO_UPDATE_MODE } from '../../../reference-setting-modal/auto-update-setting/types'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
|
||||||
|
export type VersionTarget = {
|
||||||
|
version: string | undefined
|
||||||
|
unique_identifier: string | undefined
|
||||||
|
isDowngrade?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModalStates = {
|
||||||
|
isShowUpdateModal: boolean
|
||||||
|
showUpdateModal: () => void
|
||||||
|
hideUpdateModal: () => void
|
||||||
|
isShowPluginInfo: boolean
|
||||||
|
showPluginInfo: () => void
|
||||||
|
hidePluginInfo: () => void
|
||||||
|
isShowDeleteConfirm: boolean
|
||||||
|
showDeleteConfirm: () => void
|
||||||
|
hideDeleteConfirm: () => void
|
||||||
|
deleting: boolean
|
||||||
|
showDeleting: () => void
|
||||||
|
hideDeleting: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersionPickerState = {
|
||||||
|
isShow: boolean
|
||||||
|
setIsShow: (show: boolean) => void
|
||||||
|
targetVersion: VersionTarget
|
||||||
|
setTargetVersion: (version: VersionTarget) => void
|
||||||
|
isDowngrade: boolean
|
||||||
|
setIsDowngrade: (downgrade: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDetailHeaderStateReturn = {
|
||||||
|
modalStates: ModalStates
|
||||||
|
versionPicker: VersionPickerState
|
||||||
|
hasNewVersion: boolean
|
||||||
|
isAutoUpgradeEnabled: boolean
|
||||||
|
isFromGitHub: boolean
|
||||||
|
isFromMarketplace: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => {
|
||||||
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
const { referenceSetting } = useReferenceSetting()
|
||||||
|
|
||||||
|
const {
|
||||||
|
source,
|
||||||
|
version,
|
||||||
|
latest_version,
|
||||||
|
latest_unique_identifier,
|
||||||
|
plugin_id,
|
||||||
|
} = detail
|
||||||
|
|
||||||
|
const isFromGitHub = source === PluginSource.github
|
||||||
|
const isFromMarketplace = source === PluginSource.marketplace
|
||||||
|
const [isShow, setIsShow] = useState(false)
|
||||||
|
const [targetVersion, setTargetVersion] = useState<VersionTarget>({
|
||||||
|
version: latest_version,
|
||||||
|
unique_identifier: latest_unique_identifier,
|
||||||
|
})
|
||||||
|
const [isDowngrade, setIsDowngrade] = useState(false)
|
||||||
|
|
||||||
|
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false)
|
||||||
|
const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = useBoolean(false)
|
||||||
|
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false)
|
||||||
|
const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false)
|
||||||
|
|
||||||
|
const hasNewVersion = useMemo(() => {
|
||||||
|
if (isFromMarketplace)
|
||||||
|
return !!latest_version && latest_version !== version
|
||||||
|
return false
|
||||||
|
}, [isFromMarketplace, latest_version, version])
|
||||||
|
|
||||||
|
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||||
|
|
||||||
|
const isAutoUpgradeEnabled = useMemo(() => {
|
||||||
|
if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace)
|
||||||
|
return false
|
||||||
|
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
||||||
|
return false
|
||||||
|
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
||||||
|
return true
|
||||||
|
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
||||||
|
return true
|
||||||
|
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
}, [autoUpgradeInfo, plugin_id, isFromMarketplace, enable_marketplace])
|
||||||
|
|
||||||
|
const handleSetTargetVersion = useCallback((version: VersionTarget) => {
|
||||||
|
setTargetVersion(version)
|
||||||
|
if (version.isDowngrade !== undefined)
|
||||||
|
setIsDowngrade(version.isDowngrade)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
modalStates: {
|
||||||
|
isShowUpdateModal,
|
||||||
|
showUpdateModal,
|
||||||
|
hideUpdateModal,
|
||||||
|
isShowPluginInfo,
|
||||||
|
showPluginInfo,
|
||||||
|
hidePluginInfo,
|
||||||
|
isShowDeleteConfirm,
|
||||||
|
showDeleteConfirm,
|
||||||
|
hideDeleteConfirm,
|
||||||
|
deleting,
|
||||||
|
showDeleting,
|
||||||
|
hideDeleting,
|
||||||
|
},
|
||||||
|
versionPicker: {
|
||||||
|
isShow,
|
||||||
|
setIsShow,
|
||||||
|
targetVersion,
|
||||||
|
setTargetVersion: handleSetTargetVersion,
|
||||||
|
isDowngrade,
|
||||||
|
setIsDowngrade,
|
||||||
|
},
|
||||||
|
hasNewVersion,
|
||||||
|
isAutoUpgradeEnabled,
|
||||||
|
isFromGitHub,
|
||||||
|
isFromMarketplace,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,549 @@
|
|||||||
|
import type { PluginDetail } from '../../../types'
|
||||||
|
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as amplitude from '@/app/components/base/amplitude'
|
||||||
|
import Toast from '@/app/components/base/toast'
|
||||||
|
import { PluginSource } from '../../../types'
|
||||||
|
import { usePluginOperations } from './use-plugin-operations'
|
||||||
|
|
||||||
|
type VersionPickerMock = {
|
||||||
|
setTargetVersion: (version: VersionTarget) => void
|
||||||
|
setIsDowngrade: (downgrade: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockSetShowUpdatePluginModal,
|
||||||
|
mockRefreshModelProviders,
|
||||||
|
mockInvalidateAllToolProviders,
|
||||||
|
mockUninstallPlugin,
|
||||||
|
mockFetchReleases,
|
||||||
|
mockCheckForUpdates,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
mockSetShowUpdatePluginModal: vi.fn(),
|
||||||
|
mockRefreshModelProviders: vi.fn(),
|
||||||
|
mockInvalidateAllToolProviders: vi.fn(),
|
||||||
|
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
|
||||||
|
mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/context/modal-context', () => ({
|
||||||
|
useModalContext: () => ({
|
||||||
|
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => ({
|
||||||
|
refreshModelProviders: mockRefreshModelProviders,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/plugins', () => ({
|
||||||
|
uninstallPlugin: mockUninstallPlugin,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/use-tools', () => ({
|
||||||
|
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../install-plugin/hooks', () => ({
|
||||||
|
useGitHubReleases: () => ({
|
||||||
|
checkForUpdates: mockCheckForUpdates,
|
||||||
|
fetchReleases: mockFetchReleases,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||||
|
id: 'test-id',
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
updated_at: '2024-01-02',
|
||||||
|
name: 'Test Plugin',
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
plugin_unique_identifier: 'test-uid',
|
||||||
|
declaration: {
|
||||||
|
author: 'test-author',
|
||||||
|
name: 'test-plugin-name',
|
||||||
|
category: 'tool',
|
||||||
|
label: { en_US: 'Test Plugin Label' },
|
||||||
|
description: { en_US: 'Test description' },
|
||||||
|
icon: 'icon.png',
|
||||||
|
verified: true,
|
||||||
|
} as unknown as PluginDetail['declaration'],
|
||||||
|
installation_id: 'install-1',
|
||||||
|
tenant_id: 'tenant-1',
|
||||||
|
endpoints_setups: 0,
|
||||||
|
endpoints_active: 0,
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '2.0.0',
|
||||||
|
latest_unique_identifier: 'new-uid',
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
meta: undefined,
|
||||||
|
status: 'active',
|
||||||
|
deprecated_reason: '',
|
||||||
|
alternative_plugin_id: '',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createModalStatesMock = (): ModalStates => ({
|
||||||
|
isShowUpdateModal: false,
|
||||||
|
showUpdateModal: vi.fn(),
|
||||||
|
hideUpdateModal: vi.fn(),
|
||||||
|
isShowPluginInfo: false,
|
||||||
|
showPluginInfo: vi.fn(),
|
||||||
|
hidePluginInfo: vi.fn(),
|
||||||
|
isShowDeleteConfirm: false,
|
||||||
|
showDeleteConfirm: vi.fn(),
|
||||||
|
hideDeleteConfirm: vi.fn(),
|
||||||
|
deleting: false,
|
||||||
|
showDeleting: vi.fn(),
|
||||||
|
hideDeleting: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createVersionPickerMock = (): VersionPickerMock => ({
|
||||||
|
setTargetVersion: vi.fn<(version: VersionTarget) => void>(),
|
||||||
|
setIsDowngrade: vi.fn<(downgrade: boolean) => void>(),
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('usePluginOperations', () => {
|
||||||
|
let modalStates: ModalStates
|
||||||
|
let versionPicker: VersionPickerMock
|
||||||
|
let mockOnUpdate: (isDelete?: boolean) => void
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modalStates = createModalStatesMock()
|
||||||
|
versionPicker = createVersionPickerMock()
|
||||||
|
mockOnUpdate = vi.fn()
|
||||||
|
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||||
|
vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Marketplace Update Flow', () => {
|
||||||
|
it('should show update modal for marketplace plugin', async () => {
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(modalStates.showUpdateModal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set isDowngrade when downgrading', async () => {
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(versionPicker.setIsDowngrade).toHaveBeenCalledWith(true)
|
||||||
|
expect(modalStates.showUpdateModal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onUpdate and hide modal on successful marketplace update', () => {
|
||||||
|
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleUpdatedFromMarketplace()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnUpdate).toHaveBeenCalled()
|
||||||
|
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GitHub Update Flow', () => {
|
||||||
|
it('should fetch releases from GitHub', async () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: false,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check for updates after fetching releases', async () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: false,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockCheckForUpdates).toHaveBeenCalled()
|
||||||
|
expect(Toast.notify).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show update plugin modal when update is needed', async () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: false,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show modal when no releases found', async () => {
|
||||||
|
mockFetchReleases.mockResolvedValueOnce([])
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: false,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show modal when no update needed', async () => {
|
||||||
|
mockCheckForUpdates.mockReturnValueOnce({
|
||||||
|
needUpdate: false,
|
||||||
|
toastProps: { type: 'info', message: 'Already up to date' },
|
||||||
|
})
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: false,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use author and name as fallback for repo parsing', async () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
source: PluginSource.github,
|
||||||
|
meta: { repo: '/', version: 'v1.0.0', package: 'pkg' },
|
||||||
|
declaration: {
|
||||||
|
author: 'fallback-author',
|
||||||
|
name: 'fallback-name',
|
||||||
|
category: 'tool',
|
||||||
|
label: { en_US: 'Test' },
|
||||||
|
description: { en_US: 'Test' },
|
||||||
|
icon: 'icon.png',
|
||||||
|
verified: true,
|
||||||
|
} as unknown as PluginDetail['declaration'],
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: false,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Delete Flow', () => {
|
||||||
|
it('should call uninstallPlugin with correct id', async () => {
|
||||||
|
const detail = createPluginDetail({ id: 'plugin-to-delete' })
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockUninstallPlugin).toHaveBeenCalledWith('plugin-to-delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show and hide deleting state during delete', async () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(modalStates.showDeleting).toHaveBeenCalled()
|
||||||
|
expect(modalStates.hideDeleting).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onUpdate with true after successful delete', async () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide delete confirm after successful delete', async () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refresh model providers when deleting model plugin', async () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
declaration: {
|
||||||
|
author: 'test-author',
|
||||||
|
name: 'test-plugin-name',
|
||||||
|
category: 'model',
|
||||||
|
label: { en_US: 'Test' },
|
||||||
|
description: { en_US: 'Test' },
|
||||||
|
icon: 'icon.png',
|
||||||
|
verified: true,
|
||||||
|
} as unknown as PluginDetail['declaration'],
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRefreshModelProviders).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should invalidate tool providers when deleting tool plugin', async () => {
|
||||||
|
const detail = createPluginDetail({
|
||||||
|
declaration: {
|
||||||
|
author: 'test-author',
|
||||||
|
name: 'test-plugin-name',
|
||||||
|
category: 'tool',
|
||||||
|
label: { en_US: 'Test' },
|
||||||
|
description: { en_US: 'Test' },
|
||||||
|
icon: 'icon.png',
|
||||||
|
verified: true,
|
||||||
|
} as unknown as PluginDetail['declaration'],
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track plugin uninstalled event', async () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.objectContaining({
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
plugin_name: 'test-plugin-name',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call onUpdate when delete fails', async () => {
|
||||||
|
mockUninstallPlugin.mockResolvedValueOnce({ success: false })
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
onUpdate: mockOnUpdate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOnUpdate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Optional onUpdate Callback', () => {
|
||||||
|
it('should not throw when onUpdate is not provided for marketplace update', () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
result.current.handleUpdatedFromMarketplace()
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw when onUpdate is not provided for delete', async () => {
|
||||||
|
const detail = createPluginDetail()
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.handleDelete()
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PluginDetail } from '../../../types'
|
||||||
|
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { trackEvent } from '@/app/components/base/amplitude'
|
||||||
|
import Toast from '@/app/components/base/toast'
|
||||||
|
import { useModalContext } from '@/context/modal-context'
|
||||||
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
|
import { uninstallPlugin } from '@/service/plugins'
|
||||||
|
import { useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||||
|
import { useGitHubReleases } from '../../../install-plugin/hooks'
|
||||||
|
import { PluginCategoryEnum, PluginSource } from '../../../types'
|
||||||
|
|
||||||
|
type UsePluginOperationsParams = {
|
||||||
|
detail: PluginDetail
|
||||||
|
modalStates: ModalStates
|
||||||
|
versionPicker: {
|
||||||
|
setTargetVersion: (version: VersionTarget) => void
|
||||||
|
setIsDowngrade: (downgrade: boolean) => void
|
||||||
|
}
|
||||||
|
isFromMarketplace: boolean
|
||||||
|
onUpdate?: (isDelete?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsePluginOperationsReturn = {
|
||||||
|
handleUpdate: (isDowngrade?: boolean) => Promise<void>
|
||||||
|
handleUpdatedFromMarketplace: () => void
|
||||||
|
handleDelete: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePluginOperations = ({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace,
|
||||||
|
onUpdate,
|
||||||
|
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
|
||||||
|
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||||
|
const { setShowUpdatePluginModal } = useModalContext()
|
||||||
|
const { refreshModelProviders } = useProviderContext()
|
||||||
|
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||||
|
|
||||||
|
const { id, meta, plugin_id } = detail
|
||||||
|
const { author, category, name } = detail.declaration || detail
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
|
||||||
|
if (isFromMarketplace) {
|
||||||
|
versionPicker.setIsDowngrade(!!isDowngrade)
|
||||||
|
modalStates.showUpdateModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meta?.repo || !meta?.version || !meta?.package) {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Missing plugin metadata for GitHub update',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = meta.repo.split('/')[0] || author
|
||||||
|
const repo = meta.repo.split('/')[1] || name
|
||||||
|
const fetchedReleases = await fetchReleases(owner, repo)
|
||||||
|
if (fetchedReleases.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta.version)
|
||||||
|
Toast.notify(toastProps)
|
||||||
|
|
||||||
|
if (needUpdate) {
|
||||||
|
setShowUpdatePluginModal({
|
||||||
|
onSaveCallback: () => {
|
||||||
|
onUpdate?.()
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: PluginSource.github,
|
||||||
|
category,
|
||||||
|
github: {
|
||||||
|
originalPackageInfo: {
|
||||||
|
id: detail.plugin_unique_identifier,
|
||||||
|
repo: meta.repo,
|
||||||
|
version: meta.version,
|
||||||
|
package: meta.package,
|
||||||
|
releases: fetchedReleases,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isFromMarketplace,
|
||||||
|
meta,
|
||||||
|
author,
|
||||||
|
name,
|
||||||
|
fetchReleases,
|
||||||
|
checkForUpdates,
|
||||||
|
setShowUpdatePluginModal,
|
||||||
|
detail,
|
||||||
|
onUpdate,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleUpdatedFromMarketplace = useCallback(() => {
|
||||||
|
onUpdate?.()
|
||||||
|
modalStates.hideUpdateModal()
|
||||||
|
}, [onUpdate, modalStates])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
modalStates.showDeleting()
|
||||||
|
const res = await uninstallPlugin(id)
|
||||||
|
modalStates.hideDeleting()
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
modalStates.hideDeleteConfirm()
|
||||||
|
onUpdate?.(true)
|
||||||
|
|
||||||
|
if (PluginCategoryEnum.model.includes(category))
|
||||||
|
refreshModelProviders()
|
||||||
|
|
||||||
|
if (PluginCategoryEnum.tool.includes(category))
|
||||||
|
invalidateAllToolProviders()
|
||||||
|
|
||||||
|
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
id,
|
||||||
|
category,
|
||||||
|
plugin_id,
|
||||||
|
name,
|
||||||
|
modalStates,
|
||||||
|
onUpdate,
|
||||||
|
refreshModelProviders,
|
||||||
|
invalidateAllToolProviders,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleUpdate,
|
||||||
|
handleUpdatedFromMarketplace,
|
||||||
|
handleDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,286 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PluginDetail } from '../../types'
|
||||||
|
import {
|
||||||
|
RiArrowLeftRightLine,
|
||||||
|
RiCloseLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ActionButton from '@/app/components/base/action-button'
|
||||||
|
import Badge from '@/app/components/base/badge'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
|
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||||
|
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||||
|
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||||
|
import { API_PREFIX } from '@/config'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { useGetLanguage, useLocale } from '@/context/i18n'
|
||||||
|
import useTheme from '@/hooks/use-theme'
|
||||||
|
import { useAllToolProviders } from '@/service/use-tools'
|
||||||
|
import { cn } from '@/utils/classnames'
|
||||||
|
import { getMarketplaceUrl } from '@/utils/var'
|
||||||
|
import { AutoUpdateLine } from '../../../base/icons/src/vender/system'
|
||||||
|
import Verified from '../../base/badges/verified'
|
||||||
|
import DeprecationNotice from '../../base/deprecation-notice'
|
||||||
|
import Icon from '../../card/base/card-icon'
|
||||||
|
import Description from '../../card/base/description'
|
||||||
|
import OrgInfo from '../../card/base/org-info'
|
||||||
|
import Title from '../../card/base/title'
|
||||||
|
import useReferenceSetting from '../../plugin-page/use-reference-setting'
|
||||||
|
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../../reference-setting-modal/auto-update-setting/utils'
|
||||||
|
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||||
|
import { HeaderModals, PluginSourceBadge } from './components'
|
||||||
|
import { useDetailHeaderState, usePluginOperations } from './hooks'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
detail: PluginDetail
|
||||||
|
isReadmeView?: boolean
|
||||||
|
onHide?: () => void
|
||||||
|
onUpdate?: (isDelete?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIconSrc = (icon: string | undefined, iconDark: string | undefined, theme: string, tenantId: string): string => {
|
||||||
|
const iconFileName = theme === 'dark' && iconDark ? iconDark : icon
|
||||||
|
if (!iconFileName)
|
||||||
|
return ''
|
||||||
|
return iconFileName.startsWith('http')
|
||||||
|
? iconFileName
|
||||||
|
: `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${iconFileName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDetailUrl = (
|
||||||
|
source: PluginSource,
|
||||||
|
meta: PluginDetail['meta'],
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
locale: string,
|
||||||
|
theme: string,
|
||||||
|
): string => {
|
||||||
|
if (source === PluginSource.github) {
|
||||||
|
const repo = meta?.repo
|
||||||
|
if (!repo)
|
||||||
|
return ''
|
||||||
|
return `https://github.com/${repo}`
|
||||||
|
}
|
||||||
|
if (source === PluginSource.marketplace)
|
||||||
|
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetailHeader = ({
|
||||||
|
detail,
|
||||||
|
isReadmeView = false,
|
||||||
|
onHide,
|
||||||
|
onUpdate,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { userProfile: { timezone } } = useAppContext()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const locale = useGetLanguage()
|
||||||
|
const currentLocale = useLocale()
|
||||||
|
const { referenceSetting } = useReferenceSetting()
|
||||||
|
|
||||||
|
const {
|
||||||
|
source,
|
||||||
|
tenant_id,
|
||||||
|
version,
|
||||||
|
latest_version,
|
||||||
|
latest_unique_identifier,
|
||||||
|
meta,
|
||||||
|
plugin_id,
|
||||||
|
status,
|
||||||
|
deprecated_reason,
|
||||||
|
alternative_plugin_id,
|
||||||
|
} = detail
|
||||||
|
|
||||||
|
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
||||||
|
|
||||||
|
const {
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
hasNewVersion,
|
||||||
|
isAutoUpgradeEnabled,
|
||||||
|
isFromGitHub,
|
||||||
|
isFromMarketplace,
|
||||||
|
} = useDetailHeaderState(detail)
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleUpdate,
|
||||||
|
handleUpdatedFromMarketplace,
|
||||||
|
handleDelete,
|
||||||
|
} = usePluginOperations({
|
||||||
|
detail,
|
||||||
|
modalStates,
|
||||||
|
versionPicker,
|
||||||
|
isFromMarketplace,
|
||||||
|
onUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTool = category === PluginCategoryEnum.tool
|
||||||
|
const providerBriefInfo = tool?.identity
|
||||||
|
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||||
|
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
||||||
|
const provider = useMemo(() => {
|
||||||
|
return collectionList.find(collection => collection.name === providerKey)
|
||||||
|
}, [collectionList, providerKey])
|
||||||
|
|
||||||
|
const iconSrc = getIconSrc(icon, icon_dark, theme, tenant_id)
|
||||||
|
const detailUrl = getDetailUrl(source, meta, author, name, currentLocale, theme)
|
||||||
|
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||||
|
|
||||||
|
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
|
||||||
|
versionPicker.setTargetVersion(state)
|
||||||
|
handleUpdate(state.isDowngrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTriggerLatestUpdate = () => {
|
||||||
|
if (isFromMarketplace) {
|
||||||
|
versionPicker.setTargetVersion({
|
||||||
|
version: latest_version,
|
||||||
|
unique_identifier: latest_unique_identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
handleUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||||
|
<div className="flex">
|
||||||
|
{/* Plugin Icon */}
|
||||||
|
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||||
|
<Icon src={iconSrc} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plugin Info */}
|
||||||
|
<div className="ml-3 w-0 grow">
|
||||||
|
{/* Title Row */}
|
||||||
|
<div className="flex h-5 items-center">
|
||||||
|
<Title title={label[locale]} />
|
||||||
|
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
||||||
|
|
||||||
|
{/* Version Picker */}
|
||||||
|
{!!version && (
|
||||||
|
<PluginVersionPicker
|
||||||
|
disabled={!isFromMarketplace || isReadmeView}
|
||||||
|
isShow={versionPicker.isShow}
|
||||||
|
onShowChange={versionPicker.setIsShow}
|
||||||
|
pluginID={plugin_id}
|
||||||
|
currentVersion={version}
|
||||||
|
onSelect={handleVersionSelect}
|
||||||
|
trigger={(
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'mx-1',
|
||||||
|
versionPicker.isShow && 'bg-state-base-hover',
|
||||||
|
(versionPicker.isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
uppercase={false}
|
||||||
|
text={(
|
||||||
|
<>
|
||||||
|
<div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div>
|
||||||
|
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
hasRedCornerMark={hasNewVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto Update Badge */}
|
||||||
|
{isAutoUpgradeEnabled && !isReadmeView && (
|
||||||
|
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
||||||
|
<div>
|
||||||
|
<Badge className="mr-1 cursor-pointer px-1">
|
||||||
|
<AutoUpdateLine className="size-3" />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Update Button */}
|
||||||
|
{(hasNewVersion || isFromGitHub) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary-accent"
|
||||||
|
size="small"
|
||||||
|
className="!h-5"
|
||||||
|
onClick={handleTriggerLatestUpdate}
|
||||||
|
>
|
||||||
|
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Org Info Row */}
|
||||||
|
<div className="mb-1 flex h-4 items-center justify-between">
|
||||||
|
<div className="mt-0.5 flex items-center">
|
||||||
|
<OrgInfo
|
||||||
|
packageNameClassName="w-auto"
|
||||||
|
orgName={author}
|
||||||
|
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
||||||
|
/>
|
||||||
|
{!!source && <PluginSourceBadge source={source} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{!isReadmeView && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<OperationDropdown
|
||||||
|
source={source}
|
||||||
|
onInfo={modalStates.showPluginInfo}
|
||||||
|
onCheckVersion={handleUpdate}
|
||||||
|
onRemove={modalStates.showDeleteConfirm}
|
||||||
|
detailUrl={detailUrl}
|
||||||
|
/>
|
||||||
|
<ActionButton onClick={onHide}>
|
||||||
|
<RiCloseLine className="h-4 w-4" />
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deprecation Notice */}
|
||||||
|
{isFromMarketplace && (
|
||||||
|
<DeprecationNotice
|
||||||
|
status={status}
|
||||||
|
deprecatedReason={deprecated_reason}
|
||||||
|
alternativePluginId={alternative_plugin_id}
|
||||||
|
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||||
|
className="mt-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2} />}
|
||||||
|
|
||||||
|
{/* Plugin Auth for Tools */}
|
||||||
|
{category === PluginCategoryEnum.tool && !isReadmeView && (
|
||||||
|
<PluginAuth
|
||||||
|
pluginPayload={{
|
||||||
|
provider: provider?.name || '',
|
||||||
|
category: AuthCategory.tool,
|
||||||
|
providerType: provider?.type || '',
|
||||||
|
detail,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<HeaderModals
|
||||||
|
detail={detail}
|
||||||
|
modalStates={modalStates}
|
||||||
|
targetVersion={versionPicker.targetVersion}
|
||||||
|
isDowngrade={versionPicker.isDowngrade}
|
||||||
|
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
|
||||||
|
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetailHeader
|
||||||
@ -2,15 +2,10 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block
|
|||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
// Import after mocks
|
|
||||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||||
import { CommonCreateModal } from './common-modal'
|
import { CommonCreateModal } from './common-modal'
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Type Definitions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type PluginDetail = {
|
type PluginDetail = {
|
||||||
plugin_id: string
|
plugin_id: string
|
||||||
provider: string
|
provider: string
|
||||||
@ -33,10 +28,6 @@ type TriggerLogEntity = {
|
|||||||
level: 'info' | 'warn' | 'error'
|
level: 'info' | 'warn' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Factory Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDetail {
|
function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDetail {
|
||||||
return {
|
return {
|
||||||
plugin_id: 'test-plugin-id',
|
plugin_id: 'test-plugin-id',
|
||||||
@ -74,18 +65,12 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt
|
|||||||
return { logs }
|
return { logs }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Setup
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Mock plugin store
|
|
||||||
const mockPluginDetail = createMockPluginDetail()
|
const mockPluginDetail = createMockPluginDetail()
|
||||||
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
||||||
vi.mock('../../store', () => ({
|
vi.mock('../../store', () => ({
|
||||||
usePluginStore: () => mockUsePluginStore(),
|
usePluginStore: () => mockUsePluginStore(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock subscription list hook
|
|
||||||
const mockRefetch = vi.fn()
|
const mockRefetch = vi.fn()
|
||||||
vi.mock('../use-subscription-list', () => ({
|
vi.mock('../use-subscription-list', () => ({
|
||||||
useSubscriptionList: () => ({
|
useSubscriptionList: () => ({
|
||||||
@ -93,13 +78,11 @@ vi.mock('../use-subscription-list', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock service hooks
|
|
||||||
const mockVerifyCredentials = vi.fn()
|
const mockVerifyCredentials = vi.fn()
|
||||||
const mockCreateBuilder = vi.fn()
|
const mockCreateBuilder = vi.fn()
|
||||||
const mockBuildSubscription = vi.fn()
|
const mockBuildSubscription = vi.fn()
|
||||||
const mockUpdateBuilder = vi.fn()
|
const mockUpdateBuilder = vi.fn()
|
||||||
|
|
||||||
// Configurable pending states
|
|
||||||
let mockIsVerifyingCredentials = false
|
let mockIsVerifyingCredentials = false
|
||||||
let mockIsBuilding = false
|
let mockIsBuilding = false
|
||||||
const setMockPendingStates = (verifying: boolean, building: boolean) => {
|
const setMockPendingStates = (verifying: boolean, building: boolean) => {
|
||||||
@ -129,18 +112,15 @@ vi.mock('@/service/use-triggers', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock error parser
|
|
||||||
const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null)
|
const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null)
|
||||||
vi.mock('@/utils/error-parser', () => ({
|
vi.mock('@/utils/error-parser', () => ({
|
||||||
parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args),
|
parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock URL validation
|
|
||||||
vi.mock('@/utils/urlValidation', () => ({
|
vi.mock('@/utils/urlValidation', () => ({
|
||||||
isPrivateOrLocalAddress: vi.fn().mockReturnValue(false),
|
isPrivateOrLocalAddress: vi.fn().mockReturnValue(false),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock toast
|
|
||||||
const mockToastNotify = vi.fn()
|
const mockToastNotify = vi.fn()
|
||||||
vi.mock('@/app/components/base/toast', () => ({
|
vi.mock('@/app/components/base/toast', () => ({
|
||||||
default: {
|
default: {
|
||||||
@ -148,7 +128,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock Modal component
|
|
||||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||||
default: ({
|
default: ({
|
||||||
children,
|
children,
|
||||||
@ -179,7 +158,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Configurable form mock values
|
|
||||||
type MockFormValuesConfig = {
|
type MockFormValuesConfig = {
|
||||||
values: Record<string, unknown>
|
values: Record<string, unknown>
|
||||||
isCheckValidated: boolean
|
isCheckValidated: boolean
|
||||||
@ -190,7 +168,6 @@ let mockFormValuesConfig: MockFormValuesConfig = {
|
|||||||
}
|
}
|
||||||
let mockGetFormReturnsNull = false
|
let mockGetFormReturnsNull = false
|
||||||
|
|
||||||
// Separate validation configs for different forms
|
|
||||||
let mockSubscriptionFormValidated = true
|
let mockSubscriptionFormValidated = true
|
||||||
let mockAutoParamsFormValidated = true
|
let mockAutoParamsFormValidated = true
|
||||||
let mockManualPropsFormValidated = true
|
let mockManualPropsFormValidated = true
|
||||||
@ -207,7 +184,6 @@ const setMockFormValidation = (subscription: boolean, autoParams: boolean, manua
|
|||||||
mockManualPropsFormValidated = manualProps
|
mockManualPropsFormValidated = manualProps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock BaseForm component with ref support
|
|
||||||
vi.mock('@/app/components/base/form/components/base', async () => {
|
vi.mock('@/app/components/base/form/components/base', async () => {
|
||||||
const React = await import('react')
|
const React = await import('react')
|
||||||
|
|
||||||
@ -219,7 +195,6 @@ vi.mock('@/app/components/base/form/components/base', async () => {
|
|||||||
type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void }
|
type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void }
|
||||||
|
|
||||||
function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef<MockFormRef>) {
|
function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef<MockFormRef>) {
|
||||||
// Determine which form this is based on schema
|
|
||||||
const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name')
|
const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name')
|
||||||
const isAutoParamsForm = formSchemas.some((s: { name: string }) =>
|
const isAutoParamsForm = formSchemas.some((s: { name: string }) =>
|
||||||
['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name),
|
['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name),
|
||||||
@ -265,12 +240,10 @@ vi.mock('@/app/components/base/form/components/base', async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock EncryptedBottom component
|
|
||||||
vi.mock('@/app/components/base/encrypted-bottom', () => ({
|
vi.mock('@/app/components/base/encrypted-bottom', () => ({
|
||||||
EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>,
|
EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock LogViewer component
|
|
||||||
vi.mock('../log-viewer', () => ({
|
vi.mock('../log-viewer', () => ({
|
||||||
default: ({ logs }: { logs: TriggerLogEntity[] }) => (
|
default: ({ logs }: { logs: TriggerLogEntity[] }) => (
|
||||||
<div data-testid="log-viewer">
|
<div data-testid="log-viewer">
|
||||||
@ -281,7 +254,6 @@ vi.mock('../log-viewer', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock debounce
|
|
||||||
vi.mock('es-toolkit/compat', () => ({
|
vi.mock('es-toolkit/compat', () => ({
|
||||||
debounce: (fn: (...args: unknown[]) => unknown) => {
|
debounce: (fn: (...args: unknown[]) => unknown) => {
|
||||||
const debouncedFn = (...args: unknown[]) => fn(...args)
|
const debouncedFn = (...args: unknown[]) => fn(...args)
|
||||||
@ -290,10 +262,6 @@ vi.mock('es-toolkit/compat', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Test Suites
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
describe('CommonCreateModal', () => {
|
describe('CommonCreateModal', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
@ -441,7 +409,8 @@ describe('CommonCreateModal', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should call onConfirm handler when confirm button is clicked', () => {
|
it('should call onConfirm handler when confirm button is clicked', () => {
|
||||||
render(<CommonCreateModal {...defaultProps} />)
|
// Provide builder so the guard passes and credentials check is reached
|
||||||
|
render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||||
|
|
||||||
@ -1243,13 +1212,22 @@ describe('CommonCreateModal', () => {
|
|||||||
|
|
||||||
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
|
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
|
||||||
|
|
||||||
|
// Wait for createBuilder to complete and state to update
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreateBuilder).toHaveBeenCalled()
|
expect(mockCreateBuilder).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Allow React to process the state update from createBuilder
|
||||||
|
await act(async () => {})
|
||||||
|
|
||||||
const input = screen.getByTestId('form-field-webhook_url')
|
const input = screen.getByTestId('form-field-webhook_url')
|
||||||
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
|
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
|
||||||
|
|
||||||
|
// Wait for updateBuilder to be called, then check the toast
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateBuilder).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@ -1450,7 +1428,8 @@ describe('CommonCreateModal', () => {
|
|||||||
})
|
})
|
||||||
mockUsePluginStore.mockReturnValue(detailWithCredentials)
|
mockUsePluginStore.mockReturnValue(detailWithCredentials)
|
||||||
|
|
||||||
render(<CommonCreateModal {...defaultProps} />)
|
// Provide builder so the guard passes and credentials check is reached
|
||||||
|
render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FormRefObject } from '@/app/components/base/form/types'
|
|
||||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
|
|
||||||
import { RiLoader2Line } from '@remixicon/react'
|
|
||||||
import { debounce } from 'es-toolkit/compat'
|
|
||||||
import * as React from 'react'
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
|
|
||||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||||
import { BaseForm } from '@/app/components/base/form/components/base'
|
|
||||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
|
||||||
import Modal from '@/app/components/base/modal/modal'
|
import Modal from '@/app/components/base/modal/modal'
|
||||||
import Toast from '@/app/components/base/toast'
|
|
||||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import {
|
import {
|
||||||
useBuildTriggerSubscription,
|
ConfigurationStepContent,
|
||||||
useCreateTriggerSubscriptionBuilder,
|
MultiSteps,
|
||||||
useTriggerSubscriptionBuilderLogs,
|
VerifyStepContent,
|
||||||
useUpdateTriggerSubscriptionBuilder,
|
} from './components/modal-steps'
|
||||||
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
import {
|
||||||
} from '@/service/use-triggers'
|
ApiKeyStep,
|
||||||
import { parsePluginErrorMessage } from '@/utils/error-parser'
|
MODAL_TITLE_KEY_MAP,
|
||||||
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
useCommonModalState,
|
||||||
import { usePluginStore } from '../../store'
|
} from './hooks/use-common-modal-state'
|
||||||
import LogViewer from '../log-viewer'
|
|
||||||
import { useSubscriptionList } from '../use-subscription-list'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@ -34,316 +21,33 @@ type Props = {
|
|||||||
builder?: TriggerSubscriptionBuilder
|
builder?: TriggerSubscriptionBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
|
|
||||||
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
|
|
||||||
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
|
|
||||||
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODAL_TITLE_KEY_MAP: Record<
|
|
||||||
SupportedCreationMethods,
|
|
||||||
'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title'
|
|
||||||
> = {
|
|
||||||
[SupportedCreationMethods.APIKEY]: 'modal.apiKey.title',
|
|
||||||
[SupportedCreationMethods.OAUTH]: 'modal.oauth.title',
|
|
||||||
[SupportedCreationMethods.MANUAL]: 'modal.manual.title',
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ApiKeyStep {
|
|
||||||
Verify = 'verify',
|
|
||||||
Configuration = 'configuration',
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultFormValues = { values: {}, isCheckValidated: false }
|
|
||||||
|
|
||||||
const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => {
|
|
||||||
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
|
|
||||||
return type as FormTypeEnum
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'string':
|
|
||||||
case 'text':
|
|
||||||
return FormTypeEnum.textInput
|
|
||||||
case 'password':
|
|
||||||
case 'secret':
|
|
||||||
return FormTypeEnum.secretInput
|
|
||||||
case 'number':
|
|
||||||
case 'integer':
|
|
||||||
return FormTypeEnum.textNumber
|
|
||||||
case 'boolean':
|
|
||||||
return FormTypeEnum.boolean
|
|
||||||
default:
|
|
||||||
return FormTypeEnum.textInput
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => {
|
|
||||||
return (
|
|
||||||
<div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
|
|
||||||
? 'text-state-accent-solid'
|
|
||||||
: 'text-text-tertiary'}`}
|
|
||||||
>
|
|
||||||
{/* Active indicator dot */}
|
|
||||||
{isActive && (
|
|
||||||
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
|
|
||||||
)}
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MultiSteps = ({ currentStep }: { currentStep: ApiKeyStep }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
return (
|
|
||||||
<div className="mb-6 flex w-1/3 items-center gap-2">
|
|
||||||
<StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} />
|
|
||||||
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
|
|
||||||
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const detail = usePluginStore(state => state.detail)
|
|
||||||
const { refetch } = useSubscriptionList()
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration)
|
const {
|
||||||
|
currentStep,
|
||||||
|
subscriptionBuilder,
|
||||||
|
isVerifyingCredentials,
|
||||||
|
isBuilding,
|
||||||
|
formRefs,
|
||||||
|
detail,
|
||||||
|
manualPropertiesSchema,
|
||||||
|
autoCommonParametersSchema,
|
||||||
|
apiKeyCredentialsSchema,
|
||||||
|
logData,
|
||||||
|
confirmButtonText,
|
||||||
|
handleConfirm,
|
||||||
|
handleManualPropertiesChange,
|
||||||
|
handleApiKeyCredentialsChange,
|
||||||
|
} = useCommonModalState({
|
||||||
|
createType,
|
||||||
|
builder,
|
||||||
|
onClose,
|
||||||
|
})
|
||||||
|
|
||||||
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
|
const isApiKeyType = createType === SupportedCreationMethods.APIKEY
|
||||||
const isInitializedRef = useRef(false)
|
const isVerifyStep = currentStep === ApiKeyStep.Verify
|
||||||
|
const isConfigurationStep = currentStep === ApiKeyStep.Configuration
|
||||||
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
|
||||||
const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
|
|
||||||
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
|
|
||||||
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
|
|
||||||
|
|
||||||
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] // manual
|
|
||||||
const manualPropertiesFormRef = React.useRef<FormRefObject>(null)
|
|
||||||
|
|
||||||
const subscriptionFormRef = React.useRef<FormRefObject>(null)
|
|
||||||
|
|
||||||
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth
|
|
||||||
const autoCommonParametersFormRef = React.useRef<FormRefObject>(null)
|
|
||||||
|
|
||||||
const apiKeyCredentialsSchema = useMemo(() => {
|
|
||||||
const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
|
|
||||||
return rawSchema.map(schema => ({
|
|
||||||
...schema,
|
|
||||||
tooltip: schema.help,
|
|
||||||
}))
|
|
||||||
}, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
|
|
||||||
const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null)
|
|
||||||
|
|
||||||
const { data: logData } = useTriggerSubscriptionBuilderLogs(
|
|
||||||
detail?.provider || '',
|
|
||||||
subscriptionBuilder?.id || '',
|
|
||||||
{
|
|
||||||
enabled: createType === SupportedCreationMethods.MANUAL,
|
|
||||||
refetchInterval: 3000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeBuilder = async () => {
|
|
||||||
isInitializedRef.current = true
|
|
||||||
try {
|
|
||||||
const response = await createBuilder({
|
|
||||||
provider: detail?.provider || '',
|
|
||||||
credential_type: CREDENTIAL_TYPE_MAP[createType],
|
|
||||||
})
|
|
||||||
setSubscriptionBuilder(response.subscription_builder)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('createBuilder error:', error)
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
|
|
||||||
initializeBuilder()
|
|
||||||
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) {
|
|
||||||
const form = subscriptionFormRef.current.getForm()
|
|
||||||
if (form)
|
|
||||||
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
|
|
||||||
if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) {
|
|
||||||
console.warn('callback_url is private or local address', subscriptionBuilder.endpoint)
|
|
||||||
subscriptionFormRef.current?.setFields([{
|
|
||||||
name: 'callback_url',
|
|
||||||
warnings: [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })],
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
subscriptionFormRef.current?.setFields([{
|
|
||||||
name: 'callback_url',
|
|
||||||
warnings: [],
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [subscriptionBuilder?.endpoint, currentStep, t])
|
|
||||||
|
|
||||||
const debouncedUpdate = useMemo(
|
|
||||||
() => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
|
|
||||||
updateBuilder(
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
subscriptionBuilderId: builderId,
|
|
||||||
properties,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onError: async (error: unknown) => {
|
|
||||||
const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' })
|
|
||||||
console.error('Failed to update subscription builder:', error)
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: errorMessage,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}, 500),
|
|
||||||
[updateBuilder, t],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleManualPropertiesChange = useCallback(() => {
|
|
||||||
if (!subscriptionBuilder || !detail?.provider)
|
|
||||||
return
|
|
||||||
|
|
||||||
const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) || { values: {}, isCheckValidated: true }
|
|
||||||
|
|
||||||
debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values)
|
|
||||||
}, [subscriptionBuilder, detail?.provider, debouncedUpdate])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
debouncedUpdate.cancel()
|
|
||||||
}
|
|
||||||
}, [debouncedUpdate])
|
|
||||||
|
|
||||||
const handleVerify = () => {
|
|
||||||
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || defaultFormValues
|
|
||||||
const credentials = apiKeyCredentialsFormValues.values
|
|
||||||
|
|
||||||
if (!Object.keys(credentials).length) {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Please fill in all required credentials',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKeyCredentialsFormRef.current?.setFields([{
|
|
||||||
name: Object.keys(credentials)[0],
|
|
||||||
errors: [],
|
|
||||||
}])
|
|
||||||
|
|
||||||
verifyCredentials(
|
|
||||||
{
|
|
||||||
provider: detail?.provider || '',
|
|
||||||
subscriptionBuilderId: subscriptionBuilder?.id || '',
|
|
||||||
credentials,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
setCurrentStep(ApiKeyStep.Configuration)
|
|
||||||
},
|
|
||||||
onError: async (error: unknown) => {
|
|
||||||
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
|
|
||||||
apiKeyCredentialsFormRef.current?.setFields([{
|
|
||||||
name: Object.keys(credentials)[0],
|
|
||||||
errors: [errorMessage],
|
|
||||||
}])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
if (!subscriptionBuilder) {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Subscription builder not found',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
|
|
||||||
if (!subscriptionFormValues?.isCheckValidated)
|
|
||||||
return
|
|
||||||
|
|
||||||
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
|
|
||||||
|
|
||||||
const params: BuildTriggerSubscriptionPayload = {
|
|
||||||
provider: detail?.provider || '',
|
|
||||||
subscriptionBuilderId: subscriptionBuilder.id,
|
|
||||||
name: subscriptionNameValue,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createType !== SupportedCreationMethods.MANUAL) {
|
|
||||||
if (autoCommonParametersSchema.length > 0) {
|
|
||||||
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || defaultFormValues
|
|
||||||
if (!autoCommonParametersFormValues?.isCheckValidated)
|
|
||||||
return
|
|
||||||
params.parameters = autoCommonParametersFormValues.values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (manualPropertiesSchema.length > 0) {
|
|
||||||
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || defaultFormValues
|
|
||||||
if (!manualFormValues?.isCheckValidated)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
buildSubscription(
|
|
||||||
params,
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('subscription.createSuccess', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
onClose()
|
|
||||||
refetch?.()
|
|
||||||
},
|
|
||||||
onError: async (error: unknown) => {
|
|
||||||
const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' })
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: errorMessage,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
if (currentStep === ApiKeyStep.Verify)
|
|
||||||
handleVerify()
|
|
||||||
else
|
|
||||||
handleCreate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApiKeyCredentialsChange = () => {
|
|
||||||
apiKeyCredentialsFormRef.current?.setFields([{
|
|
||||||
name: apiKeyCredentialsSchema[0].name,
|
|
||||||
errors: [],
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmButtonText = useMemo(() => {
|
|
||||||
if (currentStep === ApiKeyStep.Verify)
|
|
||||||
return isVerifyingCredentials ? t('modal.common.verifying', { ns: 'pluginTrigger' }) : t('modal.common.verify', { ns: 'pluginTrigger' })
|
|
||||||
|
|
||||||
return isBuilding ? t('modal.common.creating', { ns: 'pluginTrigger' }) : t('modal.common.create', { ns: 'pluginTrigger' })
|
|
||||||
}, [currentStep, isVerifyingCredentials, isBuilding, t])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -353,121 +57,36 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
disabled={isVerifyingCredentials || isBuilding}
|
disabled={isVerifyingCredentials || isBuilding}
|
||||||
bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null}
|
bottomSlot={isVerifyStep ? <EncryptedBottom /> : null}
|
||||||
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
|
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
|
||||||
containerClassName="min-h-[360px]"
|
containerClassName="min-h-[360px]"
|
||||||
clickOutsideNotClose
|
clickOutsideNotClose
|
||||||
>
|
>
|
||||||
{createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />}
|
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
|
||||||
{currentStep === ApiKeyStep.Verify && (
|
|
||||||
<>
|
|
||||||
{apiKeyCredentialsSchema.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<BaseForm
|
|
||||||
formSchemas={apiKeyCredentialsSchema}
|
|
||||||
ref={apiKeyCredentialsFormRef}
|
|
||||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
|
||||||
preventDefaultSubmit={true}
|
|
||||||
formClassName="space-y-4"
|
|
||||||
onChange={handleApiKeyCredentialsChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{currentStep === ApiKeyStep.Configuration && (
|
|
||||||
<div className="max-h-[70vh]">
|
|
||||||
<BaseForm
|
|
||||||
formSchemas={[
|
|
||||||
{
|
|
||||||
name: 'subscription_name',
|
|
||||||
label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }),
|
|
||||||
placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }),
|
|
||||||
type: FormTypeEnum.textInput,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'callback_url',
|
|
||||||
label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }),
|
|
||||||
placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }),
|
|
||||||
type: FormTypeEnum.textInput,
|
|
||||||
required: false,
|
|
||||||
default: subscriptionBuilder?.endpoint || '',
|
|
||||||
disabled: true,
|
|
||||||
tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }),
|
|
||||||
showCopy: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
ref={subscriptionFormRef}
|
|
||||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
|
||||||
formClassName="space-y-4 mb-4"
|
|
||||||
/>
|
|
||||||
{/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'>
|
|
||||||
{t('pluginTrigger.modal.form.callbackUrl.description')}
|
|
||||||
</div> */}
|
|
||||||
{createType !== SupportedCreationMethods.MANUAL && autoCommonParametersSchema.length > 0 && (
|
|
||||||
<BaseForm
|
|
||||||
formSchemas={autoCommonParametersSchema.map((schema) => {
|
|
||||||
const normalizedType = normalizeFormType(schema.type as FormTypeEnum | string)
|
|
||||||
return {
|
|
||||||
...schema,
|
|
||||||
tooltip: schema.description,
|
|
||||||
type: normalizedType,
|
|
||||||
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
|
|
||||||
? {
|
|
||||||
plugin_id: detail?.plugin_id || '',
|
|
||||||
provider: detail?.provider || '',
|
|
||||||
action: 'provider',
|
|
||||||
parameter: schema.name,
|
|
||||||
credential_id: subscriptionBuilder?.id || '',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
|
|
||||||
labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
ref={autoCommonParametersFormRef}
|
|
||||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
|
||||||
formClassName="space-y-4"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{createType === SupportedCreationMethods.MANUAL && (
|
|
||||||
<>
|
|
||||||
{manualPropertiesSchema.length > 0 && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<BaseForm
|
|
||||||
formSchemas={manualPropertiesSchema.map(schema => ({
|
|
||||||
...schema,
|
|
||||||
tooltip: schema.description,
|
|
||||||
}))}
|
|
||||||
ref={manualPropertiesFormRef}
|
|
||||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
|
||||||
formClassName="space-y-4"
|
|
||||||
onChange={handleManualPropertiesChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="mb-3 flex items-center gap-2">
|
|
||||||
<div className="system-xs-medium-uppercase text-text-tertiary">
|
|
||||||
{t('modal.manual.logs.title', { ns: 'pluginTrigger' })}
|
|
||||||
</div>
|
|
||||||
<div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3">
|
{isVerifyStep && (
|
||||||
<div className="h-3.5 w-3.5">
|
<VerifyStepContent
|
||||||
<RiLoader2Line className="h-full w-full animate-spin" />
|
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
|
||||||
</div>
|
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
|
||||||
<div className="system-xs-regular text-text-tertiary">
|
onChange={handleApiKeyCredentialsChange}
|
||||||
{t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName: detail?.name || '' })}
|
/>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
<LogViewer logs={logData?.logs || []} />
|
{isConfigurationStep && (
|
||||||
</div>
|
<ConfigurationStepContent
|
||||||
</>
|
createType={createType}
|
||||||
)}
|
subscriptionBuilder={subscriptionBuilder}
|
||||||
</div>
|
subscriptionFormRef={formRefs.subscriptionFormRef}
|
||||||
|
autoCommonParametersSchema={autoCommonParametersSchema}
|
||||||
|
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
|
||||||
|
manualPropertiesSchema={manualPropertiesSchema}
|
||||||
|
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
|
||||||
|
onManualPropertiesChange={handleManualPropertiesChange}
|
||||||
|
logs={logData?.logs || []}
|
||||||
|
pluginId={detail?.plugin_id || ''}
|
||||||
|
pluginName={detail?.name || ''}
|
||||||
|
provider={detail?.provider || ''}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,304 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
|
||||||
|
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import { RiLoader2Line } from '@remixicon/react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { BaseForm } from '@/app/components/base/form/components/base'
|
||||||
|
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||||
|
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||||
|
import LogViewer from '../../log-viewer'
|
||||||
|
import { ApiKeyStep } from '../hooks/use-common-modal-state'
|
||||||
|
|
||||||
|
export type SchemaItem = Partial<FormSchema> & Record<string, unknown> & {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusStepProps = {
|
||||||
|
isActive: boolean
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusStep = ({ isActive, text }: StatusStepProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
|
||||||
|
? 'text-state-accent-solid'
|
||||||
|
: 'text-text-tertiary'}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
|
||||||
|
)}
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiStepsProps = {
|
||||||
|
currentStep: ApiKeyStep
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultiSteps = ({ currentStep }: MultiStepsProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex w-1/3 items-center gap-2">
|
||||||
|
<StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} />
|
||||||
|
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
|
||||||
|
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyStepContentProps = {
|
||||||
|
apiKeyCredentialsSchema: SchemaItem[]
|
||||||
|
apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
onChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerifyStepContent = ({
|
||||||
|
apiKeyCredentialsSchema,
|
||||||
|
apiKeyCredentialsFormRef,
|
||||||
|
onChange,
|
||||||
|
}: VerifyStepContentProps) => {
|
||||||
|
if (!apiKeyCredentialsSchema.length)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<BaseForm
|
||||||
|
formSchemas={apiKeyCredentialsSchema as FormSchema[]}
|
||||||
|
ref={apiKeyCredentialsFormRef}
|
||||||
|
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||||
|
preventDefaultSubmit={true}
|
||||||
|
formClassName="space-y-4"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionFormProps = {
|
||||||
|
subscriptionFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
endpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionForm = ({
|
||||||
|
subscriptionFormRef,
|
||||||
|
endpoint,
|
||||||
|
}: SubscriptionFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const formSchemas = React.useMemo(() => [
|
||||||
|
{
|
||||||
|
name: 'subscription_name',
|
||||||
|
label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }),
|
||||||
|
placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }),
|
||||||
|
type: FormTypeEnum.textInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'callback_url',
|
||||||
|
label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }),
|
||||||
|
placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }),
|
||||||
|
type: FormTypeEnum.textInput,
|
||||||
|
required: false,
|
||||||
|
default: endpoint || '',
|
||||||
|
disabled: true,
|
||||||
|
tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }),
|
||||||
|
showCopy: true,
|
||||||
|
},
|
||||||
|
], [endpoint, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseForm
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
ref={subscriptionFormRef}
|
||||||
|
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||||
|
formClassName="space-y-4 mb-4"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => {
|
||||||
|
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
|
||||||
|
return type as FormTypeEnum
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, FormTypeEnum> = {
|
||||||
|
string: FormTypeEnum.textInput,
|
||||||
|
text: FormTypeEnum.textInput,
|
||||||
|
password: FormTypeEnum.secretInput,
|
||||||
|
secret: FormTypeEnum.secretInput,
|
||||||
|
number: FormTypeEnum.textNumber,
|
||||||
|
integer: FormTypeEnum.textNumber,
|
||||||
|
boolean: FormTypeEnum.boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
return TYPE_MAP[type] || FormTypeEnum.textInput
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoParametersFormProps = {
|
||||||
|
schemas: SchemaItem[]
|
||||||
|
formRef: React.RefObject<FormRefObject | null>
|
||||||
|
pluginId: string
|
||||||
|
provider: string
|
||||||
|
credentialId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutoParametersForm = ({
|
||||||
|
schemas,
|
||||||
|
formRef,
|
||||||
|
pluginId,
|
||||||
|
provider,
|
||||||
|
credentialId,
|
||||||
|
}: AutoParametersFormProps) => {
|
||||||
|
const formSchemas = React.useMemo(() =>
|
||||||
|
schemas.map((schema) => {
|
||||||
|
const normalizedType = normalizeFormType((schema.type || FormTypeEnum.textInput) as FormTypeEnum | string)
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
tooltip: schema.description,
|
||||||
|
type: normalizedType,
|
||||||
|
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
|
||||||
|
? {
|
||||||
|
plugin_id: pluginId,
|
||||||
|
provider,
|
||||||
|
action: 'provider',
|
||||||
|
parameter: schema.name,
|
||||||
|
credential_id: credentialId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
fieldClassName: normalizedType === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
|
||||||
|
labelClassName: normalizedType === FormTypeEnum.boolean ? 'mb-0' : undefined,
|
||||||
|
}
|
||||||
|
}) as FormSchema[], [schemas, pluginId, provider, credentialId])
|
||||||
|
|
||||||
|
if (!schemas.length)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseForm
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
ref={formRef}
|
||||||
|
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||||
|
formClassName="space-y-4"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualPropertiesSectionProps = {
|
||||||
|
schemas: SchemaItem[]
|
||||||
|
formRef: React.RefObject<FormRefObject | null>
|
||||||
|
onChange: () => void
|
||||||
|
logs: TriggerLogEntity[]
|
||||||
|
pluginName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManualPropertiesSection = ({
|
||||||
|
schemas,
|
||||||
|
formRef,
|
||||||
|
onChange,
|
||||||
|
logs,
|
||||||
|
pluginName,
|
||||||
|
}: ManualPropertiesSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const formSchemas = React.useMemo(() =>
|
||||||
|
schemas.map(schema => ({
|
||||||
|
...schema,
|
||||||
|
tooltip: schema.description,
|
||||||
|
})) as FormSchema[], [schemas])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{schemas.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<BaseForm
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
ref={formRef}
|
||||||
|
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||||
|
formClassName="space-y-4"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<div className="system-xs-medium-uppercase text-text-tertiary">
|
||||||
|
{t('modal.manual.logs.title', { ns: 'pluginTrigger' })}
|
||||||
|
</div>
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3">
|
||||||
|
<div className="h-3.5 w-3.5">
|
||||||
|
<RiLoader2Line className="h-full w-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="system-xs-regular text-text-tertiary">
|
||||||
|
{t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LogViewer logs={logs} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigurationStepContentProps = {
|
||||||
|
createType: SupportedCreationMethods
|
||||||
|
subscriptionBuilder?: TriggerSubscriptionBuilder
|
||||||
|
subscriptionFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
autoCommonParametersSchema: SchemaItem[]
|
||||||
|
autoCommonParametersFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
manualPropertiesSchema: SchemaItem[]
|
||||||
|
manualPropertiesFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
onManualPropertiesChange: () => void
|
||||||
|
logs: TriggerLogEntity[]
|
||||||
|
pluginId: string
|
||||||
|
pluginName: string
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigurationStepContent = ({
|
||||||
|
createType,
|
||||||
|
subscriptionBuilder,
|
||||||
|
subscriptionFormRef,
|
||||||
|
autoCommonParametersSchema,
|
||||||
|
autoCommonParametersFormRef,
|
||||||
|
manualPropertiesSchema,
|
||||||
|
manualPropertiesFormRef,
|
||||||
|
onManualPropertiesChange,
|
||||||
|
logs,
|
||||||
|
pluginId,
|
||||||
|
pluginName,
|
||||||
|
provider,
|
||||||
|
}: ConfigurationStepContentProps) => {
|
||||||
|
const isManualType = createType === SupportedCreationMethods.MANUAL
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[70vh]">
|
||||||
|
<SubscriptionForm
|
||||||
|
subscriptionFormRef={subscriptionFormRef}
|
||||||
|
endpoint={subscriptionBuilder?.endpoint}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isManualType && autoCommonParametersSchema.length > 0 && (
|
||||||
|
<AutoParametersForm
|
||||||
|
schemas={autoCommonParametersSchema}
|
||||||
|
formRef={autoCommonParametersFormRef}
|
||||||
|
pluginId={pluginId}
|
||||||
|
provider={provider}
|
||||||
|
credentialId={subscriptionBuilder?.id || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isManualType && (
|
||||||
|
<ManualPropertiesSection
|
||||||
|
schemas={manualPropertiesSchema}
|
||||||
|
formRef={manualPropertiesFormRef}
|
||||||
|
onChange={onManualPropertiesChange}
|
||||||
|
logs={logs}
|
||||||
|
pluginName={pluginName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,401 @@
|
|||||||
|
'use client'
|
||||||
|
import type { SimpleDetail } from '../../../store'
|
||||||
|
import type { SchemaItem } from '../components/modal-steps'
|
||||||
|
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||||
|
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
|
||||||
|
import { debounce } from 'es-toolkit/compat'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Toast from '@/app/components/base/toast'
|
||||||
|
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||||
|
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import {
|
||||||
|
useBuildTriggerSubscription,
|
||||||
|
useCreateTriggerSubscriptionBuilder,
|
||||||
|
useTriggerSubscriptionBuilderLogs,
|
||||||
|
useUpdateTriggerSubscriptionBuilder,
|
||||||
|
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
||||||
|
} from '@/service/use-triggers'
|
||||||
|
import { parsePluginErrorMessage } from '@/utils/error-parser'
|
||||||
|
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
||||||
|
import { usePluginStore } from '../../../store'
|
||||||
|
import { useSubscriptionList } from '../../use-subscription-list'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export enum ApiKeyStep {
|
||||||
|
Verify = 'verify',
|
||||||
|
Configuration = 'configuration',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
|
||||||
|
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
|
||||||
|
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
|
||||||
|
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODAL_TITLE_KEY_MAP: Record<
|
||||||
|
SupportedCreationMethods,
|
||||||
|
'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title'
|
||||||
|
> = {
|
||||||
|
[SupportedCreationMethods.APIKEY]: 'modal.apiKey.title',
|
||||||
|
[SupportedCreationMethods.OAUTH]: 'modal.oauth.title',
|
||||||
|
[SupportedCreationMethods.MANUAL]: 'modal.manual.title',
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseCommonModalStateParams = {
|
||||||
|
createType: SupportedCreationMethods
|
||||||
|
builder?: TriggerSubscriptionBuilder
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormRefs = {
|
||||||
|
manualPropertiesFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
subscriptionFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
autoCommonParametersFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseCommonModalStateReturn = {
|
||||||
|
// State
|
||||||
|
currentStep: ApiKeyStep
|
||||||
|
subscriptionBuilder: TriggerSubscriptionBuilder | undefined
|
||||||
|
isVerifyingCredentials: boolean
|
||||||
|
isBuilding: boolean
|
||||||
|
|
||||||
|
// Form refs
|
||||||
|
formRefs: FormRefs
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
detail: SimpleDetail | undefined
|
||||||
|
manualPropertiesSchema: SchemaItem[]
|
||||||
|
autoCommonParametersSchema: SchemaItem[]
|
||||||
|
apiKeyCredentialsSchema: SchemaItem[]
|
||||||
|
logData: { logs: TriggerLogEntity[] } | undefined
|
||||||
|
confirmButtonText: string
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleVerify: () => void
|
||||||
|
handleCreate: () => void
|
||||||
|
handleConfirm: () => void
|
||||||
|
handleManualPropertiesChange: () => void
|
||||||
|
handleApiKeyCredentialsChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useCommonModalState = ({
|
||||||
|
createType,
|
||||||
|
builder,
|
||||||
|
onClose,
|
||||||
|
}: UseCommonModalStateParams): UseCommonModalStateReturn => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const detail = usePluginStore(state => state.detail)
|
||||||
|
const { refetch } = useSubscriptionList()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(
|
||||||
|
createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration,
|
||||||
|
)
|
||||||
|
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
|
||||||
|
// Form refs
|
||||||
|
const manualPropertiesFormRef = useRef<FormRefObject>(null)
|
||||||
|
const subscriptionFormRef = useRef<FormRefObject>(null)
|
||||||
|
const autoCommonParametersFormRef = useRef<FormRefObject>(null)
|
||||||
|
const apiKeyCredentialsFormRef = useRef<FormRefObject>(null)
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
||||||
|
const { mutateAsync: createBuilder } = useCreateTriggerSubscriptionBuilder()
|
||||||
|
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
|
||||||
|
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || []
|
||||||
|
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || []
|
||||||
|
|
||||||
|
const apiKeyCredentialsSchema = useMemo(() => {
|
||||||
|
const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
|
||||||
|
return rawSchema.map(schema => ({
|
||||||
|
...schema,
|
||||||
|
tooltip: schema.help,
|
||||||
|
}))
|
||||||
|
}, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
|
||||||
|
|
||||||
|
// Log data for manual mode
|
||||||
|
const { data: logData } = useTriggerSubscriptionBuilderLogs(
|
||||||
|
detail?.provider || '',
|
||||||
|
subscriptionBuilder?.id || '',
|
||||||
|
{
|
||||||
|
enabled: createType === SupportedCreationMethods.MANUAL,
|
||||||
|
refetchInterval: 3000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Debounced update for manual properties
|
||||||
|
const debouncedUpdate = useMemo(
|
||||||
|
() => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
|
||||||
|
updateBuilder(
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
subscriptionBuilderId: builderId,
|
||||||
|
properties,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: async (error: unknown) => {
|
||||||
|
const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' })
|
||||||
|
console.error('Failed to update subscription builder:', error)
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: errorMessage,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, 500),
|
||||||
|
[updateBuilder, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize builder
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeBuilder = async () => {
|
||||||
|
isInitializedRef.current = true
|
||||||
|
try {
|
||||||
|
const response = await createBuilder({
|
||||||
|
provider: detail?.provider || '',
|
||||||
|
credential_type: CREDENTIAL_TYPE_MAP[createType],
|
||||||
|
})
|
||||||
|
setSubscriptionBuilder(response.subscription_builder)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('createBuilder error:', error)
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
|
||||||
|
initializeBuilder()
|
||||||
|
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
|
||||||
|
|
||||||
|
// Cleanup debounced function
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedUpdate.cancel()
|
||||||
|
}
|
||||||
|
}, [debouncedUpdate])
|
||||||
|
|
||||||
|
// Update endpoint in form when endpoint changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration)
|
||||||
|
return
|
||||||
|
|
||||||
|
const form = subscriptionFormRef.current.getForm()
|
||||||
|
if (form)
|
||||||
|
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
|
||||||
|
|
||||||
|
const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint)
|
||||||
|
? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })]
|
||||||
|
: []
|
||||||
|
|
||||||
|
subscriptionFormRef.current?.setFields([{
|
||||||
|
name: 'callback_url',
|
||||||
|
warnings,
|
||||||
|
}])
|
||||||
|
}, [subscriptionBuilder?.endpoint, currentStep, t])
|
||||||
|
|
||||||
|
// Handle manual properties change
|
||||||
|
const handleManualPropertiesChange = useCallback(() => {
|
||||||
|
if (!subscriptionBuilder || !detail?.provider)
|
||||||
|
return
|
||||||
|
|
||||||
|
const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false })
|
||||||
|
|| { values: {}, isCheckValidated: true }
|
||||||
|
|
||||||
|
debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values)
|
||||||
|
}, [subscriptionBuilder, detail?.provider, debouncedUpdate])
|
||||||
|
|
||||||
|
// Handle API key credentials change
|
||||||
|
const handleApiKeyCredentialsChange = useCallback(() => {
|
||||||
|
if (!apiKeyCredentialsSchema.length)
|
||||||
|
return
|
||||||
|
apiKeyCredentialsFormRef.current?.setFields([{
|
||||||
|
name: apiKeyCredentialsSchema[0].name,
|
||||||
|
errors: [],
|
||||||
|
}])
|
||||||
|
}, [apiKeyCredentialsSchema])
|
||||||
|
|
||||||
|
// Handle verify
|
||||||
|
const handleVerify = useCallback(() => {
|
||||||
|
// Guard against uninitialized state
|
||||||
|
if (!detail?.provider || !subscriptionBuilder?.id) {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Subscription builder not initialized',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
|
||||||
|
const credentials = apiKeyCredentialsFormValues.values
|
||||||
|
|
||||||
|
if (!Object.keys(credentials).length) {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Please fill in all required credentials',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKeyCredentialsFormRef.current?.setFields([{
|
||||||
|
name: Object.keys(credentials)[0],
|
||||||
|
errors: [],
|
||||||
|
}])
|
||||||
|
|
||||||
|
verifyCredentials(
|
||||||
|
{
|
||||||
|
provider: detail.provider,
|
||||||
|
subscriptionBuilderId: subscriptionBuilder.id,
|
||||||
|
credentials,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
setCurrentStep(ApiKeyStep.Configuration)
|
||||||
|
},
|
||||||
|
onError: async (error: unknown) => {
|
||||||
|
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
|
||||||
|
apiKeyCredentialsFormRef.current?.setFields([{
|
||||||
|
name: Object.keys(credentials)[0],
|
||||||
|
errors: [errorMessage],
|
||||||
|
}])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t])
|
||||||
|
|
||||||
|
// Handle create
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
if (!subscriptionBuilder) {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Subscription builder not found',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
|
||||||
|
if (!subscriptionFormValues?.isCheckValidated)
|
||||||
|
return
|
||||||
|
|
||||||
|
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
|
||||||
|
|
||||||
|
const params: BuildTriggerSubscriptionPayload = {
|
||||||
|
provider: detail?.provider || '',
|
||||||
|
subscriptionBuilderId: subscriptionBuilder.id,
|
||||||
|
name: subscriptionNameValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createType !== SupportedCreationMethods.MANUAL) {
|
||||||
|
if (autoCommonParametersSchema.length > 0) {
|
||||||
|
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
|
||||||
|
if (!autoCommonParametersFormValues?.isCheckValidated)
|
||||||
|
return
|
||||||
|
params.parameters = autoCommonParametersFormValues.values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (manualPropertiesSchema.length > 0) {
|
||||||
|
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
|
||||||
|
if (!manualFormValues?.isCheckValidated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSubscription(
|
||||||
|
params,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('subscription.createSuccess', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
refetch?.()
|
||||||
|
},
|
||||||
|
onError: async (error: unknown) => {
|
||||||
|
const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' })
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: errorMessage,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
subscriptionBuilder,
|
||||||
|
detail?.provider,
|
||||||
|
createType,
|
||||||
|
autoCommonParametersSchema.length,
|
||||||
|
manualPropertiesSchema.length,
|
||||||
|
buildSubscription,
|
||||||
|
onClose,
|
||||||
|
refetch,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Handle confirm (dispatch based on step)
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (currentStep === ApiKeyStep.Verify)
|
||||||
|
handleVerify()
|
||||||
|
else
|
||||||
|
handleCreate()
|
||||||
|
}, [currentStep, handleVerify, handleCreate])
|
||||||
|
|
||||||
|
// Confirm button text
|
||||||
|
const confirmButtonText = useMemo(() => {
|
||||||
|
if (currentStep === ApiKeyStep.Verify) {
|
||||||
|
return isVerifyingCredentials
|
||||||
|
? t('modal.common.verifying', { ns: 'pluginTrigger' })
|
||||||
|
: t('modal.common.verify', { ns: 'pluginTrigger' })
|
||||||
|
}
|
||||||
|
return isBuilding
|
||||||
|
? t('modal.common.creating', { ns: 'pluginTrigger' })
|
||||||
|
: t('modal.common.create', { ns: 'pluginTrigger' })
|
||||||
|
}, [currentStep, isVerifyingCredentials, isBuilding, t])
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
subscriptionBuilder,
|
||||||
|
isVerifyingCredentials,
|
||||||
|
isBuilding,
|
||||||
|
formRefs: {
|
||||||
|
manualPropertiesFormRef,
|
||||||
|
subscriptionFormRef,
|
||||||
|
autoCommonParametersFormRef,
|
||||||
|
apiKeyCredentialsFormRef,
|
||||||
|
},
|
||||||
|
detail,
|
||||||
|
manualPropertiesSchema,
|
||||||
|
autoCommonParametersSchema,
|
||||||
|
apiKeyCredentialsSchema,
|
||||||
|
logData,
|
||||||
|
confirmButtonText,
|
||||||
|
handleVerify,
|
||||||
|
handleCreate,
|
||||||
|
handleConfirm,
|
||||||
|
handleManualPropertiesChange,
|
||||||
|
handleApiKeyCredentialsChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,719 @@
|
|||||||
|
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import {
|
||||||
|
AuthorizationStatusEnum,
|
||||||
|
ClientTypeEnum,
|
||||||
|
getErrorMessage,
|
||||||
|
useOAuthClientState,
|
||||||
|
} from './use-oauth-client-state'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Factory Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
custom_configured: false,
|
||||||
|
custom_enabled: false,
|
||||||
|
system_configured: true,
|
||||||
|
redirect_uri: 'https://example.com/oauth/callback',
|
||||||
|
params: {
|
||||||
|
client_id: 'default-client-id',
|
||||||
|
client_secret: 'default-client-secret',
|
||||||
|
},
|
||||||
|
oauth_client_schema: [
|
||||||
|
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
|
||||||
|
{ name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
|
||||||
|
] as TriggerOAuthConfig['oauth_client_schema'],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder {
|
||||||
|
return {
|
||||||
|
id: 'builder-123',
|
||||||
|
name: 'Test Builder',
|
||||||
|
provider: 'test-provider',
|
||||||
|
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||||
|
credentials: {},
|
||||||
|
endpoint: 'https://example.com/callback',
|
||||||
|
parameters: {},
|
||||||
|
properties: {},
|
||||||
|
workflows_in_use: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Setup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const mockInitiateOAuth = vi.fn()
|
||||||
|
const mockVerifyBuilder = vi.fn()
|
||||||
|
const mockConfigureOAuth = vi.fn()
|
||||||
|
const mockDeleteOAuth = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/service/use-triggers', () => ({
|
||||||
|
useInitiateTriggerOAuth: () => ({
|
||||||
|
mutate: mockInitiateOAuth,
|
||||||
|
}),
|
||||||
|
useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({
|
||||||
|
mutate: mockVerifyBuilder,
|
||||||
|
}),
|
||||||
|
useConfigureTriggerOAuth: () => ({
|
||||||
|
mutate: mockConfigureOAuth,
|
||||||
|
}),
|
||||||
|
useDeleteTriggerOAuth: () => ({
|
||||||
|
mutate: mockDeleteOAuth,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockOpenOAuthPopup = vi.fn()
|
||||||
|
vi.mock('@/hooks/use-oauth', () => ({
|
||||||
|
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockToastNotify = vi.fn()
|
||||||
|
vi.mock('@/app/components/base/toast', () => ({
|
||||||
|
default: {
|
||||||
|
notify: (params: unknown) => mockToastNotify(params),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suites
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('getErrorMessage', () => {
|
||||||
|
it('should extract message from Error instance', () => {
|
||||||
|
const error = new Error('Test error message')
|
||||||
|
expect(getErrorMessage(error, 'fallback')).toBe('Test error message')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract message from object with message property', () => {
|
||||||
|
const error = { message: 'Object error message' }
|
||||||
|
expect(getErrorMessage(error, 'fallback')).toBe('Object error message')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return fallback when error is empty object', () => {
|
||||||
|
expect(getErrorMessage({}, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return fallback when error.message is not a string', () => {
|
||||||
|
expect(getErrorMessage({ message: 123 }, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return fallback when error.message is empty string', () => {
|
||||||
|
expect(getErrorMessage({ message: '' }, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return fallback when error is null', () => {
|
||||||
|
expect(getErrorMessage(null, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return fallback when error is undefined', () => {
|
||||||
|
expect(getErrorMessage(undefined, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return fallback when error is a primitive', () => {
|
||||||
|
expect(getErrorMessage('string error', 'fallback')).toBe('fallback')
|
||||||
|
expect(getErrorMessage(123, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useOAuthClientState', () => {
|
||||||
|
const defaultParams = {
|
||||||
|
oauthConfig: createMockOAuthConfig(),
|
||||||
|
providerName: 'test-provider',
|
||||||
|
onClose: vi.fn(),
|
||||||
|
showOAuthCreateModal: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Initial State', () => {
|
||||||
|
it('should default to Default client type when system_configured is true', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default to Custom client type when system_configured is false', () => {
|
||||||
|
const config = createMockOAuthConfig({ system_configured: false })
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: config,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have undefined authorizationStatus initially', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
expect(result.current.authorizationStatus).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide clientFormRef', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
expect(result.current.clientFormRef).toBeDefined()
|
||||||
|
expect(result.current.clientFormRef.current).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OAuth Client Schema', () => {
|
||||||
|
it('should compute schema with default values from params', () => {
|
||||||
|
const config = createMockOAuthConfig({
|
||||||
|
params: {
|
||||||
|
client_id: 'my-client-id',
|
||||||
|
client_secret: 'my-secret',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: config,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.current.oauthClientSchema).toHaveLength(2)
|
||||||
|
expect(result.current.oauthClientSchema[0].default).toBe('my-client-id')
|
||||||
|
expect(result.current.oauthClientSchema[1].default).toBe('my-secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array when oauth_client_schema is empty', () => {
|
||||||
|
const config = createMockOAuthConfig({
|
||||||
|
oauth_client_schema: [],
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: config,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.current.oauthClientSchema).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array when params is undefined', () => {
|
||||||
|
const config = createMockOAuthConfig({
|
||||||
|
params: undefined as unknown as TriggerOAuthConfig['params'],
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: config,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.current.oauthClientSchema).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve original schema default when param key not found', () => {
|
||||||
|
const config = createMockOAuthConfig({
|
||||||
|
params: {
|
||||||
|
client_id: 'only-client-id',
|
||||||
|
client_secret: '', // empty
|
||||||
|
},
|
||||||
|
oauth_client_schema: [
|
||||||
|
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: {} as unknown, default: 'original-default' },
|
||||||
|
{ name: 'extra_field', type: 'text-input' as unknown, required: false, label: {} as unknown, default: 'extra-default' },
|
||||||
|
] as TriggerOAuthConfig['oauth_client_schema'],
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: config,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// client_id should be overridden
|
||||||
|
expect(result.current.oauthClientSchema[0].default).toBe('only-client-id')
|
||||||
|
// extra_field should keep original default since key not in params
|
||||||
|
expect(result.current.oauthClientSchema[1].default).toBe('extra-default')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Confirm Button Text', () => {
|
||||||
|
it('should show saveAndAuth text by default', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
expect(result.current.confirmButtonText).toBe('plugin.auth.saveAndAuth')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show authorizing text when status is Pending', async () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation(() => {
|
||||||
|
// Don't resolve - stays pending
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.confirmButtonText).toBe('pluginTrigger.modal.common.authorizing')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setClientType', () => {
|
||||||
|
it('should update client type when called', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setClientType(ClientTypeEnum.Custom)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle between client types', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setClientType(ClientTypeEnum.Custom)
|
||||||
|
})
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setClientType(ClientTypeEnum.Default)
|
||||||
|
})
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleRemove', () => {
|
||||||
|
it('should call deleteOAuth with provider name', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleRemove()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDeleteOAuth).toHaveBeenCalledWith(
|
||||||
|
'test-provider',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onClose and show success toast on success', () => {
|
||||||
|
mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess())
|
||||||
|
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
onClose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleRemove()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
message: 'pluginTrigger.modal.oauth.remove.success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error toast with error message on failure', () => {
|
||||||
|
mockDeleteOAuth.mockImplementation((provider, { onError }) => {
|
||||||
|
onError(new Error('Delete failed'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleRemove()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Delete failed',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleSave', () => {
|
||||||
|
it('should call configureOAuth with enabled: false for Default type', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockConfigureOAuth).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'test-provider',
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call configureOAuth with enabled: true for Custom type', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
|
||||||
|
const config = createMockOAuthConfig({ system_configured: false })
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: config,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the form ref
|
||||||
|
const mockFormRef = {
|
||||||
|
getFormValues: () => ({
|
||||||
|
values: { client_id: 'new-id', client_secret: 'new-secret' },
|
||||||
|
isCheckValidated: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
// @ts-expect-error - mocking ref
|
||||||
|
result.current.clientFormRef.current = mockFormRef
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockConfigureOAuth).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show success toast and call onClose when needAuth is false', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
const onClose = vi.fn()
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
onClose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
message: 'pluginTrigger.modal.oauth.save.success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should trigger authorization when needAuth is true', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockInitiateOAuth).toHaveBeenCalledWith(
|
||||||
|
'test-provider',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleAuthorization', () => {
|
||||||
|
it('should set status to Pending and call initiateOAuth', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation(() => {})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending)
|
||||||
|
expect(mockInitiateOAuth).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open OAuth popup on success', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
|
||||||
|
'https://oauth.example.com/authorize',
|
||||||
|
expect.any(Function),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set status to Failed and show error toast on error', () => {
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onError }) => {
|
||||||
|
onError(new Error('OAuth failed'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Failed)
|
||||||
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||||
|
type: 'error',
|
||||||
|
message: 'pluginTrigger.modal.oauth.authorization.authFailed',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onClose and showOAuthCreateModal on callback success', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const showOAuthCreateModal = vi.fn()
|
||||||
|
const builder = createMockSubscriptionBuilder()
|
||||||
|
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: builder,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mockOpenOAuthPopup.mockImplementation((url, callback) => {
|
||||||
|
callback({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
onClose,
|
||||||
|
showOAuthCreateModal,
|
||||||
|
}))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
expect(showOAuthCreateModal).toHaveBeenCalledWith(builder)
|
||||||
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
message: 'pluginTrigger.modal.oauth.authorization.authSuccess',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call callbacks when OAuth callback returns falsy', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const showOAuthCreateModal = vi.fn()
|
||||||
|
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mockOpenOAuthPopup.mockImplementation((url, callback) => {
|
||||||
|
callback(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
onClose,
|
||||||
|
showOAuthCreateModal,
|
||||||
|
}))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
expect(showOAuthCreateModal).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Polling Effect', () => {
|
||||||
|
it('should start polling after authorization starts', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
|
||||||
|
onSuccess({ verified: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Advance timer to trigger first poll
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockVerifyBuilder).toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set status to Success when verified', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
|
||||||
|
onSuccess({ verified: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Success)
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should continue polling on error', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mockVerifyBuilder.mockImplementation((params, { onError }) => {
|
||||||
|
onError(new Error('Verify failed'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockVerifyBuilder).toHaveBeenCalled()
|
||||||
|
// Status should still be Pending
|
||||||
|
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending)
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop polling when verified', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
|
||||||
|
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||||
|
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess({
|
||||||
|
authorization_url: 'https://oauth.example.com/authorize',
|
||||||
|
subscription_builder: createMockSubscriptionBuilder(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
|
||||||
|
onSuccess({ verified: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSave(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// First poll - should verify
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Second poll - should not happen as interval is cleared
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Still only 1 call because polling stopped
|
||||||
|
expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle undefined oauthConfig', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
oauthConfig: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||||
|
expect(result.current.oauthClientSchema).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty providerName', () => {
|
||||||
|
const { result } = renderHook(() => useOAuthClientState({
|
||||||
|
...defaultParams,
|
||||||
|
providerName: '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Enum Exports', () => {
|
||||||
|
it('should export AuthorizationStatusEnum', () => {
|
||||||
|
expect(AuthorizationStatusEnum.Pending).toBe('pending')
|
||||||
|
expect(AuthorizationStatusEnum.Success).toBe('success')
|
||||||
|
expect(AuthorizationStatusEnum.Failed).toBe('failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export ClientTypeEnum', () => {
|
||||||
|
expect(ClientTypeEnum.Default).toBe('default')
|
||||||
|
expect(ClientTypeEnum.Custom).toBe('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||||
|
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Toast from '@/app/components/base/toast'
|
||||||
|
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||||
|
import {
|
||||||
|
useConfigureTriggerOAuth,
|
||||||
|
useDeleteTriggerOAuth,
|
||||||
|
useInitiateTriggerOAuth,
|
||||||
|
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
||||||
|
} from '@/service/use-triggers'
|
||||||
|
|
||||||
|
export enum AuthorizationStatusEnum {
|
||||||
|
Pending = 'pending',
|
||||||
|
Success = 'success',
|
||||||
|
Failed = 'failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ClientTypeEnum {
|
||||||
|
Default = 'default',
|
||||||
|
Custom = 'custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
// Extract error message from various error formats
|
||||||
|
export const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
|
if (error instanceof Error && error.message)
|
||||||
|
return error.message
|
||||||
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
|
const message = (error as { message?: string }).message
|
||||||
|
if (typeof message === 'string' && message)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseOAuthClientStateParams = {
|
||||||
|
oauthConfig?: TriggerOAuthConfig
|
||||||
|
providerName: string
|
||||||
|
onClose: () => void
|
||||||
|
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseOAuthClientStateReturn = {
|
||||||
|
// State
|
||||||
|
clientType: ClientTypeEnum
|
||||||
|
setClientType: (type: ClientTypeEnum) => void
|
||||||
|
authorizationStatus: AuthorizationStatusEnum | undefined
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
clientFormRef: React.RefObject<FormRefObject | null>
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
oauthClientSchema: TriggerOAuthConfig['oauth_client_schema']
|
||||||
|
confirmButtonText: string
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleAuthorization: () => void
|
||||||
|
handleRemove: () => void
|
||||||
|
handleSave: (needAuth: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOAuthClientState = ({
|
||||||
|
oauthConfig,
|
||||||
|
providerName,
|
||||||
|
onClose,
|
||||||
|
showOAuthCreateModal,
|
||||||
|
}: UseOAuthClientStateParams): UseOAuthClientStateReturn => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
|
||||||
|
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
|
||||||
|
const [clientType, setClientType] = useState<ClientTypeEnum>(
|
||||||
|
oauthConfig?.system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
const clientFormRef = useRef<FormRefObject>(null)
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
||||||
|
const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
||||||
|
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
|
||||||
|
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
|
||||||
|
|
||||||
|
// Compute OAuth client schema with default values
|
||||||
|
const oauthClientSchema = useMemo(() => {
|
||||||
|
const { oauth_client_schema, params } = oauthConfig || {}
|
||||||
|
if (!oauth_client_schema?.length || !params)
|
||||||
|
return []
|
||||||
|
|
||||||
|
const paramKeys = Object.keys(params)
|
||||||
|
return oauth_client_schema.map(schema => ({
|
||||||
|
...schema,
|
||||||
|
default: paramKeys.includes(schema.name) ? params[schema.name] : schema.default,
|
||||||
|
}))
|
||||||
|
}, [oauthConfig])
|
||||||
|
|
||||||
|
// Compute confirm button text based on authorization status
|
||||||
|
const confirmButtonText = useMemo(() => {
|
||||||
|
if (authorizationStatus === AuthorizationStatusEnum.Pending)
|
||||||
|
return t('modal.common.authorizing', { ns: 'pluginTrigger' })
|
||||||
|
if (authorizationStatus === AuthorizationStatusEnum.Success)
|
||||||
|
return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' })
|
||||||
|
return t('auth.saveAndAuth', { ns: 'plugin' })
|
||||||
|
}, [authorizationStatus, t])
|
||||||
|
|
||||||
|
// Authorization handler
|
||||||
|
const handleAuthorization = useCallback(() => {
|
||||||
|
setAuthorizationStatus(AuthorizationStatusEnum.Pending)
|
||||||
|
initiateOAuth(providerName, {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setSubscriptionBuilder(response.subscription_builder)
|
||||||
|
openOAuthPopup(response.authorization_url, (callbackData) => {
|
||||||
|
if (!callbackData)
|
||||||
|
return
|
||||||
|
Toast.notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
showOAuthCreateModal(response.subscription_builder)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setAuthorizationStatus(AuthorizationStatusEnum.Failed)
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [providerName, initiateOAuth, onClose, showOAuthCreateModal, t])
|
||||||
|
|
||||||
|
// Remove handler
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
deleteOAuth(providerName, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose()
|
||||||
|
Toast.notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [providerName, deleteOAuth, onClose, t])
|
||||||
|
|
||||||
|
// Save handler
|
||||||
|
const handleSave = useCallback((needAuth: boolean) => {
|
||||||
|
const isCustom = clientType === ClientTypeEnum.Custom
|
||||||
|
const params: ConfigureTriggerOAuthPayload = {
|
||||||
|
provider: providerName,
|
||||||
|
enabled: isCustom,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCustom && oauthClientSchema?.length) {
|
||||||
|
const clientFormValues = clientFormRef.current?.getFormValues({}) as {
|
||||||
|
values: TriggerOAuthClientParams
|
||||||
|
isCheckValidated: boolean
|
||||||
|
} | undefined
|
||||||
|
// Handle missing ref or form values
|
||||||
|
if (!clientFormValues || !clientFormValues.isCheckValidated)
|
||||||
|
return
|
||||||
|
const clientParams = { ...clientFormValues.values }
|
||||||
|
// Preserve hidden values if unchanged
|
||||||
|
if (clientParams.client_id === oauthConfig?.params.client_id)
|
||||||
|
clientParams.client_id = '[__HIDDEN__]'
|
||||||
|
if (clientParams.client_secret === oauthConfig?.params.client_secret)
|
||||||
|
clientParams.client_secret = '[__HIDDEN__]'
|
||||||
|
params.client_params = clientParams
|
||||||
|
}
|
||||||
|
|
||||||
|
configureOAuth(params, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (needAuth) {
|
||||||
|
handleAuthorization()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
Toast.notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onClose, t])
|
||||||
|
|
||||||
|
// Polling effect for authorization verification
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldPoll = providerName
|
||||||
|
&& subscriptionBuilder
|
||||||
|
&& authorizationStatus === AuthorizationStatusEnum.Pending
|
||||||
|
|
||||||
|
if (!shouldPoll)
|
||||||
|
return
|
||||||
|
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
verifyBuilder(
|
||||||
|
{
|
||||||
|
provider: providerName,
|
||||||
|
subscriptionBuilderId: subscriptionBuilder.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.verified) {
|
||||||
|
setAuthorizationStatus(AuthorizationStatusEnum.Success)
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// Continue polling on error - auth might still be in progress
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
return () => clearInterval(pollInterval)
|
||||||
|
}, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName])
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientType,
|
||||||
|
setClientType,
|
||||||
|
authorizationStatus,
|
||||||
|
clientFormRef,
|
||||||
|
oauthClientSchema,
|
||||||
|
confirmButtonText,
|
||||||
|
handleAuthorization,
|
||||||
|
handleRemove,
|
||||||
|
handleSave,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,9 +6,6 @@ import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
|||||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||||
import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index'
|
import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index'
|
||||||
|
|
||||||
// ==================== Mock Setup ====================
|
|
||||||
|
|
||||||
// Mock shared state for portal
|
|
||||||
let mockPortalOpenState = false
|
let mockPortalOpenState = false
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||||
@ -36,21 +33,18 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock Toast
|
|
||||||
vi.mock('@/app/components/base/toast', () => ({
|
vi.mock('@/app/components/base/toast', () => ({
|
||||||
default: {
|
default: {
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock zustand store
|
|
||||||
let mockStoreDetail: SimpleDetail | undefined
|
let mockStoreDetail: SimpleDetail | undefined
|
||||||
vi.mock('../../store', () => ({
|
vi.mock('../../store', () => ({
|
||||||
usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
|
usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
|
||||||
selector({ detail: mockStoreDetail }),
|
selector({ detail: mockStoreDetail }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock subscription list hook
|
|
||||||
const mockSubscriptions: TriggerSubscription[] = []
|
const mockSubscriptions: TriggerSubscription[] = []
|
||||||
const mockRefetch = vi.fn()
|
const mockRefetch = vi.fn()
|
||||||
vi.mock('../use-subscription-list', () => ({
|
vi.mock('../use-subscription-list', () => ({
|
||||||
@ -60,7 +54,6 @@ vi.mock('../use-subscription-list', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock trigger service hooks
|
|
||||||
let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined }
|
let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined }
|
||||||
let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() }
|
let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() }
|
||||||
const mockInitiateOAuth = vi.fn()
|
const mockInitiateOAuth = vi.fn()
|
||||||
@ -73,14 +66,12 @@ vi.mock('@/service/use-triggers', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock OAuth popup
|
|
||||||
vi.mock('@/hooks/use-oauth', () => ({
|
vi.mock('@/hooks/use-oauth', () => ({
|
||||||
openOAuthPopup: vi.fn((url: string, callback: (data?: unknown) => void) => {
|
openOAuthPopup: vi.fn((url: string, callback: (data?: unknown) => void) => {
|
||||||
callback({ success: true, subscriptionId: 'test-subscription' })
|
callback({ success: true, subscriptionId: 'test-subscription' })
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock child modals
|
|
||||||
vi.mock('./common-modal', () => ({
|
vi.mock('./common-modal', () => ({
|
||||||
CommonCreateModal: ({ createType, onClose, builder }: {
|
CommonCreateModal: ({ createType, onClose, builder }: {
|
||||||
createType: SupportedCreationMethods
|
createType: SupportedCreationMethods
|
||||||
@ -128,7 +119,6 @@ vi.mock('./oauth-client', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock CustomSelect
|
|
||||||
vi.mock('@/app/components/base/select/custom', () => ({
|
vi.mock('@/app/components/base/select/custom', () => ({
|
||||||
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
|
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
|
||||||
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
||||||
@ -160,11 +150,6 @@ vi.mock('@/app/components/base/select/custom', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ==================== Test Utilities ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a TriggerProviderApiEntity with defaults
|
|
||||||
*/
|
|
||||||
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
|
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
|
||||||
author: 'test-author',
|
author: 'test-author',
|
||||||
name: 'test-provider',
|
name: 'test-provider',
|
||||||
@ -179,9 +164,6 @@ const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}):
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a TriggerOAuthConfig with defaults
|
|
||||||
*/
|
|
||||||
const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig => ({
|
const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig => ({
|
||||||
configured: false,
|
configured: false,
|
||||||
custom_configured: false,
|
custom_configured: false,
|
||||||
@ -196,9 +178,6 @@ const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): Trigger
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a SimpleDetail with defaults
|
|
||||||
*/
|
|
||||||
const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
|
const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
|
||||||
plugin_id: 'test-plugin',
|
plugin_id: 'test-plugin',
|
||||||
name: 'Test Plugin',
|
name: 'Test Plugin',
|
||||||
@ -209,9 +188,6 @@ const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a TriggerSubscription with defaults
|
|
||||||
*/
|
|
||||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||||
id: 'test-subscription',
|
id: 'test-subscription',
|
||||||
name: 'Test Subscription',
|
name: 'Test Subscription',
|
||||||
@ -225,16 +201,10 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create default props
|
|
||||||
*/
|
|
||||||
const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}) => ({
|
const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}) => ({
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to set up mock data for testing
|
|
||||||
*/
|
|
||||||
const setupMocks = (config: {
|
const setupMocks = (config: {
|
||||||
providerInfo?: TriggerProviderApiEntity
|
providerInfo?: TriggerProviderApiEntity
|
||||||
oauthConfig?: TriggerOAuthConfig
|
oauthConfig?: TriggerOAuthConfig
|
||||||
@ -249,8 +219,6 @@ const setupMocks = (config: {
|
|||||||
mockSubscriptions.push(...config.subscriptions)
|
mockSubscriptions.push(...config.subscriptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Tests ====================
|
|
||||||
|
|
||||||
describe('CreateSubscriptionButton', () => {
|
describe('CreateSubscriptionButton', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -258,7 +226,6 @@ describe('CreateSubscriptionButton', () => {
|
|||||||
setupMocks()
|
setupMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Rendering Tests ====================
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render null when supportedMethods is empty', () => {
|
it('should render null when supportedMethods is empty', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@ -322,7 +289,6 @@ describe('CreateSubscriptionButton', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Props Testing ====================
|
|
||||||
describe('Props', () => {
|
describe('Props', () => {
|
||||||
it('should apply default buttonType as FULL_BUTTON', () => {
|
it('should apply default buttonType as FULL_BUTTON', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@ -355,7 +321,6 @@ describe('CreateSubscriptionButton', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== State Management ====================
|
|
||||||
describe('State Management', () => {
|
describe('State Management', () => {
|
||||||
it('should show CommonCreateModal when selectedCreateInfo is set', async () => {
|
it('should show CommonCreateModal when selectedCreateInfo is set', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@ -474,7 +439,6 @@ describe('CreateSubscriptionButton', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Memoization Logic ====================
|
|
||||||
describe('Memoization - buttonTextMap', () => {
|
describe('Memoization - buttonTextMap', () => {
|
||||||
it('should display correct button text for OAUTH method', () => {
|
it('should display correct button text for OAUTH method', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { Option } from '@/app/components/base/select/custom'
|
|||||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
|
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
|
||||||
import { useBoolean } from 'ahooks'
|
import { useBoolean } from 'ahooks'
|
||||||
import { useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
|
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
|
||||||
import Badge from '@/app/components/base/badge'
|
import Badge from '@/app/components/base/badge'
|
||||||
@ -18,11 +18,7 @@ import { usePluginStore } from '../../store'
|
|||||||
import { useSubscriptionList } from '../use-subscription-list'
|
import { useSubscriptionList } from '../use-subscription-list'
|
||||||
import { CommonCreateModal } from './common-modal'
|
import { CommonCreateModal } from './common-modal'
|
||||||
import { OAuthClientSettingsModal } from './oauth-client'
|
import { OAuthClientSettingsModal } from './oauth-client'
|
||||||
|
import { CreateButtonType, DEFAULT_METHOD } from './types'
|
||||||
export enum CreateButtonType {
|
|
||||||
FULL_BUTTON = 'full-button',
|
|
||||||
ICON_BUTTON = 'icon-button',
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -32,8 +28,6 @@ type Props = {
|
|||||||
|
|
||||||
const MAX_COUNT = 10
|
const MAX_COUNT = 10
|
||||||
|
|
||||||
export const DEFAULT_METHOD = 'default'
|
|
||||||
|
|
||||||
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
|
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { subscriptions } = useSubscriptionList()
|
const { subscriptions } = useSubscriptionList()
|
||||||
@ -43,7 +37,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
|||||||
const detail = usePluginStore(state => state.detail)
|
const detail = usePluginStore(state => state.detail)
|
||||||
|
|
||||||
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
|
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
|
||||||
const supportedMethods = providerInfo?.supported_creation_methods || []
|
const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods])
|
||||||
const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH))
|
const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH))
|
||||||
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
||||||
|
|
||||||
@ -63,11 +57,11 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
|||||||
}
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const onClickClientSettings = (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
|
const onClickClientSettings = useCallback((e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
showClientSettingsModal()
|
showClientSettingsModal()
|
||||||
}
|
}, [showClientSettingsModal])
|
||||||
|
|
||||||
const allOptions = useMemo(() => {
|
const allOptions = useMemo(() => {
|
||||||
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
|
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
|
||||||
@ -104,7 +98,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
|||||||
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
|
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [t, oauthConfig, supportedMethods, methodType])
|
}, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings])
|
||||||
|
|
||||||
const onChooseCreateType = async (type: SupportedCreationMethods) => {
|
const onChooseCreateType = async (type: SupportedCreationMethods) => {
|
||||||
if (type === SupportedCreationMethods.OAUTH) {
|
if (type === SupportedCreationMethods.OAUTH) {
|
||||||
@ -160,7 +154,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
|||||||
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
||||||
options={allOptions.filter(option => option.show)}
|
options={allOptions.filter(option => option.show)}
|
||||||
value={methodType}
|
value={methodType}
|
||||||
onChange={value => onChooseCreateType(value as any)}
|
onChange={value => onChooseCreateType(value as SupportedCreationMethods)}
|
||||||
containerProps={{
|
containerProps={{
|
||||||
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
|
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
|
||||||
placement: 'bottom-start',
|
placement: 'bottom-start',
|
||||||
@ -254,3 +248,5 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { CreateButtonType, DEFAULT_METHOD } from './types'
|
||||||
|
|||||||
@ -3,24 +3,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||||
|
|
||||||
// Import after mocks
|
|
||||||
import { OAuthClientSettingsModal } from './oauth-client'
|
import { OAuthClientSettingsModal } from './oauth-client'
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Type Definitions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type PluginDetail = {
|
type PluginDetail = {
|
||||||
plugin_id: string
|
plugin_id: string
|
||||||
provider: string
|
provider: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Factory Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
|
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
|
||||||
return {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
@ -64,18 +54,12 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Setup
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Mock plugin store
|
|
||||||
const mockPluginDetail = createMockPluginDetail()
|
const mockPluginDetail = createMockPluginDetail()
|
||||||
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
||||||
vi.mock('../../store', () => ({
|
vi.mock('../../store', () => ({
|
||||||
usePluginStore: () => mockUsePluginStore(),
|
usePluginStore: () => mockUsePluginStore(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock service hooks
|
|
||||||
const mockInitiateOAuth = vi.fn()
|
const mockInitiateOAuth = vi.fn()
|
||||||
const mockVerifyBuilder = vi.fn()
|
const mockVerifyBuilder = vi.fn()
|
||||||
const mockConfigureOAuth = vi.fn()
|
const mockConfigureOAuth = vi.fn()
|
||||||
@ -96,13 +80,11 @@ vi.mock('@/service/use-triggers', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock OAuth popup
|
|
||||||
const mockOpenOAuthPopup = vi.fn()
|
const mockOpenOAuthPopup = vi.fn()
|
||||||
vi.mock('@/hooks/use-oauth', () => ({
|
vi.mock('@/hooks/use-oauth', () => ({
|
||||||
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
|
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock toast
|
|
||||||
const mockToastNotify = vi.fn()
|
const mockToastNotify = vi.fn()
|
||||||
vi.mock('@/app/components/base/toast', () => ({
|
vi.mock('@/app/components/base/toast', () => ({
|
||||||
default: {
|
default: {
|
||||||
@ -110,7 +92,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock clipboard API
|
|
||||||
const mockClipboardWriteText = vi.fn()
|
const mockClipboardWriteText = vi.fn()
|
||||||
Object.assign(navigator, {
|
Object.assign(navigator, {
|
||||||
clipboard: {
|
clipboard: {
|
||||||
@ -118,7 +99,6 @@ Object.assign(navigator, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock Modal component
|
|
||||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||||
default: ({
|
default: ({
|
||||||
children,
|
children,
|
||||||
@ -161,24 +141,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock Button component
|
|
||||||
vi.mock('@/app/components/base/button', () => ({
|
|
||||||
default: ({ children, onClick, variant, className }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
variant?: string
|
|
||||||
className?: string
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
data-testid={`button-${variant || 'default'}`}
|
|
||||||
onClick={onClick}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
// Configurable form mock values
|
|
||||||
let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = {
|
let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = {
|
||||||
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
|
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
|
||||||
isCheckValidated: true,
|
isCheckValidated: true,
|
||||||
@ -210,29 +172,6 @@ vi.mock('@/app/components/base/form/components/base', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock OptionCard component
|
|
||||||
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
|
|
||||||
default: ({ title, onSelect, selected, className }: {
|
|
||||||
title: string
|
|
||||||
onSelect: () => void
|
|
||||||
selected: boolean
|
|
||||||
className?: string
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
data-testid={`option-card-${title}`}
|
|
||||||
onClick={onSelect}
|
|
||||||
className={`${className} ${selected ? 'selected' : ''}`}
|
|
||||||
data-selected={selected}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Test Suites
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
describe('OAuthClientSettingsModal', () => {
|
describe('OAuthClientSettingsModal', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
oauthConfig: createMockOAuthConfig(),
|
oauthConfig: createMockOAuthConfig(),
|
||||||
@ -244,7 +183,6 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockUsePluginStore.mockReturnValue(mockPluginDetail)
|
mockUsePluginStore.mockReturnValue(mockPluginDetail)
|
||||||
mockClipboardWriteText.mockResolvedValue(undefined)
|
mockClipboardWriteText.mockResolvedValue(undefined)
|
||||||
// Reset form values to default
|
|
||||||
setMockFormValues({
|
setMockFormValues({
|
||||||
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
|
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
|
||||||
isCheckValidated: true,
|
isCheckValidated: true,
|
||||||
@ -265,8 +203,8 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
it('should render client type selector when system_configured is true', () => {
|
it('should render client type selector when system_configured is true', () => {
|
||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
|
expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
|
expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render client type selector when system_configured is false', () => {
|
it('should not render client type selector when system_configured is false', () => {
|
||||||
@ -276,7 +214,7 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
|
|
||||||
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />)
|
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />)
|
||||||
|
|
||||||
expect(screen.queryByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
|
expect(screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render redirect URI info when custom client type is selected', () => {
|
it('should render redirect URI info when custom client type is selected', () => {
|
||||||
@ -319,29 +257,29 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
it('should default to Default client type when system_configured is true', () => {
|
it('should default to Default client type when system_configured is true', () => {
|
||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
|
const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div')
|
||||||
expect(defaultCard).toHaveAttribute('data-selected', 'true')
|
expect(defaultCard).toHaveClass('border-[1.5px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should switch to Custom client type when Custom card is clicked', () => {
|
it('should switch to Custom client type when Custom card is clicked', () => {
|
||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
|
||||||
fireEvent.click(customCard)
|
fireEvent.click(customCard!)
|
||||||
|
|
||||||
expect(customCard).toHaveAttribute('data-selected', 'true')
|
expect(customCard).toHaveClass('border-[1.5px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should switch back to Default client type when Default card is clicked', () => {
|
it('should switch back to Default client type when Default card is clicked', () => {
|
||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
|
||||||
fireEvent.click(customCard)
|
fireEvent.click(customCard!)
|
||||||
|
|
||||||
const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
|
const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div')
|
||||||
fireEvent.click(defaultCard)
|
fireEvent.click(defaultCard!)
|
||||||
|
|
||||||
expect(defaultCard).toHaveAttribute('data-selected', 'true')
|
expect(defaultCard).toHaveClass('border-[1.5px]')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -852,8 +790,8 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
// Switch to custom
|
// Switch to custom
|
||||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
|
||||||
fireEvent.click(customCard)
|
fireEvent.click(customCard!)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
@ -1054,7 +992,7 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
// Switch to custom type
|
// Switch to custom type
|
||||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!
|
||||||
fireEvent.click(customCard)
|
fireEvent.click(customCard)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
@ -1077,7 +1015,7 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
// Switch to custom type
|
// Switch to custom type
|
||||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
@ -1104,7 +1042,7 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
// Switch to custom type
|
// Switch to custom type
|
||||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
@ -1131,7 +1069,7 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
// Switch to custom type
|
// Switch to custom type
|
||||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
@ -1158,7 +1096,7 @@ describe('OAuthClientSettingsModal', () => {
|
|||||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||||
|
|
||||||
// Switch to custom type
|
// Switch to custom type
|
||||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FormRefObject } from '@/app/components/base/form/types'
|
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||||
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers'
|
|
||||||
import {
|
import {
|
||||||
RiClipboardLine,
|
RiClipboardLine,
|
||||||
RiInformation2Fill,
|
RiInformation2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import * as React from 'react'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import { BaseForm } from '@/app/components/base/form/components/base'
|
import { BaseForm } from '@/app/components/base/form/components/base'
|
||||||
import Modal from '@/app/components/base/modal/modal'
|
import Modal from '@/app/components/base/modal/modal'
|
||||||
import Toast from '@/app/components/base/toast'
|
import Toast from '@/app/components/base/toast'
|
||||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
|
||||||
import {
|
|
||||||
useConfigureTriggerOAuth,
|
|
||||||
useDeleteTriggerOAuth,
|
|
||||||
useInitiateTriggerOAuth,
|
|
||||||
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
|
||||||
} from '@/service/use-triggers'
|
|
||||||
import { usePluginStore } from '../../store'
|
import { usePluginStore } from '../../store'
|
||||||
|
import { ClientTypeEnum, useOAuthClientState } from './hooks/use-oauth-client-state'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
oauthConfig?: TriggerOAuthConfig
|
oauthConfig?: TriggerOAuthConfig
|
||||||
@ -29,169 +19,38 @@ type Props = {
|
|||||||
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
|
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthorizationStatusEnum {
|
const CLIENT_TYPE_OPTIONS = [ClientTypeEnum.Default, ClientTypeEnum.Custom] as const
|
||||||
Pending = 'pending',
|
|
||||||
Success = 'success',
|
|
||||||
Failed = 'failed',
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ClientTypeEnum {
|
|
||||||
Default = 'default',
|
|
||||||
Custom = 'custom',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => {
|
export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const detail = usePluginStore(state => state.detail)
|
const detail = usePluginStore(state => state.detail)
|
||||||
const { system_configured, params, oauth_client_schema } = oauthConfig || {}
|
|
||||||
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
|
|
||||||
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
|
|
||||||
|
|
||||||
const [clientType, setClientType] = useState<ClientTypeEnum>(system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom)
|
|
||||||
|
|
||||||
const clientFormRef = React.useRef<FormRefObject>(null)
|
|
||||||
|
|
||||||
const oauthClientSchema = useMemo(() => {
|
|
||||||
if (oauth_client_schema && oauth_client_schema.length > 0 && params) {
|
|
||||||
const oauthConfigPramaKeys = Object.keys(params || {})
|
|
||||||
for (const schema of oauth_client_schema) {
|
|
||||||
if (oauthConfigPramaKeys.includes(schema.name))
|
|
||||||
schema.default = params?.[schema.name]
|
|
||||||
}
|
|
||||||
return oauth_client_schema
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}, [oauth_client_schema, params])
|
|
||||||
|
|
||||||
const providerName = detail?.provider || ''
|
const providerName = detail?.provider || ''
|
||||||
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
|
||||||
const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
|
||||||
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
|
|
||||||
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
|
|
||||||
|
|
||||||
const confirmButtonText = useMemo(() => {
|
const {
|
||||||
if (authorizationStatus === AuthorizationStatusEnum.Pending)
|
clientType,
|
||||||
return t('modal.common.authorizing', { ns: 'pluginTrigger' })
|
setClientType,
|
||||||
if (authorizationStatus === AuthorizationStatusEnum.Success)
|
clientFormRef,
|
||||||
return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' })
|
oauthClientSchema,
|
||||||
return t('auth.saveAndAuth', { ns: 'plugin' })
|
confirmButtonText,
|
||||||
}, [authorizationStatus, t])
|
handleRemove,
|
||||||
|
handleSave,
|
||||||
|
} = useOAuthClientState({
|
||||||
|
oauthConfig,
|
||||||
|
providerName,
|
||||||
|
onClose,
|
||||||
|
showOAuthCreateModal,
|
||||||
|
})
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
const isCustomClient = clientType === ClientTypeEnum.Custom
|
||||||
if (error instanceof Error && error.message)
|
const showRemoveButton = oauthConfig?.custom_enabled && oauthConfig?.params && isCustomClient
|
||||||
return error.message
|
const showRedirectInfo = isCustomClient && oauthConfig?.redirect_uri
|
||||||
if (typeof error === 'object' && error && 'message' in error) {
|
const showClientForm = isCustomClient && oauthClientSchema.length > 0
|
||||||
const message = (error as { message?: string }).message
|
|
||||||
if (typeof message === 'string' && message)
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAuthorization = () => {
|
const handleCopyRedirectUri = () => {
|
||||||
setAuthorizationStatus(AuthorizationStatusEnum.Pending)
|
navigator.clipboard.writeText(oauthConfig?.redirect_uri || '')
|
||||||
initiateOAuth(providerName, {
|
Toast.notify({
|
||||||
onSuccess: (response) => {
|
type: 'success',
|
||||||
setSubscriptionBuilder(response.subscription_builder)
|
message: t('actionMsg.copySuccessfully', { ns: 'common' }),
|
||||||
openOAuthPopup(response.authorization_url, (callbackData) => {
|
|
||||||
if (callbackData) {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
onClose()
|
|
||||||
showOAuthCreateModal(response.subscription_builder)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setAuthorizationStatus(AuthorizationStatusEnum.Failed)
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending) {
|
|
||||||
const pollInterval = setInterval(() => {
|
|
||||||
verifyBuilder(
|
|
||||||
{
|
|
||||||
provider: providerName,
|
|
||||||
subscriptionBuilderId: subscriptionBuilder.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: (response) => {
|
|
||||||
if (response.verified) {
|
|
||||||
setAuthorizationStatus(AuthorizationStatusEnum.Success)
|
|
||||||
clearInterval(pollInterval)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
// Continue polling - auth might still be in progress
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
return () => clearInterval(pollInterval)
|
|
||||||
}
|
|
||||||
}, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName, t])
|
|
||||||
|
|
||||||
const handleRemove = () => {
|
|
||||||
deleteOAuth(providerName, {
|
|
||||||
onSuccess: () => {
|
|
||||||
onClose()
|
|
||||||
Toast.notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = (needAuth: boolean) => {
|
|
||||||
const isCustom = clientType === ClientTypeEnum.Custom
|
|
||||||
const params: ConfigureTriggerOAuthPayload = {
|
|
||||||
provider: providerName,
|
|
||||||
enabled: isCustom,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCustom) {
|
|
||||||
const clientFormValues = clientFormRef.current?.getFormValues({}) as { values: TriggerOAuthClientParams, isCheckValidated: boolean }
|
|
||||||
if (!clientFormValues.isCheckValidated)
|
|
||||||
return
|
|
||||||
const clientParams = clientFormValues.values
|
|
||||||
if (clientParams.client_id === oauthConfig?.params.client_id)
|
|
||||||
clientParams.client_id = '[__HIDDEN__]'
|
|
||||||
|
|
||||||
if (clientParams.client_secret === oauthConfig?.params.client_secret)
|
|
||||||
clientParams.client_secret = '[__HIDDEN__]'
|
|
||||||
|
|
||||||
params.client_params = clientParams
|
|
||||||
}
|
|
||||||
|
|
||||||
configureOAuth(params, {
|
|
||||||
onSuccess: () => {
|
|
||||||
if (needAuth) {
|
|
||||||
handleAuthorization()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onClose()
|
|
||||||
Toast.notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,25 +67,25 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onCancel={() => handleSave(false)}
|
onCancel={() => handleSave(false)}
|
||||||
onConfirm={() => handleSave(true)}
|
onConfirm={() => handleSave(true)}
|
||||||
footerSlot={
|
footerSlot={showRemoveButton && (
|
||||||
oauthConfig?.custom_enabled && oauthConfig?.params && clientType === ClientTypeEnum.Custom && (
|
<div className="grow">
|
||||||
<div className="grow">
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="secondary"
|
className="text-components-button-destructive-secondary-text"
|
||||||
className="text-components-button-destructive-secondary-text"
|
onClick={handleRemove}
|
||||||
// disabled={disabled || doingAction || !editValues}
|
>
|
||||||
onClick={handleRemove}
|
{t('operation.remove', { ns: 'common' })}
|
||||||
>
|
</Button>
|
||||||
{t('operation.remove', { ns: 'common' })}
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="system-sm-medium mb-2 text-text-secondary">{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}</div>
|
<div className="system-sm-medium mb-2 text-text-secondary">
|
||||||
|
{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}
|
||||||
|
</div>
|
||||||
|
|
||||||
{oauthConfig?.system_configured && (
|
{oauthConfig?.system_configured && (
|
||||||
<div className="mb-4 flex w-full items-start justify-between gap-2">
|
<div className="mb-4 flex w-full items-start justify-between gap-2">
|
||||||
{[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => (
|
{CLIENT_TYPE_OPTIONS.map(option => (
|
||||||
<OptionCard
|
<OptionCard
|
||||||
key={option}
|
key={option}
|
||||||
title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })}
|
title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })}
|
||||||
@ -237,7 +96,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{clientType === ClientTypeEnum.Custom && oauthConfig?.redirect_uri && (
|
|
||||||
|
{showRedirectInfo && (
|
||||||
<div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
|
<div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
|
||||||
<div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3">
|
<div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3">
|
||||||
<RiInformation2Fill className="h-5 w-5 shrink-0 text-text-accent" />
|
<RiInformation2Fill className="h-5 w-5 shrink-0 text-text-accent" />
|
||||||
@ -247,18 +107,12 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
|||||||
{t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })}
|
{t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="system-sm-medium my-1.5 break-all leading-4">
|
<div className="system-sm-medium my-1.5 break-all leading-4">
|
||||||
{oauthConfig.redirect_uri}
|
{oauthConfig?.redirect_uri}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={handleCopyRedirectUri}
|
||||||
navigator.clipboard.writeText(oauthConfig.redirect_uri)
|
|
||||||
Toast.notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('actionMsg.copySuccessfully', { ns: 'common' }),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RiClipboardLine className="mr-1 h-[14px] w-[14px]" />
|
<RiClipboardLine className="mr-1 h-[14px] w-[14px]" />
|
||||||
{t('operation.copy', { ns: 'common' })}
|
{t('operation.copy', { ns: 'common' })}
|
||||||
@ -266,7 +120,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{clientType === ClientTypeEnum.Custom && oauthClientSchema.length > 0 && (
|
|
||||||
|
{showClientForm && (
|
||||||
<BaseForm
|
<BaseForm
|
||||||
formSchemas={oauthClientSchema}
|
formSchemas={oauthClientSchema}
|
||||||
ref={clientFormRef}
|
ref={clientFormRef}
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
export enum CreateButtonType {
|
||||||
|
FULL_BUTTON = 'full-button',
|
||||||
|
ICON_BUTTON = 'icon-button',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_METHOD = 'default'
|
||||||
@ -2445,11 +2445,6 @@
|
|||||||
"count": 8
|
"count": 8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 8
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -2503,14 +2498,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
|
|
||||||
"react-refresh/only-export-components": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": {
|
"app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user