feat: introduce trigger functionality (#27644)

Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: Stream <Stream_2@qq.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
Co-authored-by: Harry <xh001x@hotmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: yessenia <yessenia.contact@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WTW0313 <twwu@dify.ai>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yeuoly
2025-11-12 17:59:37 +08:00
committed by GitHub
parent ca7794305b
commit b76e17b25d
785 changed files with 41186 additions and 3725 deletions

View File

@@ -1,20 +1,71 @@
import CheckboxList from '@/app/components/base/checkbox-list'
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import PureSelect from '@/app/components/base/select/pure'
import Tooltip from '@/app/components/base/tooltip'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import { RiExternalLinkLine } from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
import {
isValidElement,
memo,
useCallback,
useMemo,
} from 'react'
import { RiExternalLinkLine } from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import PureSelect from '@/app/components/base/select/pure'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import { useTranslation } from 'react-i18next'
const getExtraProps = (type: FormTypeEnum) => {
switch (type) {
case FormTypeEnum.secretInput:
return { type: 'password', autoComplete: 'new-password' }
case FormTypeEnum.textNumber:
return { type: 'number' }
default:
return { type: 'text' }
}
}
const getTranslatedContent = ({ content, render }: {
content: React.ReactNode | string | null | undefined | TypeWithI18N<string> | Record<string, string>
render: (content: TypeWithI18N<string> | Record<string, string>) => string
}): string => {
if (isValidElement(content) || typeof content === 'string')
return content as string
if (typeof content === 'object' && content !== null)
return render(content as TypeWithI18N<string>)
return ''
}
const VALIDATE_STATUS_STYLE_MAP: Record<FormItemValidateStatusEnum, { componentClassName: string, textClassName: string, infoFieldName: string }> = {
[FormItemValidateStatusEnum.Error]: {
componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive',
textClassName: 'text-text-destructive',
infoFieldName: 'errors',
},
[FormItemValidateStatusEnum.Warning]: {
componentClassName: 'border-components-input-border-warning focus:border-components-input-border-warning',
textClassName: 'text-text-warning',
infoFieldName: 'warnings',
},
[FormItemValidateStatusEnum.Success]: {
componentClassName: '',
textClassName: '',
infoFieldName: '',
},
[FormItemValidateStatusEnum.Validating]: {
componentClassName: '',
textClassName: '',
infoFieldName: '',
},
}
export type BaseFieldProps = {
fieldClassName?: string
@@ -25,7 +76,9 @@ export type BaseFieldProps = {
field: AnyFieldApi
disabled?: boolean
onChange?: (field: string, value: any) => void
fieldState?: FieldState
}
const BaseField = ({
fieldClassName,
labelClassName,
@@ -35,204 +88,259 @@ const BaseField = ({
field,
disabled: propsDisabled,
onChange,
fieldState,
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
const { t } = useTranslation()
const {
name,
label,
required,
placeholder,
options,
labelClassName: formLabelClassName,
disabled: formSchemaDisabled,
type: formItemType,
dynamicSelectParams,
multiple = false,
tooltip,
showCopy,
description,
url,
help,
} = formSchema
const disabled = propsDisabled || formSchemaDisabled
const memorizedLabel = useMemo(() => {
if (isValidElement(label))
return label
const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => {
const results = [
label,
placeholder,
tooltip,
description,
help,
].map(v => getTranslatedContent({ content: v, render: renderI18nObject }))
if (!results[1]) results[1] = t('common.placeholder.input')
return results
}, [label, placeholder, tooltip, description, help, renderI18nObject])
if (typeof label === 'string')
return label
const watchedVariables = useMemo(() => {
const variables = new Set<string>()
if (typeof label === 'object' && label !== null)
return renderI18nObject(label as Record<string, string>)
}, [label, renderI18nObject])
const memorizedPlaceholder = useMemo(() => {
if (typeof placeholder === 'string')
return placeholder
for (const option of options || []) {
for (const condition of option.show_on || [])
variables.add(condition.variable)
}
if (typeof placeholder === 'object' && placeholder !== null)
return renderI18nObject(placeholder as Record<string, string>)
}, [placeholder, renderI18nObject])
const optionValues = useStore(field.form.store, (s) => {
return Array.from(variables)
}, [options])
const watchedValues = useStore(field.form.store, (s) => {
const result: Record<string, any> = {}
options?.forEach((option) => {
if (option.show_on?.length) {
option.show_on.forEach((condition) => {
result[condition.variable] = s.values[condition.variable]
})
}
})
for (const variable of watchedVariables)
result[variable] = s.values[variable]
return result
})
const memorizedOptions = useMemo(() => {
return options?.filter((option) => {
if (!option.show_on || option.show_on.length === 0)
if (!option.show_on?.length)
return true
return option.show_on.every((condition) => {
const conditionValue = optionValues[condition.variable]
return conditionValue === condition.value
return watchedValues[condition.variable] === condition.value
})
}).map((option) => {
return {
label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label),
label: getTranslatedContent({ content: option.label, render: renderI18nObject }),
value: option.value,
}
}) || []
}, [options, renderI18nObject, optionValues])
}, [options, renderI18nObject, watchedValues])
const value = useStore(field.form.store, s => s.values[field.name])
const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions(
dynamicSelectParams || {
plugin_id: '',
provider: '',
action: '',
parameter: '',
credential_id: '',
},
formItemType === FormTypeEnum.dynamicSelect,
)
const dynamicOptions = useMemo(() => {
if (!dynamicOptionsData?.options)
return []
return dynamicOptionsData.options.map(option => ({
label: getTranslatedContent({ content: option.label, render: renderI18nObject }),
value: option.value,
}))
}, [dynamicOptionsData, renderI18nObject])
const handleChange = useCallback((value: any) => {
field.handleChange(value)
onChange?.(field.name, value)
}, [field, onChange])
return (
<div className={cn(fieldClassName)}>
<div className={cn(labelClassName, formLabelClassName)}>
{memorizedLabel}
{
required && !isValidElement(label) && (
<span className='ml-1 text-text-destructive-secondary'>*</span>
)
}
</div>
<div className={cn(inputContainerClassName)}>
{
formSchema.type === FormTypeEnum.textInput && (
<Input
id={field.name}
name={field.name}
className={cn(inputClassName)}
value={value || ''}
onChange={(e) => {
handleChange(e.target.value)
}}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
<>
<div className={cn(fieldClassName)}>
<div className={cn(labelClassName, formLabelClassName)}>
{translatedLabel}
{
required && !isValidElement(label) && (
<span className='ml-1 text-text-destructive-secondary'>*</span>
)
}
{tooltip && (
<Tooltip
popupContent={<div className='w-[200px]'>{translatedTooltip}</div>}
triggerClassName='ml-0.5 w-4 h-4'
/>
)
}
{
formSchema.type === FormTypeEnum.secretInput && (
<Input
id={field.name}
name={field.name}
type='password'
className={cn(inputClassName)}
value={value || ''}
onChange={e => handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
autoComplete={'new-password'}
/>
)
}
{
formSchema.type === FormTypeEnum.textNumber && (
<Input
id={field.name}
name={field.name}
type='number'
className={cn(inputClassName)}
value={value || ''}
onChange={e => handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
/>
)
}
{
formSchema.type === FormTypeEnum.select && (
<PureSelect
value={value}
onChange={v => handleChange(v)}
disabled={disabled}
placeholder={memorizedPlaceholder}
options={memorizedOptions}
triggerPopupSameWidth
popupProps={{
className: 'max-h-[320px] overflow-y-auto',
}}
/>
)
}
{
formSchema.type === FormTypeEnum.radio && (
)}
</div>
<div className={cn(inputContainerClassName)}>
{
[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
<Input
id={field.name}
name={field.name}
className={cn(inputClassName, VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus as FormItemValidateStatusEnum]?.componentClassName)}
value={value || ''}
onChange={(e) => {
handleChange(e.target.value)
}}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={translatedPlaceholder}
{...getExtraProps(formItemType)}
showCopyIcon={showCopy}
/>
)
}
{
formItemType === FormTypeEnum.select && !multiple && (
<PureSelect
value={value}
onChange={v => handleChange(v)}
disabled={disabled}
placeholder={translatedPlaceholder}
options={memorizedOptions}
triggerPopupSameWidth
popupProps={{
className: 'max-h-[320px] overflow-y-auto',
}}
/>
)
}
{
formItemType === FormTypeEnum.checkbox /* && multiple */ && (
<CheckboxList
title={name}
value={value}
onChange={v => field.handleChange(v)}
options={memorizedOptions}
maxHeight='200px'
/>
)
}
{
formItemType === FormTypeEnum.dynamicSelect && (
<PureSelect
options={dynamicOptions}
value={value}
onChange={field.handleChange}
disabled={disabled || isDynamicOptionsLoading}
placeholder={
isDynamicOptionsLoading
? t('common.dynamicSelect.loading')
: translatedPlaceholder
}
{...(dynamicOptionsError ? { popupProps: { title: t('common.dynamicSelect.error'), titleClassName: 'text-text-destructive-secondary' } }
: (!dynamicOptions.length ? { popupProps: { title: t('common.dynamicSelect.noData') } } : {}))}
triggerPopupSameWidth
multiple={multiple}
/>
)
}
{
formItemType === FormTypeEnum.radio && (
<div className={cn(
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
)}>
{
memorizedOptions.map(option => (
<div
key={option.value}
className={cn(
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
)}
onClick={() => !disabled && handleChange(option.value)}
>
{
formSchema.showRadioUI && (
<RadioE
className='mr-2'
isChecked={value === option.value}
/>
)
}
{option.label}
</div>
))
}
</div>
)
}
{
formItemType === FormTypeEnum.boolean && (
<Radio.Group
className='flex w-fit items-center'
value={value}
onChange={v => field.handleChange(v)}
>
<Radio value={true} className='!mr-1'>True</Radio>
<Radio value={false}>False</Radio>
</Radio.Group>
)
}
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
<div className={cn(
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
'system-xs-regular mt-1 px-0 py-[2px]',
VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].textClassName,
)}>
{
memorizedOptions.map(option => (
<div
key={option.value}
className={cn(
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
)}
onClick={() => !disabled && handleChange(option.value)}
>
{
formSchema.showRadioUI && (
<RadioE
className='mr-2'
isChecked={value === option.value}
/>
)
}
{option.label}
</div>
))
}
{fieldState?.[VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].infoFieldName as keyof FieldState]}
</div>
)
}
{
formSchema.type === FormTypeEnum.boolean && (
<Radio.Group
className='flex w-fit items-center'
value={value}
onChange={v => field.handleChange(v)}
>
<Radio value={true} className='!mr-1'>True</Radio>
<Radio value={false}>False</Radio>
</Radio.Group>
)
}
{
formSchema.url && (
<a
className='system-xs-regular mt-4 flex items-center text-text-accent'
href={formSchema?.url}
target='_blank'
>
<span className='break-all'>
{renderI18nObject(formSchema?.help as any)}
</span>
{
<RiExternalLinkLine className='ml-1 h-3 w-3' />
}
</a>
)
}
)}
</div>
</div>
</div>
{description && (
<div className='system-xs-regular mt-4 text-text-tertiary'>
{translatedDescription}
</div>
)}
{
url && (
<a
className='system-xs-regular mt-4 flex items-center text-text-accent'
href={url}
target='_blank'
>
<span className='break-all'>
{translatedHelp}
</span>
<RiExternalLinkLine className='ml-1 h-3 w-3 shrink-0' />
</a>
)
}
</>
)
}

