dify
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import type { FC } from 'react'
|
||||
import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types'
|
||||
import TriggerFormItem from './item'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
nodeId: string
|
||||
schema: CredentialFormSchema[]
|
||||
value: PluginTriggerVarInputs
|
||||
onChange: (value: PluginTriggerVarInputs) => void
|
||||
onOpen?: (index: number) => void
|
||||
inPanel?: boolean
|
||||
currentEvent?: Event
|
||||
currentProvider?: TriggerWithProvider
|
||||
extraParams?: Record<string, any>
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
|
||||
const TriggerForm: FC<Props> = ({
|
||||
readOnly,
|
||||
nodeId,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
inPanel,
|
||||
currentEvent,
|
||||
currentProvider,
|
||||
extraParams,
|
||||
disableVariableInsertion = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{
|
||||
schema.map((schema, index) => (
|
||||
<TriggerFormItem
|
||||
key={index}
|
||||
readOnly={readOnly}
|
||||
nodeId={nodeId}
|
||||
schema={schema}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
inPanel={inPanel}
|
||||
currentEvent={currentEvent}
|
||||
currentProvider={currentProvider}
|
||||
extraParams={extraParams}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TriggerForm
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
RiBracesLine,
|
||||
} from '@remixicon/react'
|
||||
import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types'
|
||||
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
nodeId: string
|
||||
schema: CredentialFormSchema
|
||||
value: PluginTriggerVarInputs
|
||||
onChange: (value: PluginTriggerVarInputs) => void
|
||||
inPanel?: boolean
|
||||
currentEvent?: Event
|
||||
currentProvider?: TriggerWithProvider
|
||||
extraParams?: Record<string, any>
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
|
||||
const TriggerFormItem: FC<Props> = ({
|
||||
readOnly,
|
||||
nodeId,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
inPanel,
|
||||
currentEvent,
|
||||
currentProvider,
|
||||
extraParams,
|
||||
disableVariableInsertion = false,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
const { name, label, type, required, tooltip, input_schema } = schema
|
||||
const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array
|
||||
const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
|
||||
const [isShowSchema, {
|
||||
setTrue: showSchema,
|
||||
setFalse: hideSchema,
|
||||
}] = useBoolean(false)
|
||||
return (
|
||||
<div className='space-y-0.5 py-1'>
|
||||
<div>
|
||||
<div className='flex h-6 items-center'>
|
||||
<div className='system-sm-medium text-text-secondary'>{label[language] || label.en_US}</div>
|
||||
{required && (
|
||||
<div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div>
|
||||
)}
|
||||
{!showDescription && tooltip && (
|
||||
<Tooltip
|
||||
popupContent={<div className='w-[200px]'>
|
||||
{tooltip[language] || tooltip.en_US}
|
||||
</div>}
|
||||
triggerClassName='ml-1 w-4 h-4'
|
||||
asChild={false}
|
||||
/>
|
||||
)}
|
||||
{showSchemaButton && (
|
||||
<>
|
||||
<div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
onClick={showSchema}
|
||||
className='system-xs-regular px-1 text-text-tertiary'
|
||||
>
|
||||
<RiBracesLine className='mr-1 size-3.5' />
|
||||
<span>JSON Schema</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showDescription && tooltip && (
|
||||
<div className='body-xs-regular pb-0.5 text-text-tertiary'>{tooltip[language] || tooltip.en_US}</div>
|
||||
)}
|
||||
</div>
|
||||
<FormInputItem
|
||||
readOnly={readOnly}
|
||||
nodeId={nodeId}
|
||||
schema={schema}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
inPanel={inPanel}
|
||||
currentTool={currentEvent}
|
||||
currentProvider={currentProvider}
|
||||
providerType='trigger'
|
||||
extraParams={extraParams}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
|
||||
{isShowSchema && (
|
||||
<SchemaModal
|
||||
isShow
|
||||
onClose={hideSchema}
|
||||
rootName={name}
|
||||
schema={input_schema!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TriggerFormItem
|
||||
297
dify/web/app/components/workflow/nodes/trigger-plugin/default.ts
Normal file
297
dify/web/app/components/workflow/nodes/trigger-plugin/default.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import type { NodeDefault, Var } from '../../types'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import { genNodeMetaData } from '../../utils'
|
||||
import { VarKindType } from '../_base/types'
|
||||
import { type Field, type StructuredOutput, Type } from '../llm/types'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
|
||||
const normalizeJsonSchemaType = (schema: any): string | undefined => {
|
||||
if (!schema) return undefined
|
||||
const { type, properties, items, oneOf, anyOf, allOf } = schema
|
||||
|
||||
if (Array.isArray(type))
|
||||
return type.find((item: string | null) => item && item !== 'null') || type[0]
|
||||
|
||||
if (typeof type === 'string')
|
||||
return type
|
||||
|
||||
const compositeCandidates = [oneOf, anyOf, allOf]
|
||||
.filter((entry): entry is any[] => Array.isArray(entry))
|
||||
.flat()
|
||||
|
||||
for (const candidate of compositeCandidates) {
|
||||
const normalized = normalizeJsonSchemaType(candidate)
|
||||
if (normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
if (properties)
|
||||
return 'object'
|
||||
|
||||
if (items)
|
||||
return 'array'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const pickItemSchema = (schema: any) => {
|
||||
if (!schema || !schema.items)
|
||||
return undefined
|
||||
return Array.isArray(schema.items) ? schema.items[0] : schema.items
|
||||
}
|
||||
|
||||
const extractSchemaType = (schema: any, _schemaTypeDefinitions?: SchemaTypeDefinition[]): string | undefined => {
|
||||
if (!schema)
|
||||
return undefined
|
||||
|
||||
const schemaTypeFromSchema = schema.schema_type || schema.schemaType
|
||||
if (typeof schemaTypeFromSchema === 'string' && schemaTypeFromSchema.trim().length > 0)
|
||||
return schemaTypeFromSchema
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resolveVarType = (
|
||||
schema: any,
|
||||
schemaTypeDefinitions?: SchemaTypeDefinition[],
|
||||
): { type: VarType; schemaType?: string } => {
|
||||
const schemaType = extractSchemaType(schema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(schema)
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'string':
|
||||
return { type: VarType.string, schemaType }
|
||||
case 'number':
|
||||
return { type: VarType.number, schemaType }
|
||||
case 'integer':
|
||||
return { type: VarType.integer, schemaType }
|
||||
case 'boolean':
|
||||
return { type: VarType.boolean, schemaType }
|
||||
case 'object':
|
||||
return { type: VarType.object, schemaType }
|
||||
case 'array': {
|
||||
const itemSchema = pickItemSchema(schema)
|
||||
if (!itemSchema)
|
||||
return { type: VarType.array, schemaType }
|
||||
|
||||
const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions)
|
||||
const resolvedSchemaType = schemaType || itemSchemaType
|
||||
|
||||
if (itemSchemaType === 'file')
|
||||
return { type: VarType.arrayFile, schemaType: resolvedSchemaType }
|
||||
|
||||
switch (itemType) {
|
||||
case VarType.string:
|
||||
return { type: VarType.arrayString, schemaType: resolvedSchemaType }
|
||||
case VarType.number:
|
||||
case VarType.integer:
|
||||
return { type: VarType.arrayNumber, schemaType: resolvedSchemaType }
|
||||
case VarType.boolean:
|
||||
return { type: VarType.arrayBoolean, schemaType: resolvedSchemaType }
|
||||
case VarType.object:
|
||||
return { type: VarType.arrayObject, schemaType: resolvedSchemaType }
|
||||
case VarType.file:
|
||||
return { type: VarType.arrayFile, schemaType: resolvedSchemaType }
|
||||
default:
|
||||
return { type: VarType.array, schemaType: resolvedSchemaType }
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { type: VarType.any, schemaType }
|
||||
}
|
||||
}
|
||||
|
||||
const toFieldType = (normalizedType: string | undefined, schemaType?: string): Type => {
|
||||
if (schemaType === 'file')
|
||||
return normalizedType === 'array' ? Type.array : Type.file
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return Type.number
|
||||
case 'boolean':
|
||||
return Type.boolean
|
||||
case 'object':
|
||||
return Type.object
|
||||
case 'array':
|
||||
return Type.array
|
||||
case 'string':
|
||||
default:
|
||||
return Type.string
|
||||
}
|
||||
}
|
||||
|
||||
const toArrayItemType = (type: Type): Exclude<Type, Type.array> => {
|
||||
if (type === Type.array)
|
||||
return Type.object
|
||||
return type as Exclude<Type, Type.array>
|
||||
}
|
||||
|
||||
const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[]): Field => {
|
||||
const schemaType = extractSchemaType(schema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(schema)
|
||||
const fieldType = toFieldType(normalizedType, schemaType)
|
||||
|
||||
const field: Field = {
|
||||
type: fieldType,
|
||||
}
|
||||
|
||||
if (schema?.description)
|
||||
field.description = schema.description
|
||||
|
||||
if (schemaType)
|
||||
field.schemaType = schemaType
|
||||
|
||||
if (Array.isArray(schema?.enum))
|
||||
field.enum = schema.enum
|
||||
|
||||
if (fieldType === Type.object) {
|
||||
const properties = schema?.properties || {}
|
||||
field.properties = Object.entries(properties).reduce((acc, [key, value]) => {
|
||||
acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions)
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
|
||||
const required = Array.isArray(schema?.required) ? schema.required.filter(Boolean) : undefined
|
||||
field.required = required && required.length > 0 ? required : undefined
|
||||
field.additionalProperties = false
|
||||
}
|
||||
|
||||
if (fieldType === Type.array) {
|
||||
const itemSchema = pickItemSchema(schema)
|
||||
if (itemSchema) {
|
||||
const itemField = convertJsonSchemaToField(itemSchema, schemaTypeDefinitions)
|
||||
const { type, ...rest } = itemField
|
||||
field.items = {
|
||||
...rest,
|
||||
type: toArrayItemType(type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: SchemaTypeDefinition[]): Var[] => {
|
||||
if (!schema || typeof schema !== 'object')
|
||||
return []
|
||||
|
||||
const properties = schema.properties as Record<string, any> | undefined
|
||||
if (!properties)
|
||||
return []
|
||||
|
||||
return Object.entries(properties).map(([name, propertySchema]) => {
|
||||
const { type, schemaType } = resolveVarType(propertySchema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(propertySchema)
|
||||
|
||||
const varItem: Var = {
|
||||
variable: name,
|
||||
type,
|
||||
des: propertySchema?.description,
|
||||
...(schemaType ? { schemaType } : {}),
|
||||
}
|
||||
|
||||
if (normalizedType === 'object') {
|
||||
const childProperties = propertySchema?.properties
|
||||
? Object.entries(propertySchema.properties).reduce((acc, [key, value]) => {
|
||||
acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions)
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
: {}
|
||||
|
||||
const required = Array.isArray(propertySchema?.required) ? propertySchema.required.filter(Boolean) : undefined
|
||||
|
||||
varItem.children = {
|
||||
schema: {
|
||||
type: Type.object,
|
||||
properties: childProperties,
|
||||
required: required && required.length > 0 ? required : undefined,
|
||||
additionalProperties: false,
|
||||
},
|
||||
} as StructuredOutput
|
||||
}
|
||||
|
||||
return varItem
|
||||
})
|
||||
}
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 1,
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
helpLinkUri: 'plugin-trigger',
|
||||
isStart: true,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<PluginTriggerNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
plugin_id: '',
|
||||
event_name: '',
|
||||
event_parameters: {},
|
||||
// event_type: '',
|
||||
config: {},
|
||||
},
|
||||
checkValid(payload: PluginTriggerNodeType, t: any, moreDataForCheckValid: {
|
||||
triggerInputsSchema?: Array<{
|
||||
variable: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}>
|
||||
isReadyForCheckValid?: boolean
|
||||
} = {}) {
|
||||
let errorMessage = ''
|
||||
|
||||
if (!payload.subscription_id)
|
||||
errorMessage = t('workflow.nodes.triggerPlugin.subscriptionRequired')
|
||||
|
||||
const {
|
||||
triggerInputsSchema = [],
|
||||
isReadyForCheckValid = true,
|
||||
} = moreDataForCheckValid || {}
|
||||
|
||||
if (!errorMessage && isReadyForCheckValid) {
|
||||
triggerInputsSchema.filter(field => field.required).forEach((field) => {
|
||||
if (errorMessage)
|
||||
return
|
||||
|
||||
const rawParam = payload.event_parameters?.[field.variable]
|
||||
?? (payload.config as Record<string, any> | undefined)?.[field.variable]
|
||||
if (!rawParam) {
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label })
|
||||
return
|
||||
}
|
||||
|
||||
const targetParam = typeof rawParam === 'object' && rawParam !== null && 'type' in rawParam
|
||||
? rawParam as { type: VarKindType; value: any }
|
||||
: { type: VarKindType.constant, value: rawParam }
|
||||
|
||||
const { type, value } = targetParam
|
||||
if (type === VarKindType.variable) {
|
||||
if (!value || (Array.isArray(value) && value.length === 0))
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label })
|
||||
}
|
||||
else {
|
||||
if (
|
||||
value === undefined
|
||||
|| value === null
|
||||
|| value === ''
|
||||
|| (Array.isArray(value) && value.length === 0)
|
||||
)
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: !errorMessage,
|
||||
errorMessage,
|
||||
}
|
||||
},
|
||||
getOutputVars(payload, _allPluginInfoList, _ragVars, { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) {
|
||||
const schema = payload.output_schema || {}
|
||||
return buildOutputVars(schema, schemaTypeDefinitions)
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
useBuildTriggerSubscription,
|
||||
useCreateTriggerSubscriptionBuilder,
|
||||
useUpdateTriggerSubscriptionBuilder,
|
||||
useVerifyTriggerSubscriptionBuilder,
|
||||
} from '@/service/use-triggers'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
// Helper function to serialize complex values to strings for backend encryption
|
||||
const serializeFormValues = (values: Record<string, any>): Record<string, string> => {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null || value === undefined)
|
||||
result[key] = ''
|
||||
else if (typeof value === 'object')
|
||||
result[key] = JSON.stringify(value)
|
||||
else
|
||||
result[key] = String(value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export type AuthFlowStep = 'auth' | 'params' | 'complete'
|
||||
|
||||
export type AuthFlowState = {
|
||||
step: AuthFlowStep
|
||||
builderId: string
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type AuthFlowActions = {
|
||||
startAuth: () => Promise<void>
|
||||
verifyAuth: (credentials: Record<string, any>) => Promise<void>
|
||||
completeConfig: (parameters: Record<string, any>, properties?: Record<string, any>, name?: string) => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState & AuthFlowActions => {
|
||||
const [step, setStep] = useState<AuthFlowStep>('auth')
|
||||
const [builderId, setBuilderId] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createBuilder = useCreateTriggerSubscriptionBuilder()
|
||||
const updateBuilder = useUpdateTriggerSubscriptionBuilder()
|
||||
const verifyBuilder = useVerifyTriggerSubscriptionBuilder()
|
||||
const buildSubscription = useBuildTriggerSubscription()
|
||||
|
||||
const startAuth = useCallback(async () => {
|
||||
if (builderId) return // Prevent multiple calls if already started
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await createBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
})
|
||||
setBuilderId(response.subscription_builder.id)
|
||||
setStep('auth')
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Failed to start authentication flow')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider.name, createBuilder, builderId])
|
||||
|
||||
const verifyAuth = useCallback(async (credentials: Record<string, any>) => {
|
||||
if (!builderId) {
|
||||
setError('No builder ID available')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await updateBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
credentials: serializeFormValues(credentials),
|
||||
})
|
||||
|
||||
await verifyBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
})
|
||||
|
||||
setStep('params')
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Authentication verification failed')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider.name, builderId, updateBuilder, verifyBuilder])
|
||||
|
||||
const completeConfig = useCallback(async (
|
||||
parameters: Record<string, any>,
|
||||
properties: Record<string, any> = {},
|
||||
name?: string,
|
||||
) => {
|
||||
if (!builderId) {
|
||||
setError('No builder ID available')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await updateBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
parameters: serializeFormValues(parameters),
|
||||
properties: serializeFormValues(properties),
|
||||
name,
|
||||
})
|
||||
|
||||
await buildSubscription.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
})
|
||||
|
||||
setStep('complete')
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Configuration failed')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider.name, builderId, updateBuilder, buildSubscription])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep('auth')
|
||||
setBuilderId('')
|
||||
setIsLoading(false)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
step,
|
||||
builderId,
|
||||
isLoading,
|
||||
error,
|
||||
startAuth,
|
||||
verifyAuth,
|
||||
completeConfig,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
126
dify/web/app/components/workflow/nodes/trigger-plugin/node.tsx
Normal file
126
dify/web/app/components/workflow/nodes/trigger-plugin/node.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import useConfig from './use-config'
|
||||
|
||||
const formatConfigValue = (rawValue: any): string => {
|
||||
if (rawValue === null || rawValue === undefined)
|
||||
return ''
|
||||
|
||||
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean')
|
||||
return String(rawValue)
|
||||
|
||||
if (Array.isArray(rawValue))
|
||||
return rawValue.join('.')
|
||||
|
||||
if (typeof rawValue === 'object') {
|
||||
const { value } = rawValue as { value?: any }
|
||||
if (value === null || value === undefined)
|
||||
return ''
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
||||
return String(value)
|
||||
if (Array.isArray(value))
|
||||
return value.join('.')
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { subscriptions } = useConfig(id, data)
|
||||
const { config = {}, subscription_id } = data
|
||||
const configKeys = Object.keys(config)
|
||||
const {
|
||||
isChecking,
|
||||
isMissing,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isValidSubscription = useMemo(() => {
|
||||
return subscription_id && subscriptions?.some(sub => sub.id === subscription_id)
|
||||
}, [subscription_id, subscriptions])
|
||||
|
||||
return (
|
||||
<div className="relative mb-1 px-3 py-1">
|
||||
{showInstallButton && (
|
||||
<div className="pointer-events-auto absolute right-3 top-[-32px] z-40">
|
||||
<InstallPluginButton
|
||||
size="small"
|
||||
extraIdentifiers={[
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
].filter(Boolean) as string[]}
|
||||
className="!font-medium !text-text-accent"
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
onSuccess={onInstallSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5" aria-disabled={shouldDim}>
|
||||
{!isValidSubscription && <NodeStatus status={NodeStatusEnum.warning} message={t('pluginTrigger.node.status.warning')} />}
|
||||
{isValidSubscription && configKeys.map((key, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary"
|
||||
>
|
||||
<div
|
||||
title={key}
|
||||
className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary"
|
||||
>
|
||||
{key}
|
||||
</div>
|
||||
<div
|
||||
title={formatConfigValue(config[key])}
|
||||
className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary"
|
||||
>
|
||||
{(() => {
|
||||
const displayValue = formatConfigValue(config[key])
|
||||
if (displayValue.includes('secret'))
|
||||
return '********'
|
||||
return displayValue
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import useConfig from './use-config'
|
||||
import TriggerForm from './components/trigger-form'
|
||||
import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
|
||||
import { Type } from '../llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
readOnly,
|
||||
triggerParameterSchema,
|
||||
triggerParameterValue,
|
||||
setTriggerParameterValue,
|
||||
outputSchema,
|
||||
hasObjectOutput,
|
||||
currentProvider,
|
||||
currentEvent,
|
||||
subscriptionSelected,
|
||||
} = useConfig(id, data)
|
||||
const disableVariableInsertion = data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
// Convert output schema to VarItem format
|
||||
const outputVars = Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ({
|
||||
name,
|
||||
type: schema.type || 'string',
|
||||
description: schema.description || '',
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
{/* Dynamic Parameters Form - Only show when authenticated */}
|
||||
{triggerParameterSchema.length > 0 && subscriptionSelected && (
|
||||
<>
|
||||
<div className='px-4 pb-4'>
|
||||
<TriggerForm
|
||||
readOnly={readOnly}
|
||||
nodeId={id}
|
||||
schema={triggerParameterSchema as any}
|
||||
value={triggerParameterValue}
|
||||
onChange={setTriggerParameterValue}
|
||||
currentProvider={currentProvider}
|
||||
currentEvent={currentEvent}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
</div>
|
||||
<Split />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Output Variables - Always show */}
|
||||
<OutputVars>
|
||||
<>
|
||||
{outputVars.map(varItem => (
|
||||
<VarItem
|
||||
key={varItem.name}
|
||||
name={varItem.name}
|
||||
type={varItem.type}
|
||||
description={varItem.description}
|
||||
isIndent={hasObjectOutput}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => (
|
||||
<div key={name}>
|
||||
{schema.type === 'object' ? (
|
||||
<StructureOutputItem
|
||||
rootClassName='code-sm-semibold text-text-secondary'
|
||||
payload={{
|
||||
schema: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
[name]: schema,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</OutputVars>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
import type { ResourceVarInputs } from '../_base/types'
|
||||
|
||||
export type PluginTriggerNodeType = CommonNodeType & {
|
||||
provider_id: string
|
||||
provider_type: CollectionType
|
||||
provider_name: string
|
||||
event_name: string
|
||||
event_label: string
|
||||
event_parameters: PluginTriggerVarInputs
|
||||
event_configurations: Record<string, any>
|
||||
output_schema: Record<string, any>
|
||||
parameters_schema?: Record<string, any>[]
|
||||
version?: string
|
||||
event_node_version?: string
|
||||
plugin_id?: string
|
||||
config?: Record<string, any>
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
// Use base types directly
|
||||
export { VarKindType as PluginTriggerVarType } from '../_base/types'
|
||||
export type PluginTriggerVarInputs = ResourceVarInputs
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { getTriggerCheckParams } from '@/app/components/workflow/utils/trigger'
|
||||
|
||||
type Params = {
|
||||
id: string
|
||||
payload: PluginTriggerNodeType
|
||||
}
|
||||
|
||||
const useGetDataForCheckMore = ({
|
||||
payload,
|
||||
}: Params) => {
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
const language = useGetLanguage()
|
||||
|
||||
const getData = useCallback(() => {
|
||||
return getTriggerCheckParams(payload, triggerPlugins, language)
|
||||
}, [payload, triggerPlugins, language])
|
||||
|
||||
return {
|
||||
getData,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGetDataForCheckMore
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import type { PluginTriggerVarInputs } from './types'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import {
|
||||
useAllTriggerPlugins,
|
||||
useTriggerSubscriptions,
|
||||
} from '@/service/use-triggers'
|
||||
import {
|
||||
getConfiguredValue,
|
||||
toolParametersToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import { VarKindType } from '../_base/types'
|
||||
|
||||
const normalizeEventParameters = (
|
||||
params: PluginTriggerVarInputs | Record<string, unknown> | null | undefined,
|
||||
{ allowScalars = false }: { allowScalars?: boolean } = {},
|
||||
): PluginTriggerVarInputs => {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params))
|
||||
return {} as PluginTriggerVarInputs
|
||||
|
||||
return Object.entries(params).reduce((acc, [key, entry]) => {
|
||||
if (!entry && entry !== 0 && entry !== false)
|
||||
return acc
|
||||
|
||||
if (
|
||||
typeof entry === 'object'
|
||||
&& !Array.isArray(entry)
|
||||
&& 'type' in entry
|
||||
&& 'value' in entry
|
||||
) {
|
||||
const normalizedEntry = { ...(entry as PluginTriggerVarInputs[string]) }
|
||||
if (normalizedEntry.type === VarKindType.mixed)
|
||||
normalizedEntry.type = VarKindType.constant
|
||||
acc[key] = normalizedEntry
|
||||
return acc
|
||||
}
|
||||
|
||||
if (!allowScalars)
|
||||
return acc
|
||||
|
||||
if (typeof entry === 'string') {
|
||||
acc[key] = {
|
||||
type: VarKindType.constant,
|
||||
value: entry,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
if (typeof entry === 'number' || typeof entry === 'boolean') {
|
||||
acc[key] = {
|
||||
type: VarKindType.constant,
|
||||
value: entry,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
if (Array.isArray(entry) && entry.every(item => typeof item === 'string')) {
|
||||
acc[key] = {
|
||||
type: VarKindType.variable,
|
||||
value: entry,
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as PluginTriggerVarInputs)
|
||||
}
|
||||
|
||||
const useConfig = (id: string, payload: PluginTriggerNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
|
||||
|
||||
const { inputs, setInputs: doSetInputs } = useNodeCrud<PluginTriggerNodeType>(
|
||||
id,
|
||||
payload,
|
||||
)
|
||||
|
||||
const {
|
||||
provider_id,
|
||||
provider_name,
|
||||
event_name,
|
||||
config = {},
|
||||
event_parameters: rawEventParameters = {},
|
||||
subscription_id,
|
||||
} = inputs
|
||||
|
||||
const event_parameters = useMemo(
|
||||
() => normalizeEventParameters(rawEventParameters as PluginTriggerVarInputs),
|
||||
[rawEventParameters],
|
||||
)
|
||||
const legacy_config_parameters = useMemo(
|
||||
() => normalizeEventParameters(config as PluginTriggerVarInputs, { allowScalars: true }),
|
||||
[config],
|
||||
)
|
||||
|
||||
const currentProvider = useMemo<TriggerWithProvider | undefined>(() => {
|
||||
return triggerPlugins.find(
|
||||
provider =>
|
||||
provider.name === provider_name
|
||||
|| provider.id === provider_id
|
||||
|| (provider_id && provider.plugin_id === provider_id),
|
||||
)
|
||||
}, [triggerPlugins, provider_name, provider_id])
|
||||
|
||||
const { data: subscriptions = [] } = useTriggerSubscriptions(provider_id || '')
|
||||
|
||||
const subscriptionSelected = useMemo(() => {
|
||||
return subscriptions?.find(s => s.id === subscription_id)
|
||||
}, [subscriptions, subscription_id])
|
||||
|
||||
const currentEvent = useMemo<Event | undefined>(() => {
|
||||
return currentProvider?.events.find(
|
||||
event => event.name === event_name,
|
||||
)
|
||||
}, [currentProvider, event_name])
|
||||
|
||||
// Dynamic trigger parameters (from specific trigger.parameters)
|
||||
const triggerSpecificParameterSchema = useMemo(() => {
|
||||
if (!currentEvent) return []
|
||||
return toolParametersToFormSchemas(currentEvent.parameters)
|
||||
}, [currentEvent])
|
||||
|
||||
// Combined parameter schema (subscription + trigger specific)
|
||||
const triggerParameterSchema = useMemo(() => {
|
||||
const schemaMap = new Map()
|
||||
|
||||
triggerSpecificParameterSchema.forEach((schema) => {
|
||||
schemaMap.set(schema.variable || schema.name, schema)
|
||||
})
|
||||
|
||||
return Array.from(schemaMap.values())
|
||||
}, [triggerSpecificParameterSchema])
|
||||
|
||||
const triggerParameterValue = useMemo(() => {
|
||||
if (!triggerParameterSchema.length)
|
||||
return {} as PluginTriggerVarInputs
|
||||
|
||||
const hasStoredParameters = event_parameters && Object.keys(event_parameters).length > 0
|
||||
const baseValue = hasStoredParameters ? event_parameters : legacy_config_parameters
|
||||
|
||||
const configuredValue = getConfiguredValue(baseValue, triggerParameterSchema) as PluginTriggerVarInputs
|
||||
return normalizeEventParameters(configuredValue)
|
||||
}, [triggerParameterSchema, event_parameters, legacy_config_parameters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerParameterSchema.length)
|
||||
return
|
||||
|
||||
if (event_parameters && Object.keys(event_parameters).length > 0)
|
||||
return
|
||||
|
||||
if (!triggerParameterValue || Object.keys(triggerParameterValue).length === 0)
|
||||
return
|
||||
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.event_parameters = triggerParameterValue
|
||||
draft.config = triggerParameterValue
|
||||
})
|
||||
doSetInputs(newInputs)
|
||||
}, [
|
||||
doSetInputs,
|
||||
event_parameters,
|
||||
inputs,
|
||||
triggerParameterSchema,
|
||||
triggerParameterValue,
|
||||
])
|
||||
|
||||
const setTriggerParameterValue = useCallback(
|
||||
(value: PluginTriggerVarInputs) => {
|
||||
const sanitizedValue = normalizeEventParameters(value)
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.event_parameters = sanitizedValue
|
||||
draft.config = sanitizedValue
|
||||
})
|
||||
doSetInputs(newInputs)
|
||||
},
|
||||
[inputs, doSetInputs],
|
||||
)
|
||||
|
||||
const setInputVar = useCallback(
|
||||
(variable: InputVar, varDetail: InputVar) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
const nextEventParameters = normalizeEventParameters({
|
||||
...draft.event_parameters,
|
||||
[variable.variable]: {
|
||||
type: VarKindType.variable,
|
||||
value: varDetail.variable,
|
||||
},
|
||||
} as PluginTriggerVarInputs)
|
||||
|
||||
draft.event_parameters = nextEventParameters
|
||||
draft.config = nextEventParameters
|
||||
})
|
||||
doSetInputs(newInputs)
|
||||
},
|
||||
[inputs, doSetInputs],
|
||||
)
|
||||
|
||||
// Get output schema
|
||||
const outputSchema = useMemo(() => {
|
||||
return currentEvent?.output_schema || {}
|
||||
}, [currentEvent])
|
||||
|
||||
// Check if trigger has complex output structure
|
||||
const hasObjectOutput = useMemo(() => {
|
||||
const properties = outputSchema.properties || {}
|
||||
return Object.values(properties).some(
|
||||
(prop: any) => prop.type === 'object',
|
||||
)
|
||||
}, [outputSchema])
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
currentProvider,
|
||||
currentEvent,
|
||||
triggerParameterSchema,
|
||||
triggerParameterValue,
|
||||
setTriggerParameterValue,
|
||||
setInputVar,
|
||||
outputSchema,
|
||||
hasObjectOutput,
|
||||
subscriptions,
|
||||
subscriptionSelected,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -0,0 +1,308 @@
|
||||
import { deepSanitizeFormValues, findMissingRequiredField, sanitizeFormValues } from '../form-helpers'
|
||||
|
||||
describe('Form Helpers', () => {
|
||||
describe('sanitizeFormValues', () => {
|
||||
it('should convert null values to empty strings', () => {
|
||||
const input = { field1: null, field2: 'value', field3: undefined }
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
field1: '',
|
||||
field2: 'value',
|
||||
field3: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert undefined values to empty strings', () => {
|
||||
const input = { field1: undefined, field2: 'test' }
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
field1: '',
|
||||
field2: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert non-string values to strings', () => {
|
||||
const input = { number: 123, boolean: true, string: 'test' }
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
number: '123',
|
||||
boolean: 'true',
|
||||
string: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = sanitizeFormValues({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle objects with mixed value types', () => {
|
||||
const input = {
|
||||
null_field: null,
|
||||
undefined_field: undefined,
|
||||
zero: 0,
|
||||
false_field: false,
|
||||
empty_string: '',
|
||||
valid_string: 'test',
|
||||
}
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
null_field: '',
|
||||
undefined_field: '',
|
||||
zero: '0',
|
||||
false_field: 'false',
|
||||
empty_string: '',
|
||||
valid_string: 'test',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deepSanitizeFormValues', () => {
|
||||
it('should handle nested objects', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
field1: null,
|
||||
field2: 'value',
|
||||
level2: {
|
||||
field3: undefined,
|
||||
field4: 'nested',
|
||||
},
|
||||
},
|
||||
simple: 'test',
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
field1: '',
|
||||
field2: 'value',
|
||||
level2: {
|
||||
field3: '',
|
||||
field4: 'nested',
|
||||
},
|
||||
},
|
||||
simple: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle arrays correctly', () => {
|
||||
const input = {
|
||||
array: [1, 2, 3],
|
||||
nested: {
|
||||
array: ['a', null, 'c'],
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
array: [1, 2, 3],
|
||||
nested: {
|
||||
array: ['a', null, 'c'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle null and undefined at root level', () => {
|
||||
const input = {
|
||||
null_field: null,
|
||||
undefined_field: undefined,
|
||||
nested: {
|
||||
null_nested: null,
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
null_field: '',
|
||||
undefined_field: '',
|
||||
nested: {
|
||||
null_nested: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
field: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
field: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve non-null values in nested structures', () => {
|
||||
const input = {
|
||||
config: {
|
||||
client_id: 'valid_id',
|
||||
client_secret: null,
|
||||
options: {
|
||||
timeout: 5000,
|
||||
enabled: true,
|
||||
message: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
client_id: 'valid_id',
|
||||
client_secret: '',
|
||||
options: {
|
||||
timeout: 5000,
|
||||
enabled: true,
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('findMissingRequiredField', () => {
|
||||
const requiredFields = [
|
||||
{ name: 'client_id', label: 'Client ID' },
|
||||
{ name: 'client_secret', label: 'Client Secret' },
|
||||
{ name: 'scope', label: 'Scope' },
|
||||
]
|
||||
|
||||
it('should return null when all required fields are present', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: 'test_secret',
|
||||
scope: 'read',
|
||||
optional_field: 'optional',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return the first missing field', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
scope: 'read',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' })
|
||||
})
|
||||
|
||||
it('should treat empty strings as missing fields', () => {
|
||||
const formData = {
|
||||
client_id: '',
|
||||
client_secret: 'test_secret',
|
||||
scope: 'read',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_id', label: 'Client ID' })
|
||||
})
|
||||
|
||||
it('should treat null values as missing fields', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: null,
|
||||
scope: 'read',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' })
|
||||
})
|
||||
|
||||
it('should treat undefined values as missing fields', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: 'test_secret',
|
||||
scope: undefined,
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'scope', label: 'Scope' })
|
||||
})
|
||||
|
||||
it('should handle empty required fields array', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty form data', () => {
|
||||
const result = findMissingRequiredField({}, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_id', label: 'Client ID' })
|
||||
})
|
||||
|
||||
it('should handle multilingual labels', () => {
|
||||
const multilingualFields = [
|
||||
{ name: 'field1', label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' } },
|
||||
]
|
||||
const formData = {}
|
||||
|
||||
const result = findMissingRequiredField(formData, multilingualFields)
|
||||
expect(result).toEqual({
|
||||
name: 'field1',
|
||||
label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null for form data with extra fields', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: 'test_secret',
|
||||
scope: 'read',
|
||||
extra_field1: 'extra1',
|
||||
extra_field2: 'extra2',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle objects with non-string keys', () => {
|
||||
const input = { [Symbol('test')]: 'value', regular: 'field' } as any
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result.regular).toBe('field')
|
||||
})
|
||||
|
||||
it('should handle objects with getter properties', () => {
|
||||
const obj = {}
|
||||
Object.defineProperty(obj, 'getter', {
|
||||
get: () => 'computed_value',
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
const result = sanitizeFormValues(obj)
|
||||
expect(result.getter).toBe('computed_value')
|
||||
})
|
||||
|
||||
it('should handle circular references in deepSanitizeFormValues gracefully', () => {
|
||||
const obj: any = { field: 'value' }
|
||||
obj.circular = obj
|
||||
|
||||
expect(() => deepSanitizeFormValues(obj)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Utility functions for form data handling in trigger plugin components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitizes form values by converting null/undefined to empty strings
|
||||
* This ensures React form inputs don't receive null values which can cause warnings
|
||||
*/
|
||||
export const sanitizeFormValues = (values: Record<string, any>): Record<string, string> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [
|
||||
key,
|
||||
value === null || value === undefined ? '' : String(value),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep sanitizes form values while preserving nested objects structure
|
||||
* Useful for complex form schemas with nested properties
|
||||
*/
|
||||
export const deepSanitizeFormValues = (values: Record<string, any>, visited = new WeakSet()): Record<string, any> => {
|
||||
if (visited.has(values))
|
||||
return {}
|
||||
|
||||
visited.add(values)
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null || value === undefined)
|
||||
result[key] = ''
|
||||
else if (typeof value === 'object' && !Array.isArray(value))
|
||||
result[key] = deepSanitizeFormValues(value, visited)
|
||||
else
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates required fields in form data
|
||||
* Returns the first missing required field or null if all are present
|
||||
*/
|
||||
export const findMissingRequiredField = (
|
||||
formData: Record<string, any>,
|
||||
requiredFields: Array<{ name: string; label: any }>,
|
||||
): { name: string; label: any } | null => {
|
||||
for (const field of requiredFields) {
|
||||
if (!formData[field.name] || formData[field.name] === '')
|
||||
return field
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user