(null)
+
+ useEffect(() => {
+ if (error)
+ throw error
+ }, [error])
+
+ return setError
+}
+
+// Hook for catching async errors
+export function useAsyncError() {
+ const [, setError] = useState()
+
+ return useCallback(
+ (error: Error) => {
+ setError(() => {
+ throw error
+ })
+ },
+ [setError],
+ )
+}
+
+// HOC for wrapping components with error boundary
+export function withErrorBoundary(
+ Component: React.ComponentType
,
+ errorBoundaryProps?: Omit,
+): React.ComponentType {
+ const WrappedComponent = (props: P) => (
+
+
+
+ )
+
+ WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || 'Component'})`
+
+ return WrappedComponent
+}
+
+// Simple error fallback component
+export const ErrorFallback: React.FC<{
+ error: Error
+ resetErrorBoundary: () => void
+}> = ({ error, resetErrorBoundary }) => {
+ return (
+
+
Oops! Something went wrong
+
{error.message}
+
+
+ )
+}
+
+export default ErrorBoundary
diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
index 095137203b..ff45a7ea4c 100644
--- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
+++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
@@ -26,6 +26,7 @@ import { CustomConfigurationStatusEnum } from '@/app/components/header/account-s
import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
const systemTypes = ['openai_moderation', 'keywords', 'api']
@@ -55,7 +56,7 @@ const ModerationSettingModal: FC = ({
const { setShowAccountSettingModal } = useModalContext()
const handleOpenSettingsModal = () => {
setShowAccountSettingModal({
- payload: 'provider',
+ payload: ACCOUNT_SETTING_TAB.PROVIDER,
onCancelCallback: () => {
mutate()
},
diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts
index 9675123fe7..521ecdbafd 100644
--- a/web/app/components/base/file-uploader/hooks.ts
+++ b/web/app/components/base/file-uploader/hooks.ts
@@ -305,9 +305,23 @@ export const useFile = (fileConfig: FileUpload) => {
const text = e.clipboardData?.getData('text/plain')
if (file && !text) {
e.preventDefault()
+
+ const allowedFileTypes = fileConfig.allowed_file_types || []
+ const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
+ const isFileTypeAllowed = allowedFileTypes.includes(fileType)
+
+ // Check if file type is in allowed list
+ if (!isFileTypeAllowed || !fileConfig.enabled) {
+ notify({
+ type: 'error',
+ message: t('common.fileUploader.fileExtensionNotSupport'),
+ })
+ return
+ }
+
handleLocalFileUpload(file)
}
- }, [handleLocalFileUpload])
+ }, [handleLocalFileUpload, fileConfig, notify, t])
const [isDragActive, setIsDragActive] = useState(false)
const handleDragFileEnter = useCallback((e: React.DragEvent) => {
diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx
index fce80f208e..07f2338fa7 100644
--- a/web/app/components/base/form/components/base/base-field.tsx
+++ b/web/app/components/base/form/components/base/base-field.tsx
@@ -1,9 +1,14 @@
-import {
- isValidElement,
- memo,
- useCallback,
- useMemo,
-} from 'react'
+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 {
RiArrowDownSFill,
RiDraftLine,
@@ -12,14 +17,13 @@ import {
} 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 {
+ isValidElement,
+ memo,
+ useCallback,
+ useMemo,
+} from 'react'
+import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import PromptEditor from '@/app/components/base/prompt-editor'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@@ -31,10 +35,56 @@ import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import Button from '@/app/components/base/button'
import PromptGeneratorBtn from '@/app/components/workflow/nodes/llm/components/prompt-generator-btn'
import Slider from '@/app/components/base/slider'
-import Tooltip from '@/app/components/base/tooltip'
import Switch from '../../../switch'
import NodeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/node-selector'
+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 | Record
+ render: (content: TypeWithI18N | Record) => string
+}): string => {
+ if (isValidElement(content) || typeof content === 'string')
+ return content as string
+
+ if (typeof content === 'object' && content !== null)
+ return render(content as TypeWithI18N)
+
+ return ''
+}
+
+const VALIDATE_STATUS_STYLE_MAP: Record = {
+ [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
labelClassName?: string
@@ -44,7 +94,9 @@ export type BaseFieldProps = {
field: AnyFieldApi
disabled?: boolean
onChange?: (field: string, value: any) => void
+ fieldState?: FieldState
}
+
const BaseField = ({
fieldClassName,
labelClassName,
@@ -54,85 +106,111 @@ const BaseField = ({
field,
disabled: propsDisabled,
onChange,
+ fieldState,
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
+ const { t } = useTranslation()
const {
- type: typeOrFn,
+ name,
label,
required,
placeholder,
options,
labelClassName: formLabelClassName,
+ disabled: formSchemaDisabled,
+ dynamicSelectParams,
+ multiple = false,
+ tooltip,
+ showCopy,
+ description,
+ url,
+ help,
+ type: typeOrFn,
fieldClassName: formFieldClassName,
inputContainerClassName: formInputContainerClassName,
inputClassName: formInputClassName,
- url,
- help,
selfFormProps,
onChange: formOnChange,
- tooltip,
- disabled: formSchemaDisabled,
} = formSchema
- const type = typeof typeOrFn === 'function' ? typeOrFn(field.form) : typeOrFn
+ const formItemType = typeof typeOrFn === 'function' ? typeOrFn(field.form) : typeOrFn
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()
- if (typeof label === 'object' && label !== null)
- return renderI18nObject(label as Record)
- }, [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)
- }, [placeholder, renderI18nObject])
- const memorizedTooltip = useMemo(() => {
- if (typeof tooltip === 'string')
- return tooltip
+ return Array.from(variables)
+ }, [options])
- if (typeof tooltip === 'object' && tooltip !== null)
- return renderI18nObject(tooltip as Record)
- }, [tooltip, renderI18nObject])
- const optionValues = useStore(field.form.store, (s) => {
+ const watchedValues = useStore(field.form.store, (s) => {
const result: Record = {}
- 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]
+ const conditionValue = watchedValues[condition.variable]
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === 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 booleanRadioValue = useMemo(() => {
if (value === null || value === undefined)
return undefined
return value ? 1 : 0
}, [value])
+
const handleChange = useCallback((value: any) => {
if (disabled)
return
@@ -140,7 +218,7 @@ const BaseField = ({
field.handleChange(value)
formOnChange?.(field.form, value)
onChange?.(field.name, value)
- }, [field, onChange, disabled])
+ }, [field, formOnChange, onChange, disabled])
const selfProps = typeof selfFormProps === 'function' ? selfFormProps(field.form) : selfFormProps
@@ -153,20 +231,20 @@ const BaseField = ({
}
{
- if (type === FormTypeEnum.collapse)
+ if (formItemType === FormTypeEnum.collapse)
handleChange(!value)
}}
>
- {memorizedLabel}
+ {translatedLabel}
{
required && !isValidElement(label) && (
*
)
}
{
- type === FormTypeEnum.collapse && (
+ formItemType === FormTypeEnum.collapse && (
)
}
- {
- memorizedTooltip && (
-
- )
- }
+ {tooltip && (
+ {translatedTooltip}
}
+ triggerClassName='ml-0.5 w-4 h-4'
+ />
+ )}
{
- type === FormTypeEnum.textInput && (
+ !selfProps?.withSlider && [FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
handleChange(e.target.value)}
+ onChange={(e) => {
+ handleChange(e.target.value)
+ }}
onBlur={field.handleBlur}
disabled={disabled}
- placeholder={memorizedPlaceholder}
+ placeholder={translatedPlaceholder}
+ {...getExtraProps(formItemType)}
+ showCopyIcon={showCopy}
/>
)
}
{
- type === FormTypeEnum.secretInput && (
-
handleChange(e.target.value)}
- onBlur={field.handleBlur}
- disabled={disabled}
- placeholder={memorizedPlaceholder}
- />
- )
- }
- {
- type === FormTypeEnum.textNumber && !selfProps?.withSlider && (
-
handleChange(e.target.value)}
- onBlur={field.handleBlur}
- disabled={disabled}
- placeholder={memorizedPlaceholder}
- />
- )
- }
- {
- type === FormTypeEnum.textNumber && selfProps?.withSlider && (
+ formItemType === FormTypeEnum.textNumber && selfProps?.withSlider && (
handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
- placeholder={memorizedPlaceholder}
+ placeholder={translatedPlaceholder}
/>
)
}
{
- type === FormTypeEnum.select && (
+ formItemType === FormTypeEnum.select && !multiple && (
handleChange(v)}
disabled={disabled}
- placeholder={memorizedPlaceholder}
+ placeholder={translatedPlaceholder}
options={memorizedOptions}
triggerPopupSameWidth
+ popupProps={{
+ className: 'max-h-[320px] overflow-y-auto',
+ }}
/>
)
}
{
- type === FormTypeEnum.radio && (
+ formItemType === FormTypeEnum.checkbox /* && multiple */ && (
+ field.handleChange(v)}
+ options={memorizedOptions}
+ maxHeight='200px'
+ />
+ )
+ }
+ {
+ formItemType === FormTypeEnum.dynamicSelect && (
+
+ )
+ }
+ {
+ formItemType === FormTypeEnum.radio && (
@@ -316,7 +399,7 @@ const BaseField = ({
)
}
{
- type === FormTypeEnum.textareaInput && (
+ formItemType === FormTypeEnum.textareaInput && (
+ {description && (
+
+ {translatedDescription}
+
+ )}
+ {
+ url && (
+
+
+ {translatedHelp}
+
+
+
+ )
+ }
{
selfProps?.withBottomDivider && (
)
}
>
+
)
}
diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx
index 89a673f5f1..88f82b69a4 100644
--- a/web/app/components/base/form/components/base/base-form.tsx
+++ b/web/app/components/base/form/components/base/base-form.tsx
@@ -3,6 +3,7 @@ import {
useCallback,
useImperativeHandle,
useMemo,
+ useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
@@ -13,9 +14,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,
@@ -37,9 +41,10 @@ export type BaseFormProps = {
ref?: FormRef
disabled?: boolean
formFromProps?: AnyFormApi
- onSubmit?: (values: Record) => void
onCancel?: () => void
onChange?: (field: string, value: any) => void
+ onSubmit?: (e: React.FormEvent) => void
+ preventDefaultSubmit?: boolean
} & Pick
const BaseForm = ({
@@ -53,9 +58,10 @@ const BaseForm = ({
ref,
disabled,
formFromProps,
- onSubmit,
onCancel,
onChange,
+ onSubmit,
+ preventDefaultSubmit = false,
}: BaseFormProps) => {
const { t } = useTranslation()
const initialDefaultValues = useMemo(() => {
@@ -75,6 +81,8 @@ const BaseForm = ({
const { getFormValues } = useGetFormValues(form, formSchemas)
const { getValidators } = useGetValidators()
+ const [fieldStates, setFieldStates] = useState>({})
+
const showOnValues = useStore(form.store, (s: any) => {
const result: Record = {}
formSchemas.forEach((schema) => {
@@ -102,6 +110,34 @@ const BaseForm = ({
return result
})
+ const setFields = useCallback((fields: SetFieldsParam[]) => {
+ const newFieldStates: Record = { ...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() {
@@ -110,8 +146,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)
@@ -128,18 +165,19 @@ const BaseForm = ({
)
}
return null
- }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, moreOnValues])
+ }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, moreOnValues, fieldStates])
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
const validators = getValidators(formSchema)
@@ -170,9 +208,18 @@ const BaseForm = ({
if (!formSchemas?.length)
return null
+ const handleSubmit = (e: React.FormEvent) => {
+ if (preventDefaultSubmit) {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+ onSubmit?.(e)
+ }
+
return (