Support OAuth Integration for Plugin Tools (#22550)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: Yeuoly <admin@srmxy.cn>
This commit is contained in:
177
web/app/components/base/form/components/base/base-field.tsx
Normal file
177
web/app/components/base/form/components/base/base-field.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
isValidElement,
|
||||
memo,
|
||||
useMemo,
|
||||
} from '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'
|
||||
|
||||
export type BaseFieldProps = {
|
||||
fieldClassName?: string
|
||||
labelClassName?: string
|
||||
inputContainerClassName?: string
|
||||
inputClassName?: string
|
||||
formSchema: FormSchema
|
||||
field: AnyFieldApi
|
||||
disabled?: boolean
|
||||
}
|
||||
const BaseField = ({
|
||||
fieldClassName,
|
||||
labelClassName,
|
||||
inputContainerClassName,
|
||||
inputClassName,
|
||||
formSchema,
|
||||
field,
|
||||
disabled,
|
||||
}: BaseFieldProps) => {
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const {
|
||||
label,
|
||||
required,
|
||||
placeholder,
|
||||
options,
|
||||
labelClassName: formLabelClassName,
|
||||
show_on = [],
|
||||
} = formSchema
|
||||
|
||||
const memorizedLabel = useMemo(() => {
|
||||
if (isValidElement(label))
|
||||
return label
|
||||
|
||||
if (typeof label === 'string')
|
||||
return label
|
||||
|
||||
if (typeof label === 'object' && label !== null)
|
||||
return renderI18nObject(label as Record<string, string>)
|
||||
}, [label, renderI18nObject])
|
||||
const memorizedPlaceholder = useMemo(() => {
|
||||
if (typeof placeholder === 'string')
|
||||
return placeholder
|
||||
|
||||
if (typeof placeholder === 'object' && placeholder !== null)
|
||||
return renderI18nObject(placeholder as Record<string, string>)
|
||||
}, [placeholder, renderI18nObject])
|
||||
const memorizedOptions = useMemo(() => {
|
||||
return options?.map((option) => {
|
||||
return {
|
||||
label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label),
|
||||
value: option.value,
|
||||
}
|
||||
}) || []
|
||||
}, [options, renderI18nObject])
|
||||
const value = useStore(field.form.store, s => s.values[field.name])
|
||||
const values = useStore(field.form.store, (s) => {
|
||||
return show_on.reduce((acc, condition) => {
|
||||
acc[condition.variable] = s.values[condition.variable]
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
})
|
||||
const show = useMemo(() => {
|
||||
return show_on.every((condition) => {
|
||||
const conditionValue = values[condition.variable]
|
||||
return conditionValue === condition.value
|
||||
})
|
||||
}, [values, show_on])
|
||||
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
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 => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder={memorizedPlaceholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formSchema.type === FormTypeEnum.secretInput && (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type='password'
|
||||
className={cn(inputClassName)}
|
||||
value={value || ''}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder={memorizedPlaceholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formSchema.type === FormTypeEnum.textNumber && (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type='number'
|
||||
className={cn(inputClassName)}
|
||||
value={value || ''}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder={memorizedPlaceholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formSchema.type === FormTypeEnum.select && (
|
||||
<PureSelect
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
disabled={disabled}
|
||||
placeholder={memorizedPlaceholder}
|
||||
options={memorizedOptions}
|
||||
triggerPopupSameWidth
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formSchema.type === FormTypeEnum.radio && (
|
||||
<div className='flex items-center space-x-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 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',
|
||||
)}
|
||||
onClick={() => field.handleChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BaseField)
|
||||
115
web/app/components/base/form/components/base/base-form.tsx
Normal file
115
web/app/components/base/form/components/base/base-form.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from 'react'
|
||||
import type {
|
||||
AnyFieldApi,
|
||||
AnyFormApi,
|
||||
} from '@tanstack/react-form'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import type {
|
||||
FormRef,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import {
|
||||
BaseField,
|
||||
} from '.'
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
} from '.'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
useGetFormValues,
|
||||
useGetValidators,
|
||||
} from '@/app/components/base/form/hooks'
|
||||
|
||||
export type BaseFormProps = {
|
||||
formSchemas?: FormSchema[]
|
||||
defaultValues?: Record<string, any>
|
||||
formClassName?: string
|
||||
ref?: FormRef
|
||||
disabled?: boolean
|
||||
formFromProps?: AnyFormApi
|
||||
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
|
||||
|
||||
const BaseForm = ({
|
||||
formSchemas = [],
|
||||
defaultValues,
|
||||
formClassName,
|
||||
fieldClassName,
|
||||
labelClassName,
|
||||
inputContainerClassName,
|
||||
inputClassName,
|
||||
ref,
|
||||
disabled,
|
||||
formFromProps,
|
||||
}: BaseFormProps) => {
|
||||
const formFromHook = useForm({
|
||||
defaultValues,
|
||||
})
|
||||
const form: any = formFromProps || formFromHook
|
||||
const { getFormValues } = useGetFormValues(form, formSchemas)
|
||||
const { getValidators } = useGetValidators()
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
getForm() {
|
||||
return form
|
||||
},
|
||||
getFormValues: (option) => {
|
||||
return getFormValues(option)
|
||||
},
|
||||
}
|
||||
}, [form, getFormValues])
|
||||
|
||||
const renderField = useCallback((field: AnyFieldApi) => {
|
||||
const formSchema = formSchemas?.find(schema => schema.name === field.name)
|
||||
|
||||
if (formSchema) {
|
||||
return (
|
||||
<BaseField
|
||||
field={field}
|
||||
formSchema={formSchema}
|
||||
fieldClassName={fieldClassName}
|
||||
labelClassName={labelClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
inputClassName={inputClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled])
|
||||
|
||||
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
|
||||
const validators = getValidators(formSchema)
|
||||
const {
|
||||
name,
|
||||
} = formSchema
|
||||
|
||||
return (
|
||||
<form.Field
|
||||
key={name}
|
||||
name={name}
|
||||
validators={validators}
|
||||
>
|
||||
{renderField}
|
||||
</form.Field>
|
||||
)
|
||||
}, [renderField, form, getValidators])
|
||||
|
||||
if (!formSchemas?.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn(formClassName)}
|
||||
>
|
||||
{formSchemas.map(renderFieldWrapper)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BaseForm)
|
||||
2
web/app/components/base/form/components/base/index.tsx
Normal file
2
web/app/components/base/form/components/base/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as BaseForm, type BaseFormProps } from './base-form'
|
||||
export { default as BaseField, type BaseFieldProps } from './base-field'
|
||||
23
web/app/components/base/form/form-scenarios/auth/index.tsx
Normal file
23
web/app/components/base/form/form-scenarios/auth/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { memo } from 'react'
|
||||
import { BaseForm } from '../../components/base'
|
||||
import type { BaseFormProps } from '../../components/base'
|
||||
|
||||
const AuthForm = ({
|
||||
formSchemas = [],
|
||||
defaultValues,
|
||||
ref,
|
||||
formFromProps,
|
||||
}: BaseFormProps) => {
|
||||
return (
|
||||
<BaseForm
|
||||
ref={ref}
|
||||
formSchemas={formSchemas}
|
||||
defaultValues={defaultValues}
|
||||
formClassName='space-y-4'
|
||||
labelClassName='h-6 flex items-center mb-1 system-sm-medium text-text-secondary'
|
||||
formFromProps={formFromProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AuthForm)
|
||||
3
web/app/components/base/form/hooks/index.ts
Normal file
3
web/app/components/base/form/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './use-check-validated'
|
||||
export * from './use-get-form-values'
|
||||
export * from './use-get-validators'
|
||||
48
web/app/components/base/form/hooks/use-check-validated.ts
Normal file
48
web/app/components/base/form/hooks/use-check-validated.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { AnyFormApi } from '@tanstack/react-form'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
|
||||
export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) => {
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const checkValidated = useCallback(() => {
|
||||
const allError = form?.getAllErrors()
|
||||
const values = form.state.values
|
||||
|
||||
if (allError) {
|
||||
const fields = allError.fields
|
||||
const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => {
|
||||
const currentSchema = FormSchemas.find(schema => schema.name === key)
|
||||
const { show_on = [] } = currentSchema || {}
|
||||
const showOnValues = show_on.reduce((acc, condition) => {
|
||||
acc[condition.variable] = values[condition.variable]
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
const show = show_on?.every((condition) => {
|
||||
const conditionValue = showOnValues[condition.variable]
|
||||
return conditionValue === condition.value
|
||||
})
|
||||
const errors: any[] = show ? fields[key].errors : []
|
||||
|
||||
return [...acc, ...errors]
|
||||
}, [] as string[])
|
||||
|
||||
if (errorArray.length) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: errorArray[0],
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}, [form, notify, FormSchemas])
|
||||
|
||||
return {
|
||||
checkValidated,
|
||||
}
|
||||
}
|
||||
44
web/app/components/base/form/hooks/use-get-form-values.ts
Normal file
44
web/app/components/base/form/hooks/use-get-form-values.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { AnyFormApi } from '@tanstack/react-form'
|
||||
import { useCheckValidated } from './use-check-validated'
|
||||
import type {
|
||||
FormSchema,
|
||||
GetValuesOptions,
|
||||
} from '../types'
|
||||
import { getTransformedValuesWhenSecretInputPristine } from '../utils'
|
||||
|
||||
export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => {
|
||||
const { checkValidated } = useCheckValidated(form, formSchemas)
|
||||
|
||||
const getFormValues = useCallback((
|
||||
{
|
||||
needCheckValidatedValues,
|
||||
needTransformWhenSecretFieldIsPristine,
|
||||
}: GetValuesOptions,
|
||||
) => {
|
||||
const values = form?.store.state.values || {}
|
||||
if (!needCheckValidatedValues) {
|
||||
return {
|
||||
values,
|
||||
isCheckValidated: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (checkValidated()) {
|
||||
return {
|
||||
values: needTransformWhenSecretFieldIsPristine ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) : values,
|
||||
isCheckValidated: true,
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
values: {},
|
||||
isCheckValidated: false,
|
||||
}
|
||||
}
|
||||
}, [form, checkValidated, formSchemas])
|
||||
|
||||
return {
|
||||
getFormValues,
|
||||
}
|
||||
}
|
||||
36
web/app/components/base/form/hooks/use-get-validators.ts
Normal file
36
web/app/components/base/form/hooks/use-get-validators.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FormSchema } from '../types'
|
||||
|
||||
export const useGetValidators = () => {
|
||||
const { t } = useTranslation()
|
||||
const getValidators = useCallback((formSchema: FormSchema) => {
|
||||
const {
|
||||
name,
|
||||
validators,
|
||||
required,
|
||||
} = formSchema
|
||||
let mergedValidators = validators
|
||||
if (required && !validators) {
|
||||
mergedValidators = {
|
||||
onMount: ({ value }: any) => {
|
||||
if (!value)
|
||||
return t('common.errorMsg.fieldRequired', { field: name })
|
||||
},
|
||||
onChange: ({ value }: any) => {
|
||||
if (!value)
|
||||
return t('common.errorMsg.fieldRequired', { field: name })
|
||||
},
|
||||
onBlur: ({ value }: any) => {
|
||||
if (!value)
|
||||
return t('common.errorMsg.fieldRequired', { field: name })
|
||||
},
|
||||
}
|
||||
}
|
||||
return mergedValidators
|
||||
}, [t])
|
||||
|
||||
return {
|
||||
getValidators,
|
||||
}
|
||||
}
|
||||
76
web/app/components/base/form/types.ts
Normal file
76
web/app/components/base/form/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
ForwardedRef,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
AnyFormApi,
|
||||
FieldValidators,
|
||||
} from '@tanstack/react-form'
|
||||
|
||||
export type TypeWithI18N<T = string> = {
|
||||
en_US: T
|
||||
zh_Hans: T
|
||||
[key: string]: T
|
||||
}
|
||||
|
||||
export type FormShowOnObject = {
|
||||
variable: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export enum FormTypeEnum {
|
||||
textInput = 'text-input',
|
||||
textNumber = 'number-input',
|
||||
secretInput = 'secret-input',
|
||||
select = 'select',
|
||||
radio = 'radio',
|
||||
boolean = 'boolean',
|
||||
files = 'files',
|
||||
file = 'file',
|
||||
modelSelector = 'model-selector',
|
||||
toolSelector = 'tool-selector',
|
||||
multiToolSelector = 'array[tools]',
|
||||
appSelector = 'app-selector',
|
||||
dynamicSelect = 'dynamic-select',
|
||||
}
|
||||
|
||||
export type FormOption = {
|
||||
label: TypeWithI18N | string
|
||||
value: string
|
||||
show_on?: FormShowOnObject[]
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export type AnyValidators = FieldValidators<any, any, any, any, any, any, any, any, any, any>
|
||||
|
||||
export type FormSchema = {
|
||||
type: FormTypeEnum
|
||||
name: string
|
||||
label: string | ReactNode | TypeWithI18N
|
||||
required: boolean
|
||||
default?: any
|
||||
tooltip?: string | TypeWithI18N
|
||||
show_on?: FormShowOnObject[]
|
||||
url?: string
|
||||
scope?: string
|
||||
help?: string | TypeWithI18N
|
||||
placeholder?: string | TypeWithI18N
|
||||
options?: FormOption[]
|
||||
labelClassName?: string
|
||||
validators?: AnyValidators
|
||||
}
|
||||
|
||||
export type FormValues = Record<string, any>
|
||||
|
||||
export type GetValuesOptions = {
|
||||
needTransformWhenSecretFieldIsPristine?: boolean
|
||||
needCheckValidatedValues?: boolean
|
||||
}
|
||||
export type FormRefObject = {
|
||||
getForm: () => AnyFormApi
|
||||
getFormValues: (obj: GetValuesOptions) => {
|
||||
values: Record<string, any>
|
||||
isCheckValidated: boolean
|
||||
}
|
||||
}
|
||||
export type FormRef = ForwardedRef<FormRefObject>
|
||||
1
web/app/components/base/form/utils/index.ts
Normal file
1
web/app/components/base/form/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './secret-input'
|
||||
29
web/app/components/base/form/utils/secret-input/index.ts
Normal file
29
web/app/components/base/form/utils/secret-input/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AnyFormApi } from '@tanstack/react-form'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
|
||||
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
|
||||
const transformedValues: Record<string, any> = { ...values }
|
||||
|
||||
isPristineSecretInputNames.forEach((name) => {
|
||||
if (transformedValues[name])
|
||||
transformedValues[name] = '[__HIDDEN__]'
|
||||
})
|
||||
|
||||
return transformedValues
|
||||
}
|
||||
|
||||
export const getTransformedValuesWhenSecretInputPristine = (formSchemas: FormSchema[], form: AnyFormApi) => {
|
||||
const values = form?.store.state.values || {}
|
||||
const isPristineSecretInputNames: string[] = []
|
||||
for (let i = 0; i < formSchemas.length; i++) {
|
||||
const schema = formSchemas[i]
|
||||
if (schema.type === FormTypeEnum.secretInput) {
|
||||
const fieldMeta = form?.getFieldMeta(schema.name)
|
||||
if (fieldMeta?.isPristine)
|
||||
isPristineSecretInputNames.push(schema.name)
|
||||
}
|
||||
}
|
||||
|
||||
return transformFormSchemasSecretInput(isPristineSecretInputNames, values)
|
||||
}
|
||||
Reference in New Issue
Block a user