mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
refactor(web): align search input with dify ui (#37101)
This commit is contained in:
parent
e40b30d746
commit
23cd129802
@ -660,9 +660,6 @@
|
||||
"web/app/components/apps/list.tsx": {
|
||||
"no-restricted-globals": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/apps/new-app-card.tsx": {
|
||||
@ -1744,9 +1741,6 @@
|
||||
"web/app/components/base/search-input/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 3
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/svg-gallery/index.tsx": {
|
||||
|
||||
@ -203,9 +203,9 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../new-app-card', () => ({
|
||||
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||
default: (_props: { ref?: React.Ref<unknown> }) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
@ -293,7 +293,7 @@ describe('List', () => {
|
||||
|
||||
it('should render search input', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
@ -360,13 +360,13 @@ describe('List', () => {
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('test search')
|
||||
@ -377,10 +377,7 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -505,7 +502,7 @@ describe('List', () => {
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@ -8,7 +7,7 @@ import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -39,9 +38,9 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
}
|
||||
const List: FC<Props> = ({
|
||||
function List({
|
||||
controlRefreshList = 0,
|
||||
}) => {
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
@ -224,13 +223,12 @@ const List: FC<Props> = ({
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
<SearchInput
|
||||
className="w-52"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
onValueChange={setKeywords}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import { CheckboxList } from '..'
|
||||
|
||||
describe('checkbox list component', () => {
|
||||
const selectAllName = 'common.operation.selectAll'
|
||||
const getSearchInput = () => screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
const options = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
@ -30,7 +31,7 @@ describe('checkbox list component', () => {
|
||||
it('filters options by label', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = getSearchInput()
|
||||
await userEvent.type(input, 'app')
|
||||
|
||||
expect(screen.getByText('Apple'))!.toBeInTheDocument()
|
||||
@ -116,7 +117,7 @@ describe('checkbox list component', () => {
|
||||
|
||||
it('hides select-all checkbox when searching', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
await userEvent.type(screen.getByRole('textbox'), 'app')
|
||||
await userEvent.type(getSearchInput(), 'app')
|
||||
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -182,7 +183,7 @@ describe('checkbox list component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = getSearchInput()
|
||||
await userEvent.type(input, 'ban')
|
||||
await userEvent.click(screen.getByText('common.operation.resetKeywords'))
|
||||
expect(input)!.toHaveValue('')
|
||||
@ -293,7 +294,7 @@ describe('checkbox list component', () => {
|
||||
it('filters options correctly when searching', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = getSearchInput()
|
||||
await userEvent.type(input, 'option')
|
||||
|
||||
expect(screen.getByText('Option 1'))!.toBeInTheDocument()
|
||||
@ -305,7 +306,7 @@ describe('checkbox list component', () => {
|
||||
it('shows no data message when no options match search', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = getSearchInput()
|
||||
await userEvent.type(input, 'xyz')
|
||||
|
||||
expect(screen.getByText(/common.operation.noSearchResults/i))!.toBeInTheDocument()
|
||||
@ -372,7 +373,7 @@ describe('checkbox list component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = getSearchInput()
|
||||
await userEvent.type(input, 'opt')
|
||||
|
||||
expect(screen.getByText(/operation.searchCount/i))!.toBeInTheDocument()
|
||||
|
||||
@ -8,7 +8,7 @@ import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import SearchMenu from '@/assets/search-menu.svg'
|
||||
|
||||
type CheckboxListOption = {
|
||||
@ -134,7 +134,7 @@ export const CheckboxList = ({
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={t('placeholder.search', { ns: 'common' })}
|
||||
className="w-40"
|
||||
/>
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SearchInput from '..'
|
||||
import { useState } from 'react'
|
||||
import { SearchInput } from '..'
|
||||
|
||||
describe('SearchInput', () => {
|
||||
describe('Render', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<SearchInput value="" onChange={() => {}} />)
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
render(<SearchInput value="" onValueChange={() => {}} />)
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('')
|
||||
expect(input).toHaveAttribute('name', 'query')
|
||||
expect(input).toHaveAttribute('autocomplete', 'off')
|
||||
})
|
||||
|
||||
it('renders custom placeholder', () => {
|
||||
render(<SearchInput value="" onChange={() => {}} placeholder="Custom Placeholder" />)
|
||||
expect(screen.getByPlaceholderText('Custom Placeholder')).toBeInTheDocument()
|
||||
render(<SearchInput value="" onValueChange={() => {}} placeholder="Custom Placeholder" />)
|
||||
expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveAttribute('placeholder', 'Custom Placeholder')
|
||||
})
|
||||
|
||||
it('uses custom aria label', () => {
|
||||
render(<SearchInput value="" onValueChange={() => {}} aria-label="Search providers" />)
|
||||
expect(screen.getByRole('searchbox', { name: 'Search providers' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows clear button when value is present', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SearchInput value="has value" onChange={onChange} />)
|
||||
const onValueChange = vi.fn()
|
||||
render(<SearchInput value="has value" onValueChange={onValueChange} />)
|
||||
|
||||
const clearButton = screen.getByLabelText('common.operation.clear')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
@ -25,65 +33,113 @@ describe('SearchInput', () => {
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('calls onChange when typing', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SearchInput value="" onChange={onChange} />)
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
it('calls onValueChange when typing', () => {
|
||||
const onValueChange = vi.fn()
|
||||
render(<SearchInput value="" onValueChange={onValueChange} />)
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
expect(onChange).toHaveBeenCalledWith('test')
|
||||
expect(onValueChange).toHaveBeenCalledWith('test')
|
||||
})
|
||||
|
||||
it('handles composition events', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SearchInput value="initial" onChange={onChange} />)
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
const onValueChange = vi.fn()
|
||||
render(<SearchInput value="initial" onValueChange={onValueChange} />)
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
|
||||
// Start composition
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: 'final' } })
|
||||
|
||||
// While composing, onChange should NOT be called
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
expect(input).toHaveValue('final')
|
||||
|
||||
// End composition
|
||||
fireEvent.compositionEnd(input)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith('final')
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange).toHaveBeenCalledWith('final')
|
||||
})
|
||||
|
||||
it('calls onChange with empty string when clear button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SearchInput value="has value" onChange={onChange} />)
|
||||
it('does not keep stale composition commits after the next distinct change', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
function ControlledSearchInput() {
|
||||
const [value, setValue] = useState('initial')
|
||||
|
||||
return (
|
||||
<SearchInput
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
onValueChange(nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: 'final' } })
|
||||
fireEvent.compositionEnd(input)
|
||||
fireEvent.change(input, { target: { value: 'finalx' } })
|
||||
fireEvent.change(input, { target: { value: 'final' } })
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(3)
|
||||
expect(onValueChange).toHaveBeenNthCalledWith(1, 'final')
|
||||
expect(onValueChange).toHaveBeenNthCalledWith(2, 'finalx')
|
||||
expect(onValueChange).toHaveBeenNthCalledWith(3, 'final')
|
||||
})
|
||||
|
||||
it('clears composition value without committing stale text', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
function ControlledSearchInput() {
|
||||
const [value, setValue] = useState('initial')
|
||||
|
||||
return (
|
||||
<SearchInput
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
onValueChange(nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.change(input, { target: { value: 'final' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
fireEvent.compositionEnd(input)
|
||||
|
||||
expect(input).toHaveValue('')
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('calls onValueChange with empty string when clear button is clicked', () => {
|
||||
const onValueChange = vi.fn()
|
||||
render(<SearchInput value="has value" onValueChange={onValueChange} />)
|
||||
|
||||
const clearButton = screen.getByLabelText('common.operation.clear')
|
||||
fireEvent.click(clearButton)
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
expect(onValueChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('updates focus state on focus/blur', () => {
|
||||
const { container } = render(<SearchInput value="" onChange={() => {}} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
|
||||
fireEvent.focus(input)
|
||||
expect(wrapper).toHaveClass(/bg-components-input-bg-active/)
|
||||
|
||||
fireEvent.blur(input)
|
||||
expect(wrapper).not.toHaveClass(/bg-components-input-bg-active/)
|
||||
it('uses dify-ui input spacing for the search adornment', () => {
|
||||
render(<SearchInput value="" onValueChange={() => {}} />)
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
expect(input).toHaveClass('ps-7')
|
||||
expect(input).not.toHaveClass('h-[18px]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Style', () => {
|
||||
it('applies white style', () => {
|
||||
const { container } = render(<SearchInput value="" onChange={() => {}} white />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('bg-white!')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<SearchInput value="" onChange={() => {}} className="custom-test" />)
|
||||
const { container } = render(<SearchInput value="" onValueChange={() => {}} className="custom-test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-test')
|
||||
})
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { useState } from 'react'
|
||||
import SearchInput from '.'
|
||||
import { SearchInput } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/SearchInput',
|
||||
@ -20,25 +21,21 @@ const meta = {
|
||||
control: 'text',
|
||||
description: 'Search input value',
|
||||
},
|
||||
onChange: {
|
||||
action: 'changed',
|
||||
description: 'Change handler',
|
||||
onValueChange: {
|
||||
action: 'value changed',
|
||||
description: 'Value change handler',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
white: {
|
||||
control: 'boolean',
|
||||
description: 'White background variant',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onChange: (v) => {
|
||||
onValueChange: (v: string) => {
|
||||
console.log('Search value changed:', v)
|
||||
},
|
||||
},
|
||||
@ -47,8 +44,7 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SearchInputDemo = (args: any) => {
|
||||
const SearchInputDemo = (args: ComponentProps<typeof SearchInput>) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
@ -56,7 +52,7 @@ const SearchInputDemo = (args: any) => {
|
||||
<SearchInput
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onValueChange={(v: string) => {
|
||||
setValue(v)
|
||||
console.log('Search value changed:', v)
|
||||
}}
|
||||
@ -77,31 +73,19 @@ export const Default: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
value: '',
|
||||
onChange: (v) => {
|
||||
onValueChange: (v: string) => {
|
||||
console.log('Search value changed:', v)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// White variant
|
||||
export const WhiteBackground: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search...',
|
||||
white: true,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Initial search query',
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
@ -110,7 +94,6 @@ export const CustomPlaceholder: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search documents, files, and more...',
|
||||
white: false,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
@ -138,7 +121,7 @@ const UserListSearchDemo = () => {
|
||||
<h3 className="mb-4 text-lg font-semibold">Team Members</h3>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder="Search by name, email, or role..."
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
@ -212,9 +195,8 @@ const ProductSearchDemo = () => {
|
||||
<h3 className="mb-4 text-lg font-semibold">Product Catalog</h3>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder="Search products..."
|
||||
white
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{filteredProducts.length > 0
|
||||
@ -272,10 +254,8 @@ const DocumentationSearchDemo = () => {
|
||||
<p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder="Search documentation..."
|
||||
white
|
||||
className="h-10!"
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filteredDocs.length > 0
|
||||
@ -338,10 +318,8 @@ const CommandPaletteDemo = () => {
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder="Type a command or search..."
|
||||
white
|
||||
className="h-10!"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
@ -413,13 +391,13 @@ const LiveSearchWithCountDemo = () => {
|
||||
</div>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder="Search resources..."
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
{filteredItems.map((item, index) => (
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={index}
|
||||
key={item}
|
||||
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
<div className="text-sm font-medium">{item}</div>
|
||||
@ -445,24 +423,22 @@ const SizeVariationsDemo = () => {
|
||||
<div style={{ width: '500px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label>
|
||||
<SearchInput value={value1} onChange={setValue1} placeholder="Search..." />
|
||||
<SearchInput value={value1} onValueChange={setValue1} placeholder="Search..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label>
|
||||
<SearchInput
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
onValueChange={setValue2}
|
||||
placeholder="Search..."
|
||||
className="h-10!"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label>
|
||||
<SearchInput
|
||||
value={value3}
|
||||
onChange={setValue3}
|
||||
onValueChange={setValue3}
|
||||
placeholder="Search..."
|
||||
className="h-12!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -480,6 +456,5 @@ export const Playground: Story = {
|
||||
args: {
|
||||
value: '',
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,86 +1,100 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCloseCircleFill, RiSearchLine } from '@remixicon/react'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SearchInputProps = {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
white?: boolean
|
||||
}
|
||||
} & Pick<ComponentProps<'input'>, 'aria-label'>
|
||||
|
||||
const SearchInput: FC<SearchInputProps> = ({
|
||||
export function SearchInput({
|
||||
placeholder,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
white,
|
||||
}) => {
|
||||
onValueChange,
|
||||
'aria-label': ariaLabel,
|
||||
}: SearchInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [focus, setFocus] = useState<boolean>(false)
|
||||
const isComposing = useRef<boolean>(false)
|
||||
const [compositionValue, setCompositionValue] = useState<string>('')
|
||||
const isComposingRef = useRef<boolean>(false)
|
||||
const compositionCommitRef = useRef<string | null>(null)
|
||||
const [compositionValue, setCompositionValue] = useState('')
|
||||
const inputValue = isComposingRef.current ? compositionValue : value
|
||||
|
||||
const handleClear = () => {
|
||||
isComposingRef.current = false
|
||||
compositionCommitRef.current = null
|
||||
setCompositionValue('')
|
||||
onValueChange('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'group flex h-8 items-center overflow-hidden rounded-lg border-none bg-components-input-bg-normal px-2 hover:bg-components-input-bg-hover',
|
||||
focus && 'bg-components-input-bg-active!',
|
||||
white && 'border-gray-300! bg-white! shadow-xs hover:border-gray-300! hover:bg-white!',
|
||||
'relative',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none mr-1.5 flex size-4 shrink-0 items-center justify-center">
|
||||
<RiSearchLine className="size-4 text-components-input-text-placeholder" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
<span className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" aria-hidden="true" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
type="search"
|
||||
name="query"
|
||||
aria-label={ariaLabel ?? t('operation.search', { ns: 'common' })}
|
||||
className={cn(
|
||||
'caret-#295EFF block h-[18px] grow appearance-none border-0 bg-transparent system-sm-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder',
|
||||
white && 'bg-white! group-hover:bg-white! placeholder:text-gray-400! hover:bg-white!',
|
||||
'ps-7',
|
||||
!!inputValue && 'pe-7',
|
||||
'[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
|
||||
)}
|
||||
placeholder={placeholder || t('operation.search', { ns: 'common' })!}
|
||||
value={isComposing.current ? compositionValue : value}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
if (isComposing.current)
|
||||
setCompositionValue(newValue)
|
||||
else
|
||||
onChange(newValue)
|
||||
placeholder={placeholder ?? t('operation.search', { ns: 'common' })}
|
||||
value={inputValue}
|
||||
onValueChange={(nextValue) => {
|
||||
if (isComposingRef.current) {
|
||||
setCompositionValue(nextValue)
|
||||
return
|
||||
}
|
||||
if (compositionCommitRef.current !== null) {
|
||||
if (compositionCommitRef.current !== nextValue) {
|
||||
compositionCommitRef.current = null
|
||||
onValueChange(nextValue)
|
||||
return
|
||||
}
|
||||
compositionCommitRef.current = null
|
||||
return
|
||||
}
|
||||
onValueChange(nextValue)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
isComposing.current = true
|
||||
isComposingRef.current = true
|
||||
compositionCommitRef.current = null
|
||||
setCompositionValue(value)
|
||||
}}
|
||||
onCompositionEnd={(e) => {
|
||||
isComposing.current = false
|
||||
if (!isComposingRef.current)
|
||||
return
|
||||
|
||||
isComposingRef.current = false
|
||||
setCompositionValue('')
|
||||
onChange(e.currentTarget.value)
|
||||
compositionCommitRef.current = e.currentTarget.value
|
||||
onValueChange(e.currentTarget.value)
|
||||
}}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
{value && (
|
||||
{!!inputValue && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="group/clear flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0"
|
||||
onClick={() => {
|
||||
onChange('')
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="group/clear absolute top-1/2 right-2 flex size-4 -translate-y-1/2 cursor-pointer touch-manipulation items-center justify-center rounded-md border-none bg-transparent p-0 outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<RiCloseCircleFill className="size-4 text-text-quaternary group-hover/clear:text-text-tertiary" />
|
||||
<span className="i-ri-close-circle-fill size-4 text-text-quaternary group-hover/clear:text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchInput
|
||||
|
||||
@ -461,7 +461,7 @@ describe('AccountSetting', () => {
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
fireEvent.change(input, { target: { value: 'test-search' } })
|
||||
|
||||
// Assert
|
||||
|
||||
@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import BillingPage from '@/app/components/billing/billing-page'
|
||||
import CustomPage from '@/app/components/custom/custom-page'
|
||||
import {
|
||||
@ -218,8 +218,8 @@ export default function AccountSetting({
|
||||
{activeItem?.key === ACCOUNT_SETTING_TAB.PROVIDER && (
|
||||
<div className="flex grow justify-end">
|
||||
<SearchInput
|
||||
className="w-[200px]"
|
||||
onChange={setSearchValue}
|
||||
className="w-52"
|
||||
onValueChange={setSearchValue}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -42,20 +42,20 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/search-input', () => ({
|
||||
default: ({
|
||||
SearchInput: ({
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}) => (
|
||||
<input
|
||||
aria-label={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onChange={e => onValueChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
@ -220,14 +220,14 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
<PopoverContext value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
</PopoverContext>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
const { open, setOpen } = React.use(PopoverContext)
|
||||
return (
|
||||
<div data-testid="agent-strategy-trigger" onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
@ -236,7 +236,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
}
|
||||
|
||||
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
const { open } = React.use(PopoverContext)
|
||||
return open ? <div data-testid="agent-strategy-popover">{children}</div> : null
|
||||
}
|
||||
|
||||
|
||||
@ -14,11 +14,10 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@ -45,7 +44,7 @@ const NotFoundWarn = (props: {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<div><RiErrorWarningFill className="size-4 text-text-destructive" /></div>}
|
||||
render={<div><span className="i-ri-error-warning-fill size-4 text-text-destructive" aria-hidden="true" /></div>}
|
||||
/>
|
||||
<TooltipContent className="w-[180px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
@ -204,7 +203,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
|
||||
/>
|
||||
)
|
||||
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
|
||||
: <span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" aria-hidden="true" />}
|
||||
{showSwitchVersion && value && (
|
||||
<SwitchPluginVersion
|
||||
uniqueIdentifier={value.plugin_unique_identifier}
|
||||
@ -234,7 +233,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
>
|
||||
<div className="w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow">
|
||||
<header className="flex gap-1 p-2">
|
||||
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onChange={setQuery} className="w-full" />
|
||||
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onValueChange={setQuery} className="w-full" />
|
||||
<ViewTypeSelect viewType={viewType} onChange={setViewType} />
|
||||
</header>
|
||||
<main className="relative flex w-full flex-col overflow-hidden md:max-h-[300px] xl:max-h-[400px] 2xl:max-h-[564px]" ref={wrapElemRef}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user