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
id={id}
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,
disabled && disabledClassName,
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 { FormTypeEnum } from '@/app/components/base/form/types'
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 { PortalSelect } from '@/app/components/base/select'
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'
@ -52,6 +54,7 @@ const BaseField = ({
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
const {
name,
label,
required,
placeholder,
@ -60,6 +63,8 @@ const BaseField = ({
disabled: formSchemaDisabled,
type: formItemType,
dynamicSelectParams,
multiple = false,
tooltip,
} = formSchema
const disabled = propsDisabled || formSchemaDisabled
@ -150,6 +155,12 @@ const BaseField = ({
<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 className={cn(inputContainerClassName)}>
{
@ -170,7 +181,7 @@ const BaseField = ({
)
}
{
formItemType === FormTypeEnum.select && (
formItemType === FormTypeEnum.select && !multiple && (
<PureSelect
value={value}
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 && (
<PortalSelect

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

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

View File

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

View File

@ -30,19 +30,6 @@ type Props = {
export const DEFAULT_METHOD = 'default'
/**
*
* 1.
* -
* 2.
* -
*
* oauth
* - client_id client_secret
* -
* -
* -
*/
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON }: Props) => {
const { t } = useTranslation()
const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null)

View File

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

View File

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