feat: add checkbox list

This commit is contained in:
yessenia
2025-09-28 11:45:12 +08:00
parent 4d49db0ff9
commit 3edf1e2f59
9 changed files with 249 additions and 66 deletions

View File

@ -0,0 +1,171 @@
'use client'
import Badge from '@/app/components/base/badge'
import Checkbox from '@/app/components/base/checkbox'
import cn from '@/utils/classnames'
import type { FC } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type CheckboxListOption = {
label: string
value: string
disabled?: boolean
}
export type CheckboxListProps = {
title?: string
label?: string
description?: string
options: CheckboxListOption[]
value?: string[]
onChange?: (value: string[]) => void
disabled?: boolean
containerClassName?: string
showSelectAll?: boolean
showCount?: boolean
maxHeight?: string | number
}
const CheckboxList: FC<CheckboxListProps> = ({
title = '',
label,
description,
options,
value = [],
onChange,
disabled = false,
containerClassName,
showSelectAll = true,
showCount = true,
maxHeight,
}) => {
const { t } = useTranslation()
const selectedCount = value.length
const isAllSelected = useMemo(() => {
const selectableOptions = options.filter(option => !option.disabled)
return selectableOptions.length > 0 && selectableOptions.every(option => value.includes(option.value))
}, [options, value])
const isIndeterminate = useMemo(() => {
const selectableOptions = options.filter(option => !option.disabled)
const selectedCount = selectableOptions.filter(option => value.includes(option.value)).length
return selectedCount > 0 && selectedCount < selectableOptions.length
}, [options, value])
const handleSelectAll = useCallback(() => {
if (disabled)
return
if (isAllSelected) {
// Deselect all
onChange?.([])
}
else {
// Select all non-disabled options
const allValues = options
.filter(option => !option.disabled)
.map(option => option.value)
onChange?.(allValues)
}
}, [isAllSelected, options, onChange, disabled])
const handleToggleOption = useCallback((optionValue: string) => {
if (disabled)
return
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: [...value, optionValue]
onChange?.(newValue)
}, [value, onChange, disabled])
return (
<div className={cn('flex flex-col gap-1', containerClassName)}>
{label && (
<div className='system-sm-medium text-text-secondary'>
{label}
</div>
)}
{description && (
<div className='body-xs-regular text-text-tertiary'>
{description}
</div>
)}
<div className='rounded-lg border border-components-panel-border bg-components-panel-bg'>
{(showSelectAll || title) && (
<div className='relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2'>
{showSelectAll && (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onCheck={handleSelectAll}
disabled={disabled}
/>
)}
<div className='flex flex-1 items-center gap-1'>
{title && (
<span className='system-xs-semibold-uppercase leading-5 text-text-secondary'>
{title}
</span>
)}
{showCount && selectedCount > 0 && (
<Badge uppercase>
{t('common.operation.selectCount', { count: selectedCount })}
</Badge>
)}
</div>
</div>
)}
<div
className='p-1'
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
>
{!options.length ? (
<div className='px-3 py-6 text-center text-sm text-text-tertiary'>
{t('common.noData')}
</div>
) : (
options.map((option) => {
const selected = value.includes(option.value)
return (
<div
key={option.value}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
option.disabled && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
>
<Checkbox
checked={selected}
onCheck={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
disabled={option.disabled || disabled}
/>
<div
className='system-sm-medium flex-1 truncate text-text-secondary'
title={option.label}
>
{option.label}
</div>
</div>
)
})
)}
</div>
</div>
</div>
)
}
export default CheckboxList

View File

@ -30,7 +30,7 @@ const Checkbox = ({
<div <div
id={id} id={id}
className={cn( className={cn(
'flex h-4 w-4 cursor-pointer items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3', 'flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
checkClassName, checkClassName,
disabled && disabledClassName, disabled && disabledClassName,
className, className,

View File

@ -1,3 +1,4 @@
import CheckboxList from '@/app/components/base/checkbox-list'
import type { FormSchema } from '@/app/components/base/form/types' import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
@ -5,6 +6,7 @@ import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui' import RadioE from '@/app/components/base/radio/ui'
import { PortalSelect } from '@/app/components/base/select' import { PortalSelect } from '@/app/components/base/select'
import PureSelect from '@/app/components/base/select/pure' import PureSelect from '@/app/components/base/select/pure'
import Tooltip from '@/app/components/base/tooltip'
import { useRenderI18nObject } from '@/hooks/use-i18n' import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
@ -52,6 +54,7 @@ const BaseField = ({
}: BaseFieldProps) => { }: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject() const renderI18nObject = useRenderI18nObject()
const { const {
name,
label, label,
required, required,
placeholder, placeholder,
@ -60,6 +63,8 @@ const BaseField = ({
disabled: formSchemaDisabled, disabled: formSchemaDisabled,
type: formItemType, type: formItemType,
dynamicSelectParams, dynamicSelectParams,
multiple = false,
tooltip,
} = formSchema } = formSchema
const disabled = propsDisabled || formSchemaDisabled const disabled = propsDisabled || formSchemaDisabled
@ -150,6 +155,12 @@ const BaseField = ({
<span className='ml-1 text-text-destructive-secondary'>*</span> <span className='ml-1 text-text-destructive-secondary'>*</span>
) )
} }
{tooltip && (
<Tooltip
popupContent={<div className='w-[200px]'>{typeof tooltip === 'string' ? tooltip : renderI18nObject(tooltip as Record<string, string>)}</div>}
triggerClassName='ml-0.5 w-4 h-4'
/>
)}
</div> </div>
<div className={cn(inputContainerClassName)}> <div className={cn(inputContainerClassName)}>
{ {
@ -170,7 +181,7 @@ const BaseField = ({
) )
} }
{ {
formItemType === FormTypeEnum.select && ( formItemType === FormTypeEnum.select && !multiple && (
<PureSelect <PureSelect
value={value} value={value}
onChange={v => handleChange(v)} onChange={v => handleChange(v)}
@ -184,6 +195,17 @@ const BaseField = ({
/> />
) )
} }
{
formItemType === FormTypeEnum.select && multiple && (
<CheckboxList
title={name}
value={value}
onChange={v => field.handleChange(v)}
options={memorizedOptions}
maxHeight='200px'
/>
)
}
{ {
formItemType === FormTypeEnum.dynamicSelect && ( formItemType === FormTypeEnum.dynamicSelect && (
<PortalSelect <PortalSelect

View File

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

View File

@ -50,6 +50,7 @@ export type FormSchema = {
name: string name: string
label: string | ReactNode | TypeWithI18N | Record<Locale, string> label: string | ReactNode | TypeWithI18N | Record<Locale, string>
required: boolean required: boolean
multiple?: boolean
default?: any default?: any
tooltip?: string | TypeWithI18N | Record<Locale, string> tooltip?: string | TypeWithI18N | Record<Locale, string>
show_on?: FormShowOnObject[] show_on?: FormShowOnObject[]

View File

@ -1,9 +1,8 @@
'use client' 'use client'
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' // import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
import { BaseForm } from '@/app/components/base/form/components/base' import { BaseForm } from '@/app/components/base/form/components/base'
import type { FormRefObject } from '@/app/components/base/form/types' import type { FormRefObject } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal/modal' import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { SupportedCreationMethods } from '@/app/components/plugins/types' import { SupportedCreationMethods } from '@/app/components/plugins/types'
@ -66,7 +65,6 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration) const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration)
const [subscriptionName, setSubscriptionName] = useState('')
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
const [verificationError, setVerificationError] = useState<string>('') const [verificationError, setVerificationError] = useState<string>('')
@ -76,6 +74,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const providerName = `${detail?.plugin_id}/${detail?.declaration.name}` const providerName = `${detail?.plugin_id}/${detail?.declaration.name}`
const propertiesSchema = detail?.declaration.trigger.subscription_schema.properties_schema || [] // manual const propertiesSchema = detail?.declaration.trigger.subscription_schema.properties_schema || [] // manual
const subscriptionFormRef = React.useRef<FormRefObject>(null)
const propertiesFormRef = React.useRef<FormRefObject>(null) const propertiesFormRef = React.useRef<FormRefObject>(null)
const parametersSchema = detail?.declaration.trigger?.subscription_schema?.parameters_schema || [] // apikey and oauth const parametersSchema = detail?.declaration.trigger?.subscription_schema?.parameters_schema || [] // apikey and oauth
const parametersFormRef = React.useRef<FormRefObject>(null) const parametersFormRef = React.useRef<FormRefObject>(null)
@ -151,32 +150,23 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
} }
const handleCreate = () => { const handleCreate = () => {
if (!subscriptionName.trim()) { const parameterForm = parametersFormRef.current?.getFormValues({}) || { values: {}, isCheckValidated: false }
Toast.notify({ const subscriptionForm = subscriptionFormRef.current?.getFormValues({})
type: 'error', // console.log('parameterForm', parameterForm)
message: t('pluginTrigger.modal.form.subscriptionName.required'),
}) if (!subscriptionForm?.isCheckValidated || !parameterForm?.isCheckValidated)
return return
}
if (!subscriptionBuilder) if (!subscriptionBuilder)
return return
const parameterForm = parametersFormRef.current?.getFormValues({}) || { values: {}, isCheckValidated: false } const subscriptionNameValue = subscriptionForm.values.subscription_name as string
// console.log('formValues', formValues)
// if (!formValues.isCheckValidated) {
// Toast.notify({
// type: 'error',
// message: t('pluginTrigger.modal.form.properties.required'),
// })
// return
// }
buildSubscription( buildSubscription(
{ {
provider: providerName, provider: providerName,
subscriptionBuilderId: subscriptionBuilder.id, subscriptionBuilderId: subscriptionBuilder.id,
name: subscriptionName, name: subscriptionNameValue,
parameters: { ...parameterForm.values, events: ['*'] }, parameters: { ...parameterForm.values, events: ['*'] },
// properties: formValues.values, // properties: formValues.values,
}, },
@ -228,6 +218,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
ref={credentialsFormRef} ref={credentialsFormRef}
labelClassName='system-sm-medium mb-2 block text-text-primary' labelClassName='system-sm-medium mb-2 block text-text-primary'
preventDefaultSubmit={true} preventDefaultSubmit={true}
formClassName='space-y-4'
/> />
</div> </div>
)} )}
@ -241,34 +232,39 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
</> </>
)} )}
{currentStep === ApiKeyStep.Configuration && <div className='max-h-[70vh] overflow-y-auto'> {currentStep === ApiKeyStep.Configuration && <div className='max-h-[70vh] overflow-y-auto'>
<div className='mb-6'> <BaseForm
<label className='system-sm-medium mb-2 block text-text-primary'> formSchemas={[
{t('pluginTrigger.modal.form.subscriptionName.label')} {
</label> name: 'subscription_name',
<Input label: t('pluginTrigger.modal.form.subscriptionName.label'),
value={subscriptionName} placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'),
onChange={e => setSubscriptionName(e.target.value)} type: FormTypeEnum.textInput,
placeholder={t('pluginTrigger.modal.form.subscriptionName.placeholder')} required: true,
/> },
</div> {
name: 'callback_url',
<div className='mb-6'> label: t('pluginTrigger.modal.form.callbackUrl.label'),
<label className='system-sm-medium mb-2 block text-text-primary'> placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'),
{t('pluginTrigger.modal.form.callbackUrl.label')} type: FormTypeEnum.textInput,
</label> required: false,
<div className='relative'> default: subscriptionBuilder?.endpoint || '',
<Input disabled: true,
value={subscriptionBuilder?.endpoint} tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'),
readOnly // extra: subscriptionBuilder?.endpoint ? (
className='pr-12' // <CopyFeedbackNew
placeholder={t('pluginTrigger.modal.form.callbackUrl.placeholder')} // className='absolute right-1 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary'
/> // content={subscriptionBuilder?.endpoint || ''}
<CopyFeedbackNew className='absolute right-1 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary' content={subscriptionBuilder?.endpoint || ''} /> // />
</div> // ) : undefined,
<div className='system-xs-regular mt-1 text-text-tertiary'> },
{t('pluginTrigger.modal.form.callbackUrl.description')} ]}
</div> ref={subscriptionFormRef}
</div> labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary'
formClassName='space-y-4 mb-4'
/>
{/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'>
{t('pluginTrigger.modal.form.callbackUrl.description')}
</div> */}
{createType !== SupportedCreationMethods.MANUAL && parametersSchema.length > 0 && ( {createType !== SupportedCreationMethods.MANUAL && parametersSchema.length > 0 && (
<BaseForm <BaseForm
formSchemas={parametersSchema.map(schema => ({ formSchemas={parametersSchema.map(schema => ({
@ -283,6 +279,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
}))} }))}
ref={parametersFormRef} ref={parametersFormRef}
labelClassName='system-sm-medium mb-2 block text-text-primary' labelClassName='system-sm-medium mb-2 block text-text-primary'
formClassName='space-y-4'
/> />
)} )}
{createType === SupportedCreationMethods.MANUAL && <> {createType === SupportedCreationMethods.MANUAL && <>
@ -292,6 +289,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
formSchemas={propertiesSchema} formSchemas={propertiesSchema}
ref={propertiesFormRef} ref={propertiesFormRef}
labelClassName='system-sm-medium mb-2 block text-text-primary' labelClassName='system-sm-medium mb-2 block text-text-primary'
formClassName='space-y-4'
/> />
</div> </div>
)} )}
@ -311,11 +309,9 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
Awaiting request from {detail?.declaration.name}... Awaiting request from {detail?.declaration.name}...
</div> </div>
</div> </div>
<LogViewer logs={logData?.logs || []} /> <LogViewer logs={logData?.logs || []} />
</div> </div>
</>} </>}
</div>} </div>}
</Modal> </Modal>
) )

