feat: add validation status for formitem

This commit is contained in:
yessenia 2025-10-11 19:03:18 +08:00
parent 63dbc7c63d
commit 854a091f82
12 changed files with 222 additions and 65 deletions

View File

@ -0,0 +1,30 @@
import cn from '@/utils/classnames'
import { RiLock2Fill } from '@remixicon/react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
type Props = {
className?: string
frontTextKey?: string
backTextKey?: string
}
export const EncryptedBottom = (props: Props) => {
const { t } = useTranslation()
const { frontTextKey, backTextKey, className } = props
return (
<div className={cn('system-xs-regular flex items-center border-t-[0.5px] border-divider-subtle bg-background-soft px-2 py-3 text-text-tertiary', className)}>
<RiLock2Fill className='mx-1 h-3 w-3 text-text-quaternary' />
{t(frontTextKey || 'common.provider.encrypted.front')}
<Link
className='mx-1 text-text-accent'
target='_blank' rel='noopener noreferrer'
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</Link>
{t(backTextKey || 'common.provider.encrypted.back')}
</div>
)
}

View File

@ -1,6 +1,6 @@
import CheckboxList from '@/app/components/base/checkbox-list'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import type { FieldState, FormSchema } 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'
@ -31,6 +31,29 @@ const getExtraProps = (type: FormTypeEnum) => {
}
}
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
labelClassName?: string
@ -40,6 +63,7 @@ export type BaseFieldProps = {
field: AnyFieldApi
disabled?: boolean
onChange?: (field: string, value: any) => void
fieldState?: FieldState
}
const BaseField = ({
@ -51,6 +75,7 @@ const BaseField = ({
field,
disabled: propsDisabled,
onChange,
fieldState,
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
const {
@ -168,7 +193,7 @@ const BaseField = ({
<Input
id={field.name}
name={field.name}
className={cn(inputClassName)}
className={cn(inputClassName, VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus as FormItemValidateStatusEnum]?.componentClassName)}
value={value || ''}
onChange={(e) => {
handleChange(e.target.value)
@ -266,6 +291,14 @@ const BaseField = ({
</Radio.Group>
)
}
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
<div className={cn(
'system-xs-regular mt-1 px-0 py-[2px]',
VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].textClassName,
)}>
{fieldState?.[VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].infoFieldName as keyof FieldState]}
</div>
)}
{
formSchema.url && (
<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,
@ -72,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) => {
@ -85,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() {
@ -93,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)
@ -110,12 +145,13 @@ const BaseForm = ({
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)

View File

@ -45,6 +45,13 @@ export type FormOption = {
export type AnyValidators = FieldValidators<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
@ -79,11 +86,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>

View File

@ -1,5 +1,6 @@
'use client'
// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import { BaseForm } from '@/app/components/base/form/components/base'
import type { FormRefObject } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
@ -15,10 +16,11 @@ import {
useUpdateTriggerSubscriptionBuilder,
useVerifyTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { RiLoader2Line } from '@remixicon/react'
import { debounce } from 'lodash-es'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { debounce } from 'lodash-es'
import LogViewer from '../log-viewer'
import { usePluginStore, usePluginSubscriptionStore } from '../store'
@ -68,7 +70,6 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration)
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
const [verificationError, setVerificationError] = useState<string>('')
const isInitializedRef = useRef(false)
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyTriggerSubscriptionBuilder()
@ -175,7 +176,10 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
return
}
setVerificationError('')
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [],
}])
verifyCredentials(
{
@ -191,8 +195,12 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
})
setCurrentStep(ApiKeyStep.Configuration)
},
onError: (error: any) => {
setVerificationError(error?.message || t('pluginTrigger.modal.apiKey.verify.error'))
onError: async (error: any) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error')
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [errorMessage],
}])
},
},
)
@ -238,10 +246,11 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
onClose()
refresh?.()
},
onError: (error: any) => {
onError: async (error: any) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.errors.createFailed')
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.modal.errors.createFailed'),
message: errorMessage,
})
},
},
@ -255,6 +264,13 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
handleCreate()
}
const handleApiKeyCredentialsChange = () => {
apiKeyCredentialsFormRef.current?.setFields([{
name: apiKeyCredentialsSchema[0].name,
errors: [],
}])
}
return (
<Modal
title={t(`pluginTrigger.modal.${createType === SupportedCreationMethods.APIKEY ? 'apiKey' : createType.toLowerCase()}.title`)}
@ -266,6 +282,8 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isVerifyingCredentials || isBuilding}
bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null}
>
{createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />}
{currentStep === ApiKeyStep.Verify && (
@ -278,16 +296,10 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
labelClassName='system-sm-medium mb-2 block text-text-primary'
preventDefaultSubmit={true}
formClassName='space-y-4'
onChange={handleApiKeyCredentialsChange}
/>
</div>
)}
{verificationError && (
<div className='bg-state-destructive-bg mb-4 rounded-lg border border-state-destructive-border p-3'>
<div className='text-state-destructive-text system-xs-medium'>
{verificationError}
</div>
</div>
)}
</>
)}
{currentStep === ApiKeyStep.Configuration && <div className='max-h-[70vh] overflow-y-auto'>

View File

@ -21,30 +21,23 @@ export const SubscriptionList = withErrorBoundary(({
selectedId,
onSelect,
}: SubscriptionListProps) => {
const { subscriptions, isLoading, hasSubscriptions } = useSubscriptionList()
// console.log('detail', detail)
const { subscriptions, isLoading } = useSubscriptionList()
if (mode === SubscriptionListMode.SELECTOR) {
return (
<SubscriptionSelectorView
subscriptions={subscriptions}
isLoading={isLoading}
hasSubscriptions={hasSubscriptions}
selectedId={selectedId}
onSelect={onSelect}
/>
)
}
// const showTopBorder = !!(detail?.declaration?.tool || detail?.declaration?.endpoint)
return (
<SubscriptionListView
subscriptions={subscriptions}
isLoading={isLoading}
// showTopBorder={showTopBorder}
hasSubscriptions={hasSubscriptions}
/>
)
})

View File

@ -11,14 +11,12 @@ type SubscriptionListViewProps = {
subscriptions?: TriggerSubscription[]
isLoading: boolean
showTopBorder?: boolean
hasSubscriptions: boolean
}
export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
subscriptions,
isLoading,
showTopBorder = false,
hasSubscriptions,
}) => {
const { t } = useTranslation()
@ -35,7 +33,7 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
return (
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
<div className='relative mb-3 flex items-center justify-between'>
{hasSubscriptions && (
{subscriptions?.length && (
<div className='flex shrink-0 items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
@ -44,11 +42,11 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
</div>
)}
<CreateSubscriptionButton
buttonType={hasSubscriptions ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON}
buttonType={subscriptions?.length ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON}
/>
</div>
{hasSubscriptions && (
{subscriptions?.length && (
<div className='flex flex-col gap-1'>
{subscriptions?.map(subscription => (
<SubscriptionCard

View File

@ -1,10 +1,9 @@
'use client'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import cn from '@/utils/classnames'
import { RiCheckLine, RiDeleteBinLine } from '@remixicon/react'
import { RiCheckLine, RiDeleteBinLine, RiWebhookLine } from '@remixicon/react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreateButtonType, CreateSubscriptionButton } from './create'
@ -13,7 +12,6 @@ import { DeleteConfirm } from './delete-confirm'
type SubscriptionSelectorProps = {
subscriptions?: TriggerSubscription[]
isLoading: boolean
hasSubscriptions: boolean
selectedId?: string
onSelect?: ({ id, name }: { id: string, name: string }) => void
}
@ -21,7 +19,6 @@ type SubscriptionSelectorProps = {
export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
subscriptions,
isLoading,
hasSubscriptions,
selectedId,
onSelect,
}) => {
@ -38,7 +35,7 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
return (
<div className='w-[320px] p-1'>
{hasSubscriptions && <div className='ml-7 mr-1.5 mt-0.5 flex items-center justify-between'>
{subscriptions?.length && <div className='ml-7 mr-1.5 mt-0.5 flex items-center justify-between'>
<div className='flex shrink-0 items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
@ -50,7 +47,7 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
/>
</div>}
<div className='max-h-[320px] overflow-y-auto'>
{hasSubscriptions ? (
{subscriptions?.length ? (
<>
{subscriptions?.map(subscription => (
<button
@ -66,10 +63,7 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
{selectedId === subscription.id && (
<RiCheckLine className='mr-2 h-4 w-4 shrink-0 text-text-accent' />
)}
<Indicator
color={subscription.properties?.active !== false ? 'green' : 'red'}
className={cn('mr-1.5', selectedId !== subscription.id && 'ml-6')}
/>
<RiWebhookLine className={cn('mr-1.5 h-3.5 w-3.5 text-text-secondary', selectedId !== subscription.id && 'ml-6')} />
<span className='system-md-regular leading-6 text-text-secondary'>
{subscription.name}
</span>

View File

@ -1,6 +1,5 @@
'use client'
import ActionButton from '@/app/components/base/action-button'
import Indicator from '@/app/components/header/indicator'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import cn from '@/utils/classnames'
import {
@ -22,8 +21,6 @@ const SubscriptionCard = ({ data }: Props) => {
setFalse: hideDeleteModal,
}] = useBoolean(false)
const isActive = data.properties?.active !== false
return (
<>
<div
@ -35,28 +32,19 @@ const SubscriptionCard = ({ data }: Props) => {
)}
>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1'>
<div className='flex h-6 items-center gap-1'>
<RiWebhookLine className='h-4 w-4 text-text-secondary' />
<span className='system-md-semibold text-text-secondary'>
{data.name}
</span>
</div>
<div className='flex h-[26px] w-6 items-center justify-center group-hover:hidden'>
<Indicator
color={isActive ? 'green' : 'red'}
className=''
/>
</div>
<div className='hidden group-hover:block'>
<ActionButton
onClick={showDeleteModal}
className='subscription-delete-btn transition-colors hover:bg-state-destructive-hover hover:text-text-destructive'
>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
</div>
<ActionButton
onClick={showDeleteModal}
className='subscription-delete-btn hidden transition-colors hover:bg-state-destructive-hover hover:text-text-destructive group-hover:block'
>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
</div>
<div className='mt-1 flex items-center justify-between'>

View File

@ -18,6 +18,5 @@ export const useSubscriptionList = () => {
subscriptions,
isLoading,
refetch,
hasSubscriptions: !!(subscriptions && subscriptions.length > 0),
}
}

View File

@ -167,6 +167,7 @@ export const useVerifyTriggerSubscriptionBuilder = () => {
return post<{ verified: boolean }>(
`/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify/${subscriptionBuilderId}`,
{ body },
{ silent: true },
)
},
})

52
web/utils/error-parser.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* Parse plugin error message from nested error structure
* Extracts the real error message from PluginInvokeError JSON string
*
* @example
* Input: { message: "req_id: xxx PluginInvokeError: {\"message\":\"Bad credentials\"}" }
* Output: "Bad credentials"
*
* @param error - Error object (can be Response object or error with message property)
* @returns Promise<string> or string - Parsed error message
*/
export const parsePluginErrorMessage = async (error: any): Promise<string> => {
let rawMessage = ''
// Handle Response object from fetch/ky
if (error instanceof Response) {
try {
const body = await error.clone().json()
rawMessage = body?.message || error.statusText || 'Unknown error'
}
catch {
rawMessage = error.statusText || 'Unknown error'
}
}
else {
rawMessage = error?.message || error?.toString() || 'Unknown error'
}
console.log('rawMessage', rawMessage)
// Try to extract nested JSON from PluginInvokeError
// Use greedy match .+ to capture the complete JSON object with nested braces
const pluginErrorPattern = /PluginInvokeError:\s*(\{.+\})/
const match = rawMessage.match(pluginErrorPattern)
if (match) {
try {
const errorData = JSON.parse(match[1])
// Return the inner message if exists
if (errorData.message)
return errorData.message
// Fallback to error_type if message not available
if (errorData.error_type)
return errorData.error_type
}
catch (parseError) {
console.warn('Failed to parse plugin error JSON:', parseError)
}
}
return rawMessage
}