From 16ac05ebd5dde1fc65e57514d82b723ddd24f45a Mon Sep 17 00:00:00 2001 From: yessenia Date: Tue, 14 Oct 2025 14:53:00 +0800 Subject: [PATCH] feat: support search in checkbox list --- .../components/base/checkbox-list/index.tsx | 48 ++++++++++++++--- .../components/base/search-input/index.tsx | 15 +++--- .../subscription-list/create/common-modal.tsx | 53 ++++++++++++++++++- .../subscription-list/list-view.tsx | 2 +- web/assets/search-menu.svg | 7 +++ web/i18n/en-US/common.ts | 6 ++- web/i18n/en-US/plugin-trigger.ts | 1 + web/i18n/zh-Hans/common.ts | 6 ++- web/i18n/zh-Hans/plugin-trigger.ts | 1 + 9 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 web/assets/search-menu.svg diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index efb3d101da..cc0b561c90 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -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 = ({ 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 = ({ )}
- {(showSelectAll || title) && ( + {(showSelectAll || title || showSearch) && (
- {showSelectAll && ( + {!searchQuery && showSelectAll && ( = ({ disabled={disabled} /> )} -
+ {!searchQuery ?
{title && ( {title} @@ -116,7 +133,18 @@ const CheckboxList: FC = ({ {t('common.operation.selectCount', { count: selectedCount })} )} -
+
:
{ + filteredOptions.length > 0 + ? t('common.operation.searchCount', { count: filteredOptions.length, content: title }) + : t('common.operation.noSearchCount', { content: title })}
} + {showSearch && ( + + )}
)} @@ -124,12 +152,16 @@ const CheckboxList: FC = ({ className='p-1' style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}} > - {!options.length ? ( + {!filteredOptions.length ? (
- {t('common.noData')} + {searchQuery ?
+ search menu + {t('common.operation.noSearchResults', { content: title })} + +
: t('common.noData')}
) : ( - options.map((option) => { + filteredOptions.map((option) => { const selected = value.includes(option.value) return ( diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx index 3330b55330..abf1817e88 100644 --- a/web/app/components/base/search-input/index.tsx +++ b/web/app/components/base/search-input/index.tsx @@ -22,7 +22,7 @@ const SearchInput: FC = ({ const { t } = useTranslation() const [focus, setFocus] = useState(false) const isComposing = useRef(false) - const [internalValue, setInternalValue] = useState(value) + const [compositionValue, setCompositionValue] = useState('') return (
= ({ 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 = ({ className='group/clear flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center' onClick={() => { onChange('') - setInternalValue('') }} > diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx index e9fcf81d38..d07960b5f7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -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
{ }, [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) => { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx index 3af568ab2b..9828dcae57 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -24,7 +24,7 @@ export const SubscriptionListView: React.FC = ({
{subscriptionCount > 0 && ( -
+
{t('pluginTrigger.subscription.listNum', { num: subscriptionCount })} diff --git a/web/assets/search-menu.svg b/web/assets/search-menu.svg new file mode 100644 index 0000000000..8f7131c2ce --- /dev/null +++ b/web/assets/search-menu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 72f74d2511..bbed96e3b7 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -29,6 +29,11 @@ const translation = { refresh: 'Restart', reset: 'Reset', search: 'Search', + noSearchResults: 'No {{content}} were found', + resetKeywords: 'Reset keywords', + selectCount: '{{count}} Selected', + searchCount: 'Find {{count}} {{content}}', + noSearchCount: '0 {{content}}', change: 'Change', remove: 'Remove', send: 'Send', @@ -72,7 +77,6 @@ const translation = { more: 'More', selectAll: 'Select All', deSelectAll: 'Deselect All', - selectCount: '{{count}} Selected', }, errorMsg: { fieldRequired: '{{field}} is required', diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts index 91ea2ab93a..7078e6c054 100644 --- a/web/i18n/en-US/plugin-trigger.ts +++ b/web/i18n/en-US/plugin-trigger.ts @@ -143,6 +143,7 @@ const translation = { description: 'This URL will receive webhook events', tooltip: 'Provide a publicly accessible endpoint that can receive callback requests from the trigger provider.', placeholder: 'Generating...', + privateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail.', }, }, errors: { diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index b0351fe125..003cb7cec4 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -29,6 +29,11 @@ const translation = { refresh: '重新开始', reset: '重置', search: '搜索', + noSearchResults: '没有找到{{content}}', + resetKeywords: '重置关键词', + selectCount: '已选择 {{count}} 项', + searchCount: '找到 {{count}} 个 {{content}}', + noSearchCount: '0 个 {{content}}', change: '更改', remove: '移除', send: '发送', @@ -72,7 +77,6 @@ const translation = { selectAll: '全选', deSelectAll: '取消全选', now: '现在', - selectCount: '已选择 {{count}} 项', }, errorMsg: { fieldRequired: '{{field}} 为必填项', diff --git a/web/i18n/zh-Hans/plugin-trigger.ts b/web/i18n/zh-Hans/plugin-trigger.ts index 8c91ad1042..8c5a309feb 100644 --- a/web/i18n/zh-Hans/plugin-trigger.ts +++ b/web/i18n/zh-Hans/plugin-trigger.ts @@ -143,6 +143,7 @@ const translation = { description: '此 URL 将接收Webhook事件', tooltip: '填写能被触发器提供方访问的公网地址,用于接收回调请求。', placeholder: '生成中...', + privateAddressWarning: '此 URL 似乎是一个内部地址,可能会导致 Webhook 请求失败。', }, }, errors: {