View File

@ -30,19 +30,6 @@ type Props = {
export const DEFAULT_METHOD = 'default' export const DEFAULT_METHOD = 'default'
/**
* 区分创建订阅的授权方式有几种
* 1. 只有一种授权方式
* - 按钮直接显示授权方式,点击按钮展示创建订阅弹窗
* 2. 有多种授权方式
* - 下拉框显示授权方式,点击按钮展示下拉框,点击选项展示创建订阅弹窗
* 有订阅与无订阅时,按钮形态不同
* oauth 的授权类型:
* - 是否配置 client_id 和 client_secret
* - 未配置则点击按钮去配置
* - 已配置则点击按钮去创建
* - 固定展示设置按钮
*/
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON }: Props) => { export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null) const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null)

View File

@ -68,6 +68,7 @@ const translation = {
more: 'More', more: 'More',
selectAll: 'Select All', selectAll: 'Select All',
deSelectAll: 'Deselect All', deSelectAll: 'Deselect All',
selectCount: '{{count}} Selected',
}, },
errorMsg: { errorMsg: {
fieldRequired: '{{field}} is required', fieldRequired: '{{field}} is required',
@ -76,7 +77,9 @@ const translation = {
placeholder: { placeholder: {
input: 'Please enter', input: 'Please enter',
select: 'Please select', select: 'Please select',
search: 'Search...',
}, },
noData: 'No data',
label: { label: {
optional: '(optional)', optional: '(optional)',
}, },

View File

@ -68,6 +68,7 @@ const translation = {
selectAll: '全选', selectAll: '全选',
deSelectAll: '取消全选', deSelectAll: '取消全选',
now: '现在', now: '现在',
selectCount: '已选择 {{count}} 项',
}, },
errorMsg: { errorMsg: {
fieldRequired: '{{field}} 为必填项', fieldRequired: '{{field}} 为必填项',
@ -76,7 +77,9 @@ const translation = {
placeholder: { placeholder: {
input: '请输入', input: '请输入',
select: '请选择', select: '请选择',
search: '搜索...',
}, },
noData: '暂无数据',
label: { label: {
optional: '(可选)', optional: '(可选)',
}, },