View File

@@ -3,6 +3,7 @@ import {
useCallback,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import type {
AnyFieldApi,
@@ -12,9 +13,12 @@ import {
useForm,
useStore,
} from '@tanstack/react-form'
import type {
FormRef,
FormSchema,
import {
type FieldState,
FormItemValidateStatusEnum,
type FormRef,
type FormSchema,
type SetFieldsParam,
} from '@/app/components/base/form/types'
import {
BaseField,
@@ -36,6 +40,8 @@ export type BaseFormProps = {
disabled?: boolean
formFromProps?: AnyFormApi
onChange?: (field: string, value: any) => void
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
preventDefaultSubmit?: boolean
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
const BaseForm = ({
@@ -50,6 +56,8 @@ const BaseForm = ({
disabled,
formFromProps,
onChange,
onSubmit,
preventDefaultSubmit = false,
}: BaseFormProps) => {
const initialDefaultValues = useMemo(() => {
if (defaultValues)
@@ -68,6 +76,8 @@ const BaseForm = ({
const { getFormValues } = useGetFormValues(form, formSchemas)
const { getValidators } = useGetValidators()
const [fieldStates, setFieldStates] = useState<Record<string, FieldState>>({})
const showOnValues = useStore(form.store, (s: any) => {
const result: Record<string, any> = {}
formSchemas.forEach((schema) => {
@@ -81,6 +91,34 @@ const BaseForm = ({
return result
})
const setFields = useCallback((fields: SetFieldsParam[]) => {
const newFieldStates: Record<string, FieldState> = { ...fieldStates }
for (const field of fields) {
const { name, value, errors, warnings, validateStatus, help } = field
if (value !== undefined)
form.setFieldValue(name, value)
let finalValidateStatus = validateStatus
if (!finalValidateStatus) {
if (errors && errors.length > 0)
finalValidateStatus = FormItemValidateStatusEnum.Error
else if (warnings && warnings.length > 0)
finalValidateStatus = FormItemValidateStatusEnum.Warning
}
newFieldStates[name] = {
validateStatus: finalValidateStatus,
help,
errors,
warnings,
}
}
setFieldStates(newFieldStates)
}, [form, fieldStates])
useImperativeHandle(ref, () => {
return {
getForm() {
@@ -89,8 +127,9 @@ const BaseForm = ({
getFormValues: (option) => {
return getFormValues(option)
},
setFields,
}
}, [form, getFormValues])
}, [form, getFormValues, setFields])
const renderField = useCallback((field: AnyFieldApi) => {
const formSchema = formSchemas?.find(schema => schema.name === field.name)
@@ -100,18 +139,19 @@ const BaseForm = ({
<BaseField
field={field}
formSchema={formSchema}
fieldClassName={fieldClassName}
labelClassName={labelClassName}
fieldClassName={fieldClassName ?? formSchema.fieldClassName}
labelClassName={labelClassName ?? formSchema.labelClassName}
inputContainerClassName={inputContainerClassName}
inputClassName={inputClassName}
disabled={disabled}
onChange={onChange}
fieldState={fieldStates[field.name]}
/>
)
}
return null
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange])
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates])
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
const validators = getValidators(formSchema)
@@ -142,9 +182,18 @@ const BaseForm = ({
if (!formSchemas?.length)
return null
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (preventDefaultSubmit) {
e.preventDefault()
e.stopPropagation()
}
onSubmit?.(e)
}
return (
<form
className={cn(formClassName)}
onSubmit={handleSubmit}
>
{formSchemas.map(renderFieldWrapper)}
</form>

View File

@@ -11,7 +11,9 @@ type SelectFieldProps = {
options: Option[]
onChange?: (value: string) => void
className?: string
} & Omit<PureSelectProps, 'options' | 'value' | 'onChange'>
} & Omit<PureSelectProps, 'options' | 'value' | 'onChange' | 'multiple'> & {
multiple?: false
}
const SelectField = ({
label,

View File

@@ -1,5 +1,5 @@
import type { ChangeEvent } from 'react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { RiEditLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import SegmentedControl from '@/app/components/base/segmented-control'
@@ -33,9 +33,9 @@ const VariableOrConstantInputField = ({
},
]
const handleVariableOrConstantChange = (value: string) => {
const handleVariableOrConstantChange = useCallback((value: string) => {
setVariableType(value)
}
}, [setVariableType])
const handleVariableValueChange = () => {
console.log('Variable value changed')

View File

@@ -12,7 +12,7 @@ export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) =>
const getFormValues = useCallback((
{
needCheckValidatedValues,
needCheckValidatedValues = true,
needTransformWhenSecretFieldIsPristine,
}: GetValuesOptions,
) => {
@@ -20,7 +20,7 @@ export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) =>
if (!needCheckValidatedValues) {
return {
values,
isCheckValidated: false,
isCheckValidated: true,
}
}

View File

@@ -102,14 +102,14 @@ const FormPlayground = () => {
options={{
...demoFormOpts,
validators: {
onSubmit: ({ value }) => {
const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues)
onSubmit: ({ value: formValue }) => {
const result = UserSchema.safeParse(formValue as typeof demoFormOpts.defaultValues)
if (!result.success)
return result.error.issues[0].message
return undefined
},
},
onSubmit: ({ value }) => {
onSubmit: () => {
setStatus('Successfully saved profile.')
},
}}

View File

@@ -6,6 +6,7 @@ import type {
AnyFormApi,
FieldValidators,
} from '@tanstack/react-form'
import type { Locale } from '@/i18n-config'
export type TypeWithI18N<T = string> = {
en_US: T
@@ -36,7 +37,7 @@ export enum FormTypeEnum {
}
export type FormOption = {
label: TypeWithI18N | string
label: string | TypeWithI18N | Record<Locale, string>
value: string
show_on?: FormShowOnObject[]
icon?: string
@@ -44,23 +45,41 @@ export type FormOption = {
export type AnyValidators = FieldValidators<any, any, any, any, any, any, any, any, any, any, any, any>
export enum FormItemValidateStatusEnum {
Success = 'success',
Warning = 'warning',
Error = 'error',
Validating = 'validating',
}
export type FormSchema = {
type: FormTypeEnum
name: string
label: string | ReactNode | TypeWithI18N
label: string | ReactNode | TypeWithI18N | Record<Locale, string>
required: boolean
multiple?: boolean
default?: any
tooltip?: string | TypeWithI18N
description?: string | TypeWithI18N | Record<Locale, string>
tooltip?: string | TypeWithI18N | Record<Locale, string>
show_on?: FormShowOnObject[]
url?: string
scope?: string
help?: string | TypeWithI18N
placeholder?: string | TypeWithI18N
help?: string | TypeWithI18N | Record<Locale, string>
placeholder?: string | TypeWithI18N | Record<Locale, string>
options?: FormOption[]
labelClassName?: string
fieldClassName?: string
validators?: AnyValidators
showRadioUI?: boolean
disabled?: boolean
showCopy?: boolean
dynamicSelectParams?: {
plugin_id: string
provider: string
action: string
parameter: string
credential_id: string
}
}
export type FormValues = Record<string, any>
@@ -69,11 +88,25 @@ export type GetValuesOptions = {
needTransformWhenSecretFieldIsPristine?: boolean
needCheckValidatedValues?: boolean
}
export type FieldState = {
validateStatus?: FormItemValidateStatusEnum
help?: string | ReactNode
errors?: string[]
warnings?: string[]
}
export type SetFieldsParam = {
name: string
value?: any
} & FieldState
export type FormRefObject = {
getForm: () => AnyFormApi
getFormValues: (obj: GetValuesOptions) => {
values: Record<string, any>
isCheckValidated: boolean
}
setFields: (fields: SetFieldsParam[]) => void
}
export type FormRef = ForwardedRef<FormRefObject>