test(web): exercise foundational inputs in Browser Mode (#45484)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-04 00:50:34 +00:00
1 parent 8e67d43735
commit 6e85fa171b
7 files changed
+484 -461

No files matched your search

@@ -0,0 +1,40 @@
import { useState } from 'react'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import AutoHeightTextarea from '../index'
function Editor() {
const [value, setValue] = useState('hello')
const [controlFocus, setControlFocus] = useState(1)
return (
<>
<AutoHeightTextarea
aria-label="Message"
value={value}
onChange={(event) => setValue(event.target.value)}
controlFocus={controlFocus}
/>
<button
onClick={() => {
setValue('hello world')
setControlFocus(2)
}}
>
Replace message
</button>
</>
)
}
it('focuses the replacement text at its end when the caller requests focus', async () => {
await page.viewport(800, 600)
await render(<Editor />)
const input = page.getByRole('textbox', { name: 'Message' })
await expect.element(input).toHaveFocus()
await userEvent.keyboard('!')
await expect.element(input).toHaveValue('hello!')
await page.getByRole('button', { name: 'Replace message' }).click()
await expect.element(input).toHaveFocus()
await userEvent.keyboard('!')
await expect.element(input).toHaveValue('hello world!')
})
@@ -1,22 +0,0 @@
import { render, screen } from '@testing-library/react'
import AutoHeightTextarea from '../index'
describe('AutoHeightTextarea', () => {
it('moves focus and the cursor to the end when controlFocus changes', () => {
const { rerender } = render(
<AutoHeightTextarea value="hello" onChange={vi.fn()} controlFocus={1} />,
)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
expect(input).toHaveFocus()
expect(input.selectionStart).toBe(5)
expect(input.selectionEnd).toBe(5)
input.blur()
rerender(<AutoHeightTextarea value="hello world" onChange={vi.fn()} controlFocus={2} />)
expect(input).toHaveFocus()
expect(input.selectionStart).toBe(11)
expect(input.selectionEnd).toBe(11)
})
})
@@ -0,0 +1,226 @@
import type { ComponentProps } from 'react'
import { useState } from 'react'
import { page as screen, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { CheckboxList } from '..'
function ControlledList(props: ComponentProps<typeof CheckboxList>) {
const [value, setValue] = useState(props.value ?? [])
return (
<>
<CheckboxList
{...props}
value={value}
onChange={(next) => {
setValue(next)
}}
/>
<output aria-label="Selected values">{JSON.stringify(value)}</output>
</>
)
}
describe('checkbox list component', () => {
beforeEach(async () => {
await screen.viewport(800, 600)
})
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' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Apple', value: 'apple' },
]
it('renders with title, description and options', async () => {
await render(
<ControlledList title="Test Title" description="Test Description" options={options} />,
)
await expect.element(screen.getByText('Test Title')).toBeInTheDocument()
await expect.element(screen.getByText('Test Description')).toBeInTheDocument()
await expect
.element(screen.getByRole('group', { name: 'Test Title' }))
.toHaveAccessibleDescription('Test Description')
for (const option of options) {
await expect.element(screen.getByText(option.label)).toBeVisible()
}
})
it('filters options by label', async () => {
await render(<ControlledList options={options} />)
const input = getSearchInput()
await input.fill('app')
await expect.element(screen.getByText('Apple')).toBeInTheDocument()
await expect.element(screen.getByText('Option 2')).not.toBeInTheDocument()
await expect.element(screen.getByText('Option 3')).not.toBeInTheDocument()
})
it('selects all options when select-all is clicked', async () => {
await render(<ControlledList options={options} value={[]} showSelectAll />)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await selectAll.click()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent(JSON.stringify(['option1', 'option2', 'option3', 'apple']))
await expect.element(selectAll).toBeChecked()
await userEvent.keyboard(' ')
await expect.element(selectAll).not.toBeChecked()
await expect.element(screen.getByRole('checkbox', { name: 'Option 1' })).not.toBeChecked()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent('[]')
})
it('deselects all options when select-all is clicked', async () => {
await render(
<ControlledList
options={options}
value={['option1', 'option2', 'option3', 'apple']}
showSelectAll
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await expect.element(selectAll).toBeChecked()
await selectAll.click()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent(JSON.stringify([]))
})
it('hides select-all checkbox when searching', async () => {
await render(<ControlledList options={options} />)
await getSearchInput().fill('app')
await expect
.element(screen.getByRole('checkbox', { name: selectAllName }))
.not.toBeInTheDocument()
})
it('selects options when checkbox is clicked', async () => {
await render(<ControlledList options={options} value={[]} showSelectAll={false} />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await selectOption.click()
await expect.element(selectOption).toBeChecked()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent(JSON.stringify(['option1']))
})
it('deselects options when checkbox is clicked when selected', async () => {
await render(<ControlledList options={options} value={['option1']} showSelectAll={false} />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await selectOption.click()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent(JSON.stringify([]))
})
it('keeps a disabled list unavailable', async () => {
await render(<ControlledList options={options} value={[]} disabled />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await expect.element(screen.getByRole('checkbox', { name: selectAllName })).toBeDisabled()
await expect.element(selectOption).toBeDisabled()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent('[]')
})
it('Reset button works', async () => {
await render(<ControlledList options={options} value={[]} />)
const input = getSearchInput()
await input.fill('ban')
await expect.element(screen.getByText(/common.operation.noSearchResults/)).toBeVisible()
await screen.getByRole('button', { name: 'common.operation.resetKeywords' }).click()
await expect.element(input).toHaveValue('')
await expect.element(screen.getByRole('checkbox', { name: 'Apple' })).toBeInTheDocument()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent('[]')
})
it('keeps disabled options unavailable', async () => {
const disabledOptions = [
{ label: 'Enabled', value: 'enabled' },
{ label: 'Disabled', value: 'disabled', disabled: true },
]
await render(<ControlledList options={disabledOptions} value={[]} />)
const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' })
await expect.element(disabledCheckbox).toBeDisabled()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent('[]')
})
it('renders with label prop', async () => {
await render(<ControlledList options={options} label="Test Label" />)
await expect.element(screen.getByText('Test Label')).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Test Label' })).toBeInTheDocument()
})
it('renders without showSelectAll, showCount, showSearch', async () => {
await render(
<ControlledList
options={options}
showSelectAll={false}
showCount={false}
showSearch={false}
/>,
)
await expect
.element(screen.getByRole('checkbox', { name: selectAllName }))
.not.toBeInTheDocument()
for (const option of options) {
await expect.element(screen.getByText(option.label)).toBeVisible()
}
})
it('shows indeterminate state when some options are selected', async () => {
await render(<ControlledList options={options} value={['option1', 'option2']} showSelectAll />)
// When some but not all options are selected, clicking select-all should select all remaining options
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await expect.element(selectAll).toBeInTheDocument()
await expect.element(selectAll).toHaveAttribute('aria-checked', 'mixed')
await selectAll.click()
await expect
.element(screen.getByRole('status', { name: 'Selected values' }))
.toHaveTextContent(JSON.stringify(['option1', 'option2', 'option3', 'apple']))
})
it('shows no data message when no options match search', async () => {
await render(<ControlledList options={options} />)
const input = getSearchInput()
await input.fill('xyz')
await expect.element(screen.getByText(/common.operation.noSearchResults/i)).toBeInTheDocument()
})
it('shows correct filtered count message when searching', async () => {
await render(<ControlledList options={options} title="Items" />)
const input = getSearchInput()
await input.fill('opt')
await expect
.element(screen.getByText(/operation.searchCount/i))
.toHaveTextContent('common.operation.searchCount:{"count":3,"content":"Items"}')
})
it('shows no data message when no options are provided', async () => {
await render(<ControlledList options={[]} />)
await expect.element(screen.getByText('common.noData')).toBeInTheDocument()
})
})
@@ -1,231 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Apple', value: 'apple' },
]
it('renders with title, description and options', () => {
render(<CheckboxList title="Test Title" description="Test Description" options={options} />)
expect(screen.getByText('Test Title'))!.toBeInTheDocument()
expect(screen.getByText('Test Description'))!.toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription(
'Test Description',
)
options.forEach((option) => {
expect(screen.getByText(option.label))!.toBeInTheDocument()
})
})
it('filters options by label', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} />)
const input = getSearchInput()
await user.type(input, 'app')
expect(screen.getByText('Apple'))!.toBeInTheDocument()
expect(screen.queryByText('Option 2')).not.toBeInTheDocument()
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
})
it('selects all options when select-all is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll />)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await user.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
})
it('does not select all options when select-all is clicked when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} disabled showSelectAll onChange={onChange} />)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await user.click(selectAll)
expect(onChange).not.toHaveBeenCalled()
})
it('deselects all options when select-all is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={['option1', 'option2', 'option3', 'apple']}
onChange={onChange}
showSelectAll
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
expect(selectAll).toBeChecked()
await user.click(selectAll)
expect(onChange).toHaveBeenCalledWith([])
})
it('hides select-all checkbox when searching', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} />)
await user.type(getSearchInput(), 'app')
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
})
it('selects options when checkbox is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll={false} />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await user.click(selectOption)
expect(onChange).toHaveBeenCalledWith(['option1'])
})
it('deselects options when checkbox is clicked when selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={['option1']}
onChange={onChange}
showSelectAll={false}
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await user.click(selectOption)
expect(onChange).toHaveBeenCalledWith([])
})
it('does not select options when checkbox is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} disabled />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await user.click(selectOption)
expect(onChange).not.toHaveBeenCalled()
})
it('Reset button works', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} />)
const input = getSearchInput()
await user.type(input, 'ban')
await user.click(screen.getByText('common.operation.resetKeywords'))
expect(input).toHaveValue('')
expect(screen.getByRole('checkbox', { name: 'Apple' })).toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
it('does not toggle disabled option when clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const disabledOptions = [
{ label: 'Enabled', value: 'enabled' },
{ label: 'Disabled', value: 'disabled', disabled: true },
]
render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} />)
const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' })
await user.click(disabledCheckbox)
expect(onChange).not.toHaveBeenCalled()
})
it('renders with label prop', () => {
render(<CheckboxList options={options} label="Test Label" />)
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Test Label' }))!.toBeInTheDocument()
})
it('renders without showSelectAll, showCount, showSearch', () => {
render(
<CheckboxList options={options} showSelectAll={false} showCount={false} showSearch={false} />,
)
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
options.forEach((option) => {
expect(screen.getByText(option.label))!.toBeInTheDocument()
})
})
it('shows indeterminate state when some options are selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={['option1', 'option2']}
onChange={onChange}
showSelectAll
/>,
)
// When some but not all options are selected, clicking select-all should select all remaining options
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
expect(selectAll)!.toBeInTheDocument()
expect(selectAll)!.toHaveAttribute('aria-checked', 'mixed')
await user.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
})
it('shows no data message when no options match search', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} />)
const input = getSearchInput()
await user.type(input, 'xyz')
expect(screen.getByText(/common.operation.noSearchResults/i))!.toBeInTheDocument()
})
it('does not toggle when clicking disabled option row', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const disabledOptions = [{ label: 'Option 1', value: 'option1', disabled: true }]
render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} />)
await user.click(screen.getByText('Option 1'))
expect(onChange).not.toHaveBeenCalled()
})
it('shows correct filtered count message when searching', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} title="Items" />)
const input = getSearchInput()
await user.type(input, 'opt')
expect(screen.getByText(/operation.searchCount/i)).toHaveTextContent('3')
})
it('shows no data message when no options are provided', () => {
render(<CheckboxList options={[]} />)
expect(screen.getByText('common.noData'))!.toBeInTheDocument()
})
})
@@ -1,8 +1,8 @@
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it } from 'vite-plus/test'
import { page as screen, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import RadioCard from '../index'
type ExampleMode = 'standard' | 'advanced'
@@ -46,18 +46,25 @@ function SelectableCards() {
}
describe('RadioCard', () => {
beforeEach(async () => {
await screen.viewport(800, 600)
})
it('connects card selection to the controlled group', async () => {
const user = userEvent.setup()
render(<SelectableCards />)
await render(<SelectableCards />)
const advanced = screen.getByRole('radio', { name: /Advanced/ })
expect(advanced).not.toBeChecked()
await user.click(advanced)
expect(advanced).toBeChecked()
expect(screen.getByRole('radio', { name: /Standard/ })).not.toBeChecked()
await expect.element(advanced).not.toBeChecked()
await expect.element(screen.getByText('Advanced config')).not.toBeVisible()
await advanced.click()
await expect.element(advanced).toBeChecked()
await expect.element(screen.getByText('Advanced config')).toBeVisible()
await expect.element(screen.getByRole('radio', { name: /Standard/ })).not.toBeChecked()
await userEvent.keyboard('{ArrowLeft}')
await expect.element(screen.getByRole('radio', { name: /Standard/ })).toBeChecked()
await expect.element(screen.getByText('Advanced config')).not.toBeVisible()
})
it('should render noRadio card as static content without radio role', () => {
render(
it('should render noRadio card as static content without radio role', async () => {
await render(
<RadioCard
noRadio
icon={<span>i</span>}
@@ -67,8 +74,8 @@ describe('RadioCard', () => {
/>,
)
expect(screen.getByText('No Radio')).toBeInTheDocument()
expect(screen.getByText('Static config')).toBeInTheDocument()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
await expect.element(screen.getByText('No Radio')).toBeInTheDocument()
await expect.element(screen.getByText('Static config')).toBeInTheDocument()
await expect.element(screen.getByRole('radio')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,198 @@
import type { ComponentProps } from 'react'
import { useState } from 'react'
import { page as screen, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import TagInput from '../index'
const mockNotify = vi.fn()
vi.mock('@/app/notifications', () => ({
toast: { error: (message: string) => mockNotify({ type: 'error', message }) },
}))
type TagInputProps = ComponentProps<typeof TagInput>
function ControlledTags(props: Partial<TagInputProps>) {
const [items, setItems] = useState(props.items ?? [])
return (
<>
<label htmlFor="recipient-tags">Recipients</label>
<TagInput {...props} id="recipient-tags" items={items} onChange={setItems} />
<output aria-label="Tags">{JSON.stringify(items)}</output>
<button>Next field</button>
</>
)
}
const renderTagInput = async (props: Partial<TagInputProps> = {}) => {
await render(<ControlledTags {...props} />)
}
describe('TagInput', () => {
beforeEach(async () => {
await screen.viewport(800, 600)
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should hide input when add is disabled', async () => {
await renderTagInput({ disableAdd: true })
await expect.element(screen.getByRole('textbox')).not.toBeInTheDocument()
})
it('should hide remove controls when remove is disabled', async () => {
await renderTagInput({ items: ['alpha'], disableRemove: true })
await expect
.element(screen.getByRole('button', { name: 'common.operation.remove alpha' }))
.not.toBeInTheDocument()
})
})
it('associates an external label with the tag entry input', async () => {
await renderTagInput()
const input = screen.getByRole('textbox', { name: 'Recipients' })
await screen.getByText('Recipients').click()
await expect.element(input).toHaveFocus()
await userEvent.keyboard('alice')
await expect.element(input).toHaveValue('alice')
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent('["alice"]')
})
describe('User Interactions', () => {
it('should remove item when remove control is clicked', async () => {
await renderTagInput({ items: ['alpha', 'beta'] })
const removeControl = screen.getByRole('button', { name: 'common.operation.remove alpha' })
await removeControl.click()
await expect.element(removeControl).not.toBeInTheDocument()
await expect
.element(screen.getByRole('button', { name: 'common.operation.remove beta' }))
.toBeVisible()
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify(['beta']))
})
it('should add trimmed tag on Enter and clear input', async () => {
await renderTagInput()
const input = screen.getByRole('textbox')
await input.fill(' new-tag ')
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify(['new-tag']))
await expect.element(input).toHaveValue('')
await expect
.element(screen.getByRole('button', { name: 'common.operation.remove new-tag' }))
.toBeVisible()
})
it('should add tag on blur when input has valid value', async () => {
await renderTagInput()
const input = screen.getByRole('textbox')
await input.fill('blur-tag')
await screen.getByRole('button', { name: 'Next field' }).click()
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify(['blur-tag']))
})
it('should append return marker on Enter and confirm on Tab in special mode', async () => {
await renderTagInput({ customizedConfirmKey: 'Tab' })
const input = screen.getByRole('textbox')
await input.fill('stop')
await userEvent.keyboard('{Enter}')
await expect.element(input).toHaveValue('stop↵')
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify([]))
await userEvent.keyboard('{Tab}')
await expect.element(input).toHaveFocus()
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify(['stop↵']))
await expect.element(input).toHaveValue('')
await expect
.element(screen.getByRole('button', { name: 'common.operation.remove stop↵' }))
.toBeVisible()
})
})
describe('Validation', () => {
it('should notify duplicate error when tag already exists', async () => {
await renderTagInput({ items: ['dup-tag'] })
const input = screen.getByRole('textbox')
await input.fill('dup-tag')
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify(['dup-tag']))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordDuplicate',
})
})
it('should notify length error when tag is longer than 20 chars', async () => {
await renderTagInput()
const input = screen.getByRole('textbox')
await input.fill('a'.repeat(21))
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify([]))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordError',
})
})
it('should notify required error when value is empty and required is true', async () => {
await renderTagInput({ required: true })
const input = screen.getByRole('textbox')
await input.fill(' ')
await screen.getByRole('button', { name: 'Next field' }).click()
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify([]))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordEmpty',
})
})
it('should ignore empty value when required is false', async () => {
await renderTagInput({ required: false })
const input = screen.getByRole('textbox')
await input.fill(' ')
await screen.getByRole('button', { name: 'Next field' }).click()
await expect
.element(screen.getByRole('status', { name: 'Tags' }))
.toHaveTextContent(JSON.stringify([]))
expect(mockNotify).not.toHaveBeenCalled()
})
})
})
@@ -1,195 +0,0 @@
import type { ComponentProps } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagInput from '../index'
const mockNotify = vi.fn()
vi.mock('@/app/notifications', () => ({
default: {
notify: (args: unknown) => mockNotify(args),
},
toast: {
success: (message: string) => mockNotify({ type: 'success', message }),
error: (message: string) => mockNotify({ type: 'error', message }),
warning: (message: string) => mockNotify({ type: 'warning', message }),
info: (message: string) => mockNotify({ type: 'info', message }),
},
}))
type TagInputProps = ComponentProps<typeof TagInput>
const renderTagInput = (props: Partial<TagInputProps> = {}) => {
const onChange = vi.fn<(items: string[]) => void>()
const items = props.items ?? []
render(<TagInput items={items} onChange={onChange} {...props} />)
return { onChange }
}
describe('TagInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should expose the current input text for CSS sizing', async () => {
const user = userEvent.setup()
renderTagInput({ placeholder: 'Tag' })
const input = screen.getByRole('textbox')
const inputContainer = input.parentElement
expect(inputContainer).toHaveAttribute('data-input-value', 'Tag')
await user.type(input, 'longer')
expect(inputContainer).toHaveAttribute('data-input-value', 'longer')
})
it('should hide input when add is disabled', () => {
renderTagInput({ disableAdd: true })
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should hide remove controls when remove is disabled', () => {
renderTagInput({ items: ['alpha'], disableRemove: true })
expect(
screen.queryByRole('button', { name: 'common.operation.remove alpha' }),
).not.toBeInTheDocument()
})
})
it('associates an external label with the tag entry input', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<>
<label htmlFor="recipient-tags">Recipients</label>
<TagInput id="recipient-tags" items={[]} onChange={onChange} />
</>,
)
const input = screen.getByRole('textbox', { name: 'Recipients' })
await user.click(screen.getByText('Recipients'))
expect(input).toHaveFocus()
await user.keyboard('alice{Enter}')
expect(onChange).toHaveBeenCalledWith(['alice'])
})
describe('User Interactions', () => {
it('should remove item when remove control is clicked', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput({ items: ['alpha', 'beta'] })
const removeControl = screen.getByRole('button', { name: 'common.operation.remove alpha' })
await user.click(removeControl)
expect(onChange).toHaveBeenCalledWith(['beta'])
})
it('should add trimmed tag on Enter and clear input', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput()
const input = screen.getByRole('textbox')
await user.type(input, ' new-tag ')
await user.type(input, '{Enter}')
expect(onChange).toHaveBeenCalledWith(['new-tag'])
await waitFor(() => {
expect(input)!.toHaveValue('')
})
})
it('should add tag on blur when input has valid value', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput()
const input = screen.getByRole('textbox')
await user.type(input, 'blur-tag')
await user.click(document.body)
expect(onChange).toHaveBeenCalledWith(['blur-tag'])
})
it('should append return marker on Enter and confirm on Tab in special mode', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput({ customizedConfirmKey: 'Tab' })
const input = screen.getByRole('textbox')
// Type normally
await user.type(input, 'stop')
await user.keyboard('{Enter}')
expect(input)!.toHaveValue('stop↵')
expect(onChange).not.toHaveBeenCalled()
await user.keyboard('{Tab}')
expect(input).toHaveFocus()
expect(onChange).toHaveBeenCalledWith(['stop↵'])
})
})
describe('Validation', () => {
it('should notify duplicate error when tag already exists', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput({ items: ['dup-tag'] })
const input = screen.getByRole('textbox')
await user.type(input, 'dup-tag')
await user.keyboard('{Enter}')
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordDuplicate',
})
})
it('should notify length error when tag is longer than 20 chars', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput()
const input = screen.getByRole('textbox')
await user.type(input, 'a'.repeat(21))
await user.keyboard('{Enter}')
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordError',
})
})
it('should notify required error when value is empty and required is true', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput({ required: true })
const input = screen.getByRole('textbox')
await user.type(input, ' ')
await user.click(document.body)
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordEmpty',
})
})
it('should ignore empty value when required is false', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput({ required: false })
const input = screen.getByRole('textbox')
await user.type(input, ' ')
await user.click(document.body)
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
})
})