feat: support search in checkbox list

This commit is contained in:
yessenia
2025-10-14 14:53:00 +08:00
parent ac77b9b735
commit 16ac05ebd5
9 changed files with 120 additions and 19 deletions

View File

@ -1,10 +1,14 @@
'use client'
import Badge from '@/app/components/base/badge'
import Checkbox from '@/app/components/base/checkbox'
import SearchInput from '@/app/components/base/search-input'
import SearchMenu from '@/assets/search-menu.svg'
import cn from '@/utils/classnames'
import Image from 'next/image'
import type { FC } from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '../button'
export type CheckboxListOption = {
label: string
@ -23,6 +27,7 @@ export type CheckboxListProps = {
containerClassName?: string
showSelectAll?: boolean
showCount?: boolean
showSearch?: boolean
maxHeight?: string | number
}
@ -37,9 +42,21 @@ const CheckboxList: FC<CheckboxListProps> = ({
containerClassName,
showSelectAll = true,
showCount = true,
showSearch = true,
maxHeight,
}) => {
const { t } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const filteredOptions = useMemo(() => {
if (!searchQuery?.trim())
return options
const query = searchQuery.toLowerCase()
return options.filter(option =>
option.label.toLowerCase().includes(query) || option.value.toLowerCase().includes(query),
)
}, [options, searchQuery])
const selectedCount = value.length
@ -95,9 +112,9 @@ const CheckboxList: FC<CheckboxListProps> = ({
)}
<div className='rounded-lg border border-components-panel-border bg-components-panel-bg'>
{(showSelectAll || title) && (
{(showSelectAll || title || showSearch) && (
<div className='relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2'>
{showSelectAll && (
{!searchQuery && showSelectAll && (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
@ -105,7 +122,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
disabled={disabled}
/>
)}
<div className='flex flex-1 items-center gap-1'>
{!searchQuery ? <div className='flex flex-1 items-center gap-1'>
{title && (
<span className='system-xs-semibold-uppercase leading-5 text-text-secondary'>
{title}
@ -116,7 +133,18 @@ const CheckboxList: FC<CheckboxListProps> = ({
{t('common.operation.selectCount', { count: selectedCount })}
</Badge>
)}
</div>
</div> : <div className='system-sm-medium-uppercase flex-1 leading-6 text-text-secondary'>{
filteredOptions.length > 0
? t('common.operation.searchCount', { count: filteredOptions.length, content: title })
: t('common.operation.noSearchCount', { content: title })}</div>}
{showSearch && (
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('common.placeholder.search')}
className='w-40'
/>
)}
</div>
)}
@ -124,12 +152,16 @@ const CheckboxList: FC<CheckboxListProps> = ({
className='p-1'
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
>
{!options.length ? (
{!filteredOptions.length ? (
<div className='px-3 py-6 text-center text-sm text-text-tertiary'>
{t('common.noData')}
{searchQuery ? <div className='flex flex-col items-center justify-center gap-2'>
<Image alt='search menu' src={SearchMenu} width={32} />
<span className='system-sm-regular text-text-secondary'>{t('common.operation.noSearchResults', { content: title })}</span>
<Button variant='secondary-accent' size='small' onClick={() => setSearchQuery('')}>{t('common.operation.resetKeywords')}</Button>
</div> : t('common.noData')}
</div>
) : (
options.map((option) => {
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (

View File

@ -22,7 +22,7 @@ const SearchInput: FC<SearchInputProps> = ({
const { t } = useTranslation()
const [focus, setFocus] = useState<boolean>(false)
const isComposing = useRef<boolean>(false)
const [internalValue, setInternalValue] = useState<string>(value)
const [compositionValue, setCompositionValue] = useState<string>('')
return (
<div className={cn(
@ -42,17 +42,21 @@ const SearchInput: FC<SearchInputProps> = ({
white && '!bg-white placeholder:!text-gray-400 hover:!bg-white group-hover:!bg-white',
)}
placeholder={placeholder || t('common.operation.search')!}
value={internalValue}
value={isComposing.current ? compositionValue : value}
onChange={(e) => {
setInternalValue(e.target.value)
if (!isComposing.current)
onChange(e.target.value)
const newValue = e.target.value
if (isComposing.current)
setCompositionValue(newValue)
else
onChange(newValue)
}}
onCompositionStart={() => {
isComposing.current = true
setCompositionValue(value)
}}
onCompositionEnd={(e) => {
isComposing.current = false
setCompositionValue('')
onChange(e.currentTarget.value)
}}
onFocus={() => setFocus(true)}
@ -64,7 +68,6 @@ const SearchInput: FC<SearchInputProps> = ({
className='group/clear flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center'
onClick={() => {
onChange('')
setInternalValue('')
}}
>
<RiCloseCircleFill className='h-4 w-4 text-text-quaternary group-hover/clear:text-text-tertiary' />

View File

@ -41,6 +41,43 @@ enum ApiKeyStep {
Configuration = 'configuration',
}
// Check if URL is a private/local network address
const isPrivateOrLocalAddress = (url: string): boolean => {
try {
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
// Check for localhost
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1')
return true
// Check for private IP ranges
const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
const ipv4Match = hostname.match(ipv4Regex)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10)
return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31)
return true
// 192.168.0.0/16
if (a === 192 && b === 168)
return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254)
return true
}
// Check for .local domains
return hostname.endsWith('.local')
}
catch {
return false
}
}
const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => {
return <div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
? 'text-state-accent-solid'
@ -120,12 +157,24 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
useEffect(() => {
if (subscriptionBuilder?.endpoint && subscriptionFormRef.current) {
if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) {
const form = subscriptionFormRef.current.getForm()
if (form)
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) {
subscriptionFormRef.current?.setFields([{
name: 'callback_url',
warnings: [t('pluginTrigger.modal.form.callbackUrl.privateAddressWarning')],
}])
}
else {
subscriptionFormRef.current?.setFields([{
name: 'callback_url',
warnings: [],
}])
}
}
}, [subscriptionBuilder?.endpoint])
}, [subscriptionBuilder?.endpoint, currentStep, t])
const debouncedUpdate = useMemo(
() => debounce((provider: string, builderId: string, properties: Record<string, any>) => {

View File

@ -24,7 +24,7 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
<div className='relative mb-3 flex items-center justify-between'>
{subscriptionCount > 0 && (
<div className='flex shrink-0 items-center gap-1'>
<div className='flex h-8 shrink-0 items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptionCount })}
</span>