mirror of https://github.com/langgenius/dify.git
feat: support search in checkbox list
This commit is contained in:
parent
ac77b9b735
commit
16ac05ebd5
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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' />
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.00488 16H6.67155" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.00488 9.33301H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.00488 22.667H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26 22L29.3333 25.3333" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 822 B |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}} 为必填项',
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ const translation = {
|
|||
description: '此 URL 将接收Webhook事件',
|
||||
tooltip: '填写能被触发器提供方访问的公网地址,用于接收回调请求。',
|
||||
placeholder: '生成中...',
|
||||
privateAddressWarning: '此 URL 似乎是一个内部地址,可能会导致 Webhook 请求失败。',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue