test: add tests for some base components (#32479)

This commit is contained in:
Saumya Talwani 2026-02-25 13:38:03 +05:30 committed by GitHub
parent 34b6fc92d7
commit 6f2c101e3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 3577 additions and 78 deletions

View File

@ -0,0 +1,202 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { useParams, usePathname } from 'next/navigation'
import AudioBtn from './index'
const mockPlayAudio = vi.fn()
const mockPauseAudio = vi.fn()
const mockGetAudioPlayer = vi.fn()
vi.mock('next/navigation', () => ({
useParams: vi.fn(),
usePathname: vi.fn(),
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: vi.fn(() => ({
getAudioPlayer: mockGetAudioPlayer,
})),
},
}))
describe('AudioBtn', () => {
const getButton = () => screen.getByRole('button')
const mockUseParams = (value: Partial<Record<string, string>>) => {
vi.mocked(useParams).mockReturnValue(value as ReturnType<typeof useParams>)
}
const mockUsePathname = (value: string) => {
vi.mocked(usePathname).mockReturnValue(value)
}
const hoverAndCheckTooltip = async (expectedText: string) => {
await userEvent.hover(getButton())
expect(await screen.findByText(expectedText)).toBeInTheDocument()
}
const getLatestAudioCallback = () => {
const lastCall = mockGetAudioPlayer.mock.calls[mockGetAudioPlayer.mock.calls.length - 1]
const callback = lastCall?.[5]
if (typeof callback !== 'function')
throw new Error('Audio callback not found in latest getAudioPlayer call')
return callback as (event: string) => void
}
beforeAll(async () => {
await i18next.init({})
})
beforeEach(() => {
vi.clearAllMocks()
mockGetAudioPlayer.mockReturnValue({
playAudio: mockPlayAudio,
pauseAudio: mockPauseAudio,
})
mockUseParams({})
mockUsePathname('/')
})
// Core rendering and base UI integration.
describe('Rendering', () => {
it('should render button with play tooltip by default', async () => {
render(<AudioBtn value="hello" />)
expect(getButton()).toBeInTheDocument()
expect(getButton()).not.toBeDisabled()
await hoverAndCheckTooltip('play')
})
it('should apply className in initial state', () => {
const { container } = render(<AudioBtn value="hello" className="custom-wrapper" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('custom-wrapper')
})
})
// URL path resolution for app/public audio endpoints.
describe('URL routing', () => {
it('should call public text-to-audio endpoint when token exists', async () => {
mockUseParams({ token: 'public-token' })
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
expect(call[0]).toBe('/text-to-audio')
expect(call[1]).toBe(true)
})
it('should call app endpoint when appId exists', async () => {
mockUseParams({ appId: '123' })
mockUsePathname('/apps/123/chat')
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
expect(call[0]).toBe('/apps/123/text-to-audio')
expect(call[1]).toBe(false)
})
it('should call installed app endpoint for explore installed routes', async () => {
mockUseParams({ appId: '456' })
mockUsePathname('/explore/installed/app/456')
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
expect(call[0]).toBe('/installed-apps/456/text-to-audio')
expect(call[1]).toBe(false)
})
})
// User-visible playback state transitions.
describe('Playback interactions', () => {
it('should start loading and call playAudio when button is clicked', async () => {
render(<AudioBtn value="test" className="custom-wrapper" />)
await userEvent.click(getButton())
await waitFor(() => {
expect(mockPlayAudio).toHaveBeenCalledTimes(1)
expect(getButton()).toBeDisabled()
})
expect(screen.getByRole('status')).toBeInTheDocument()
await hoverAndCheckTooltip('loading')
})
it('should pause audio when clicked while playing', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await act(() => {
getLatestAudioCallback()('play')
})
await hoverAndCheckTooltip('playing')
expect(getButton()).not.toBeDisabled()
await userEvent.click(getButton())
await waitFor(() => expect(mockPauseAudio).toHaveBeenCalledTimes(1))
})
})
// Audio event callback handling from the player manager.
describe('Audio callback events', () => {
it('should set loading tooltip when loaded event is received', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await act(() => {
getLatestAudioCallback()('loaded')
})
await hoverAndCheckTooltip('loading')
expect(getButton()).toBeDisabled()
})
it.each(['ended', 'paused', 'error'])('should return to play tooltip when %s event is received', async (event) => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await act(() => {
getLatestAudioCallback()(event)
})
await hoverAndCheckTooltip('play')
expect(getButton()).not.toBeDisabled()
})
})
// Prop forwarding and minimal-input behavior.
describe('Props and edge cases', () => {
it('should pass id, value, and voice to getAudioPlayer', async () => {
render(<AudioBtn id="msg-1" value="hello" voice="en-US" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
expect(call[2]).toBe('msg-1')
expect(call[3]).toBe('hello')
expect(call[4]).toBe('en-US')
})
it('should keep empty route when neither token nor appId is present', async () => {
render(<AudioBtn />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
expect(call[0]).toBe('')
expect(call[1]).toBe(false)
expect(call[3]).toBeUndefined()
})
})
})

View File

@ -7,8 +7,8 @@ import { cn } from '@/utils/classnames'
const dividerVariants = cva('', {
variants: {
type: {
horizontal: 'w-full h-[0.5px] my-2 ',
vertical: 'w-[1px] h-full mx-2',
horizontal: 'my-2 h-[0.5px] w-full',
vertical: 'mx-2 h-full w-[1px]',
},
bgStyle: {
gradient: 'bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent',
@ -28,7 +28,7 @@ export type DividerProps = {
const Divider: FC<DividerProps> = ({ type, bgStyle, className = '', style }) => {
return (
<div className={cn(dividerVariants({ type, bgStyle }), 'shrink-0', className)} style={style}></div>
<div className={cn(dividerVariants({ type, bgStyle }), 'shrink-0', className)} style={style} data-testid="divider"></div>
)
}

View File

@ -0,0 +1,187 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
type ConfigState = {
isCeEdition: boolean
isProd: boolean
}
type GaProps = {
gaType: string
}
type GaRenderFn = (props: GaProps) => Promise<ReactNode>
type GaTypeValue = 'admin' | 'webapp'
const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({
mockHeaders: vi.fn(),
mockHeadersGet: vi.fn(),
configState: ({
isCeEdition: false,
isProd: true,
}) as ConfigState,
}))
vi.mock('@/config', () => ({
get IS_CE_EDITION() {
return configState.isCeEdition
},
get IS_PROD() {
return configState.isProd
},
}))
vi.mock('next/headers', () => ({
headers: mockHeaders,
}))
vi.mock('next/script', () => ({
default: ({
id,
strategy,
src,
nonce,
dangerouslySetInnerHTML,
}: {
id?: string
strategy?: string
src?: string
nonce?: string
dangerouslySetInnerHTML?: { __html?: string }
}) => (
<script
data-testid="mock-next-script"
data-id={id ?? ''}
data-inline={dangerouslySetInnerHTML?.__html ?? ''}
data-nonce={nonce ?? ''}
data-src={src ?? ''}
data-strategy={strategy ?? ''}
/>
),
}))
const loadComponent = async () => {
const mod = await import('./index')
// mod.default is either an async function (server component) or
// a React.memo object whose .type is the async function.
const rawExport = mod.default as unknown
const renderer: GaRenderFn | undefined
= typeof rawExport === 'function' ? (rawExport as GaRenderFn) : (rawExport as { type?: GaRenderFn }).type
if (!renderer)
throw new Error('GA component is not callable in tests')
return {
renderer,
GaType: mod.GaType,
}
}
const renderGA = async (gaType: GaTypeValue) => {
const { renderer } = await loadComponent()
const element = await renderer({ gaType })
if (!element)
return { element }
render(element as ReactElement)
return { element }
}
describe('GA', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
configState.isCeEdition = false
configState.isProd = true
mockHeadersGet.mockReturnValue(`default-src 'self'; script-src 'self' 'nonce-test-nonce'`)
mockHeaders.mockResolvedValue({
get: mockHeadersGet,
})
})
describe('Rendering', () => {
it('should return null when CE edition is enabled', async () => {
configState.isCeEdition = true
const { element } = await renderGA('admin')
expect(element).toBeNull()
expect(mockHeaders).not.toHaveBeenCalled()
})
it('should render three script tags with admin GA id in production', async () => {
await renderGA('admin')
const scripts = screen.getAllByTestId('mock-next-script')
expect(scripts).toHaveLength(3)
expect(mockHeaders).toHaveBeenCalledTimes(1)
expect(mockHeadersGet).toHaveBeenCalledWith('content-security-policy')
expect(scripts[0]).toHaveAttribute('data-id', 'ga-init')
expect(scripts[0]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`))
expect(scripts[1]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[1]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V')
expect(scripts[2]).toHaveAttribute('data-id', 'cookieyes')
expect(scripts[2]).toHaveAttribute('data-strategy', 'lazyOnload')
expect(scripts[2]).toHaveAttribute('data-src', 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js')
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', 'test-nonce')
})
})
})
describe('Props', () => {
it('should use webapp GA id when gaType is webapp', async () => {
await renderGA('webapp')
const scripts = screen.getAllByTestId('mock-next-script')
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-2MFWXK7WYT');`))
expect(scripts[1]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-2MFWXK7WYT')
})
})
describe('Edge Cases', () => {
it('should not read headers and should omit nonce when not in production', async () => {
configState.isProd = false
await renderGA('admin')
const scripts = screen.getAllByTestId('mock-next-script')
expect(mockHeaders).not.toHaveBeenCalled()
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
})
it('should omit nonce when CSP header does not contain nonce token', async () => {
mockHeadersGet.mockReturnValue(`default-src 'self'; script-src 'self'`)
await renderGA('admin')
const scripts = screen.getAllByTestId('mock-next-script')
expect(mockHeaders).toHaveBeenCalledTimes(1)
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
})
it('should omit nonce when CSP header is null', async () => {
mockHeadersGet.mockReturnValue(null)
await renderGA('admin')
const scripts = screen.getAllByTestId('mock-next-script')
expect(mockHeaders).toHaveBeenCalledTimes(1)
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
})
})
})

View File

@ -9,9 +9,13 @@ export enum GaType {
webapp = 'webapp',
}
export const GA_MEASUREMENT_ID_ADMIN = 'G-DM9497FN4V'
export const GA_MEASUREMENT_ID_WEBAPP = 'G-2MFWXK7WYT'
export const COOKIEYES_SCRIPT_SRC = 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
const gaIdMaps = {
[GaType.admin]: 'G-DM9497FN4V',
[GaType.webapp]: 'G-2MFWXK7WYT',
[GaType.admin]: GA_MEASUREMENT_ID_ADMIN,
[GaType.webapp]: GA_MEASUREMENT_ID_WEBAPP,
}
export type IGAProps = {
@ -62,7 +66,7 @@ const GA: FC<IGAProps> = async ({
<Script
id="cookieyes"
strategy="lazyOnload"
src="https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js"
src={COOKIEYES_SCRIPT_SRC}
nonce={nonce}
/>
</>

View File

@ -0,0 +1,320 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from '@/app/components/base/date-and-time-picker/utils/dayjs'
import MarkdownForm from './form'
type TextNode = {
type: 'text'
value: string
}
type ElementNode = {
type: 'element'
tagName: string
properties: Record<string, unknown>
children: Array<ElementNode | TextNode>
}
type RootNode = {
properties: Record<string, unknown>
children: Array<ElementNode | TextNode>
}
const { mockOnSend, mockFormatDateForOutput } = vi.hoisted(() => ({
mockOnSend: vi.fn(),
mockFormatDateForOutput: vi.fn((_date: unknown, includeTime?: boolean) => {
return includeTime ? 'formatted-datetime' : 'formatted-date'
}),
}))
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({
onSend: mockOnSend,
}),
}))
vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', async () => {
const actual = await vi.importActual<typeof import('@/app/components/base/date-and-time-picker/utils/dayjs')>(
'@/app/components/base/date-and-time-picker/utils/dayjs',
)
return {
...actual,
formatDateForOutput: mockFormatDateForOutput,
}
})
const createTextNode = (value: string): TextNode => ({
type: 'text',
value,
})
const createElementNode = (
tagName: string,
properties: Record<string, unknown> = {},
children: Array<ElementNode | TextNode> = [],
): ElementNode => ({
type: 'element',
tagName,
properties,
children,
})
const createRootNode = (
children: Array<ElementNode | TextNode>,
properties: Record<string, unknown> = {},
): RootNode => ({
properties,
children,
})
describe('MarkdownForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Render supported tags and fallback output for unsupported tags.
describe('Rendering', () => {
it('should render label, inputs, textarea, button, and unsupported tag fallback', () => {
const node = createRootNode([
createElementNode('label', { for: 'name' }, [createTextNode('Name')]),
createElementNode('input', { type: 'text', name: 'name', placeholder: 'Enter name' }),
createElementNode('textarea', { name: 'bio', placeholder: 'Enter bio' }),
createElementNode('button', {}, [createTextNode('Submit')]),
createElementNode('article', {}, [createTextNode('Unsupported child')]),
])
render(<MarkdownForm node={node} />)
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter bio')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
expect(screen.getByText(/Unsupported tag:\s*article/)).toBeInTheDocument()
})
})
// Convert current form values to plain text output by default.
describe('Text format submission', () => {
it('should call onSend with text output when dataFormat is not provided', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
createElementNode('textarea', { name: 'bio', value: 'Hello' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('name: Alice\nbio: Hello')
})
})
it('should submit updated text input and textarea values after user typing', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: '', placeholder: 'Name input' }),
createElementNode('textarea', { name: 'bio', value: '', placeholder: 'Bio input' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
const nameInput = screen.getByPlaceholderText('Name input')
const bioInput = screen.getByPlaceholderText('Bio input')
await user.type(nameInput, 'Bob')
await user.type(bioInput, 'Hi there')
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('name: Bob\nbio: Hi there')
})
})
})
// Emit serialized JSON when data-format requests JSON output.
describe('JSON format submission', () => {
it('should call onSend with JSON output when dataFormat is json', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', { type: 'hidden', name: 'token', value: 'secret-token' }),
createElementNode('input', { type: 'select', name: 'color', value: 'red', dataOptions: ['red', 'blue'] }),
createElementNode('button', {}, [createTextNode('Send JSON')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Send JSON' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('{"token":"secret-token","color":"red"}')
})
})
it('should fallback hidden value to empty string when value is missing', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', { type: 'hidden', name: 'token' }),
createElementNode('button', {}, [createTextNode('Send JSON')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Send JSON' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('{"token":""}')
})
})
})
// Select options parser should handle both valid and invalid string payloads.
describe('Select options parsing', () => {
it('should parse options from data-options string and submit selected value', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', {
'type': 'select',
'name': 'city',
'value': 'Paris',
'data-options': '["Paris","Tokyo"]',
}),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('city: Paris')
})
})
it('should handle invalid data-options string without crashing', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createRootNode([
createElementNode('input', {
'type': 'select',
'name': 'city',
'value': 'Paris',
'data-options': 'not-json',
}),
createElementNode('button', {}, [createTextNode('Submit')]),
])
try {
render(<MarkdownForm node={node} />)
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
expect(consoleErrorSpy).toHaveBeenCalled()
}
finally {
consoleErrorSpy.mockRestore()
}
})
it('should update selected value via onSelect and submit the new option', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', {
type: 'select',
name: 'city',
value: 'Paris',
dataOptions: ['Paris', 'Tokyo'],
}),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
const triggerText = await screen.findByTitle('Paris')
await user.click(triggerText)
await user.click(await screen.findByText('Tokyo'))
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('city: Tokyo')
})
})
})
// Date and datetime values should be formatted through shared utility before submission.
describe('Date formatting', () => {
it('should format date and datetime values before sending', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', { type: 'date', name: 'startDate', value: dayjs('2026-01-10') }),
createElementNode('input', { type: 'datetime', name: 'runAt', value: dayjs('2026-01-10T08:30:00') }),
createElementNode('button', {}, [createTextNode('Submit')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockFormatDateForOutput).toHaveBeenCalledTimes(2)
expect(mockFormatDateForOutput).toHaveBeenNthCalledWith(1, expect.anything(), false)
expect(mockFormatDateForOutput).toHaveBeenNthCalledWith(2, expect.anything(), true)
expect(mockOnSend).toHaveBeenCalledWith('{"startDate":"formatted-date","runAt":"formatted-datetime"}')
})
})
})
// Checkbox interactions should update form state and be reflected in submission output.
describe('Checkbox interaction', () => {
it('should toggle checkbox value and submit updated value', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'checkbox', name: 'acceptTerms', value: false, dataTip: 'Accept terms' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByTestId('checkbox-acceptTerms'))
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('acceptTerms: true')
})
})
})
// Native submit event is intentionally blocked at form level.
describe('Form submit behavior', () => {
it('should prevent native submit propagation from form onSubmit', () => {
const parentOnSubmit = vi.fn()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
const { container } = render(
<div onSubmit={parentOnSubmit}>
<MarkdownForm node={node} />
</div>,
)
const form = container.querySelector('form')
expect(form).not.toBeNull()
if (!form)
throw new Error('Form element not found')
fireEvent.submit(form)
expect(parentOnSubmit).not.toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
})
})
})

View File

@ -100,8 +100,8 @@ const MarkdownForm = ({ node }: any) => {
return (
<label
key={index}
htmlFor={child.properties.for}
className="system-md-semibold my-2 text-text-secondary"
htmlFor={child.properties.htmlFor || child.properties.name}
className="my-2 text-text-secondary system-md-semibold"
>
{child.children[0]?.value || ''}
</label>
@ -161,6 +161,7 @@ const MarkdownForm = ({ node }: any) => {
[child.properties.name]: !prevValues[child.properties.name],
}))
}}
id={child.properties.name}
/>
<span>{child.properties.dataTip || child.properties['data-tip'] || ''}</span>
</div>

View File

@ -4,10 +4,10 @@ import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { InputVarType } from '@/app/components/workflow/types'
import ActionButton from '../../../action-button'
import { VariableX } from '../../../icons/src/vender/workflow'
@ -55,6 +55,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
ragVariables,
readonly,
}) => {
const { t } = useTranslation()
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
@ -125,7 +126,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
/>
)}
{!isDefaultValueVariable && (
<div className="system-xs-medium max-w-full truncate text-components-input-text-filled">{formInput.default?.value}</div>
<div className="max-w-full truncate text-components-input-text-filled system-xs-medium">{formInput.default?.value}</div>
)}
</div>
@ -133,14 +134,22 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
{!readonly && (
<div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex">
<div className="flex h-full items-center" ref={editBtnRef}>
<ActionButton size="s">
<RiEditLine className="size-4 text-text-tertiary" />
<ActionButton
size="s"
data-testid="action-btn-edit"
aria-label={t('operation.edit', { ns: 'common' })}
>
<span className="i-ri-edit-line size-4 text-text-tertiary" />
</ActionButton>
</div>
<div className="flex h-full items-center" ref={removeBtnRef}>
<ActionButton size="s">
<RiDeleteBinLine className="size-4 text-text-tertiary" />
<ActionButton
size="s"
data-testid="action-btn-remove"
aria-label={t('operation.remove', { ns: 'common' })}
>
<span className="i-ri-delete-bin-line size-4 text-text-tertiary" />
</ActionButton>
</div>
</div>

View File

@ -0,0 +1,124 @@
import type { Option } from './custom'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CustomSelect from './custom'
const options: Option[] = [
{ label: 'First option', value: 'first' },
{ label: 'Second option', value: 'second' },
]
describe('CustomSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior and value fallback.
describe('Rendering', () => {
it('should show the placeholder when value is undefined or not found', () => {
const { rerender } = render(
<CustomSelect options={options} />,
)
expect(screen.getByTitle(/select/i)).toBeInTheDocument()
rerender(
<CustomSelect options={options} value="missing" />,
)
expect(screen.getByTitle(/select/i)).toBeInTheDocument()
})
})
// User interactions for opening and selecting options.
describe('User Interactions', () => {
it('should call onChange and close the popup when an option is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<CustomSelect options={options} onChange={onChange} />,
)
await user.click(screen.getByTitle(/select/i))
expect(screen.getByTitle('Second option')).toBeInTheDocument()
await user.click(screen.getByTitle('Second option'))
expect(onChange).toHaveBeenCalledWith('second')
expect(screen.queryByTitle('Second option')).not.toBeInTheDocument()
})
})
// Controlled container props behavior.
describe('Container Props', () => {
it('should delegate open-state changes through containerProps.onOpenChange', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
render(
<CustomSelect
options={options}
containerProps={{ open: true, onOpenChange }}
/>,
)
expect(screen.getByTitle('First option')).toBeInTheDocument()
await user.click(screen.getByTitle(/select/i))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
// Custom rendering hooks for trigger and options.
describe('Custom Renderers', () => {
it('should render CustomTrigger and CustomOption with selected state', async () => {
const user = userEvent.setup()
render(
<CustomSelect
options={options}
value="first"
CustomTrigger={(option, open) => <div>{`${option?.label ?? 'none'}-${open ? 'open' : 'closed'}`}</div>}
CustomOption={(option, selected) => <div>{`${option.label}-${selected ? 'selected' : 'idle'}`}</div>}
/>,
)
expect(screen.getByText('First option-closed')).toBeInTheDocument()
await user.click(screen.getByText('First option-closed'))
expect(screen.getByText('First option-open')).toBeInTheDocument()
expect(screen.getByText('First option-selected')).toBeInTheDocument()
expect(screen.getByText('Second option-idle')).toBeInTheDocument()
})
})
// Class-based customization props.
describe('Style Props', () => {
it('should apply trigger and popup class names from props', async () => {
const user = userEvent.setup()
render(
<CustomSelect
options={options}
triggerProps={{ className: 'trigger-class' }}
popupProps={{
wrapperClassName: 'wrapper-class',
className: 'popup-class',
itemClassName: 'item-class',
}}
/>,
)
const triggerLabel = screen.getByTitle(/select/i)
const trigger = triggerLabel.parentElement
expect(trigger).toHaveClass('trigger-class')
await user.click(triggerLabel)
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
expect(document.querySelector('.popup-class')).toBeInTheDocument()
expect(document.querySelectorAll('.item-class')).toHaveLength(options.length)
})
})
})

View File

@ -0,0 +1,216 @@
import type { Item } from './index'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Select, { PortalSelect, SimpleSelect } from './index'
const items: Item[] = [
{ value: 'apple', name: 'Apple' },
{ value: 'banana', name: 'Banana' },
{ value: 'citrus', name: 'Citrus' },
]
describe('Select', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering and edge behavior for default select.
describe('Rendering', () => {
it('should show the default selected item when defaultValue matches an item', () => {
render(
<Select
items={items}
defaultValue="banana"
allowSearch={false}
onSelect={vi.fn()}
/>,
)
expect(screen.getByTitle('Banana')).toBeInTheDocument()
})
})
// User interactions for default select.
describe('User Interactions', () => {
it('should call onSelect when choosing an option from default select', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<Select
items={items}
defaultValue="banana"
allowSearch={false}
onSelect={onSelect}
/>,
)
await user.click(screen.getByTitle('Banana'))
await user.click(screen.getByText('Citrus'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
value: 'citrus',
name: 'Citrus',
}))
})
it('should not open or select when default select is disabled', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<Select
items={items}
defaultValue="banana"
allowSearch={false}
disabled={true}
onSelect={onSelect}
/>,
)
await user.click(screen.getByTitle('Banana'))
expect(screen.queryByText('Citrus')).not.toBeInTheDocument()
expect(onSelect).not.toHaveBeenCalled()
})
})
})
describe('SimpleSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering and placeholder fallback behavior.
describe('Rendering', () => {
it('should render i18n placeholder when no selection exists', () => {
render(
<SimpleSelect
items={items}
defaultValue="missing"
onSelect={vi.fn()}
/>,
)
expect(screen.getByText(/select/i)).toBeInTheDocument()
})
it('should render custom placeholder when provided', () => {
render(
<SimpleSelect
items={items}
defaultValue="missing"
placeholder="Pick one"
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('Pick one')).toBeInTheDocument()
})
})
// User interactions and callback behavior.
describe('User Interactions', () => {
it('should call onSelect and update display when an option is chosen', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<SimpleSelect
items={items}
defaultValue="missing"
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('Apple'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
value: 'apple',
name: 'Apple',
}))
expect(screen.getByText('Apple')).toBeInTheDocument()
})
it('should pass open state into renderTrigger', async () => {
const user = userEvent.setup()
render(
<SimpleSelect
items={items}
defaultValue="missing"
onSelect={vi.fn()}
renderTrigger={(selected, open) => (
<span>{`${selected?.name ?? 'none'}-${open ? 'open' : 'closed'}`}</span>
)}
/>,
)
expect(screen.getByText('none-closed')).toBeInTheDocument()
await user.click(screen.getByText('none-closed'))
expect(screen.getByText('none-open')).toBeInTheDocument()
})
})
})
describe('PortalSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering for edge case when value is empty.
describe('Rendering', () => {
it('should show placeholder when value is empty', () => {
render(
<PortalSelect
value=""
items={items}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText(/select/i)).toBeInTheDocument()
})
})
// Interaction and readonly behavior.
describe('User Interactions', () => {
it('should call onSelect when choosing an option from portal dropdown', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<PortalSelect
value=""
items={items}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText(/select/i))
await user.click(screen.getByText('Citrus'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
value: 'citrus',
name: 'Citrus',
}))
})
it('should not open the portal dropdown when readonly is true', async () => {
const user = userEvent.setup()
render(
<PortalSelect
value=""
items={items}
readonly={true}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText(/select/i))
expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,116 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LocaleSigninSelect from './locale-signin'
const localeItems = [
{ value: 'en-US', name: 'English (US)' },
{ value: 'zh-Hans', name: '简体中文' },
{ value: 'ja-JP', name: '日本語' },
]
describe('LocaleSigninSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for selected value and fallback state.
describe('Rendering', () => {
it('should render selected locale name when value matches an item', () => {
render(
<LocaleSigninSelect
items={localeItems}
value="en-US"
onChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /english \(us\)/i })).toBeInTheDocument()
})
it('should render trigger without selected label when value is not found', () => {
render(
<LocaleSigninSelect
items={localeItems}
value="missing"
onChange={vi.fn()}
/>,
)
const trigger = screen.getByRole('button')
expect(trigger).toBeInTheDocument()
expect(trigger).not.toHaveTextContent('English (US)')
})
})
// Menu interactions and callback behavior.
describe('User Interactions', () => {
it('should call onChange with selected locale value when clicking an option', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<LocaleSigninSelect
items={localeItems}
value="en-US"
onChange={onChange}
/>,
)
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
await user.click(screen.getByRole('menuitem', { name: '日本語' }))
expect(onChange).toHaveBeenCalledWith('ja-JP')
})
it('should render all locale options when menu is opened', async () => {
const user = userEvent.setup()
render(
<LocaleSigninSelect
items={localeItems}
value="en-US"
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
expect(screen.getByRole('menuitem', { name: 'English (US)' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '简体中文' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '日本語' })).toBeInTheDocument()
})
})
// Edge behavior for missing callback and empty data.
describe('Edge Cases', () => {
it('should not throw when onChange is undefined and option is selected', async () => {
const user = userEvent.setup()
render(
<LocaleSigninSelect
items={localeItems}
value="en-US"
/>,
)
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
await user.click(screen.getByRole('menuitem', { name: '简体中文' }))
// No assertion needed — test verifies no exception is thrown during selection without onChange.
})
it('should render no options when items are empty', async () => {
const user = userEvent.setup()
render(
<LocaleSigninSelect
items={[]}
value="en-US"
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button'))
expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LocaleSelect from './locale'
const localeItems = [
{ value: 'en-US', name: 'English (US)' },
{ value: 'zh-Hans', name: '简体中文' },
{ value: 'ja-JP', name: '日本語' },
]
describe('LocaleSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for selected value and fallback state.
describe('Rendering', () => {
it('should render selected locale name when value matches an item', () => {
render(
<LocaleSelect
items={localeItems}
value="en-US"
onChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /english \(us\)/i })).toBeInTheDocument()
})
it('should render trigger without selected label when value is not found', () => {
render(
<LocaleSelect
items={localeItems}
value="missing"
onChange={vi.fn()}
/>,
)
const trigger = screen.getByRole('button')
expect(trigger).toBeInTheDocument()
expect(trigger).not.toHaveTextContent('English (US)')
})
})
// Menu interactions and callback behavior.
describe('User Interactions', () => {
it('should call onChange with selected locale value when clicking an option', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<LocaleSelect
items={localeItems}
value="en-US"
onChange={onChange}
/>,
)
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
await user.click(screen.getByRole('menuitem', { name: '日本語' }))
expect(onChange).toHaveBeenCalledWith('ja-JP')
})
it('should render all locale options when menu is opened', async () => {
const user = userEvent.setup()
render(
<LocaleSelect
items={localeItems}
value="en-US"
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
expect(screen.getByRole('menuitem', { name: 'English (US)' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '简体中文' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '日本語' })).toBeInTheDocument()
})
})
// Edge behavior for missing callback and empty data.
describe('Edge Cases', () => {
it('should not throw when onChange is undefined and option is selected', async () => {
const user = userEvent.setup()
render(
<LocaleSelect
items={localeItems}
value="en-US"
/>,
)
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
await user.click(screen.getByRole('menuitem', { name: '简体中文' }))
})
it('should render no options when items are empty', async () => {
const user = userEvent.setup()
render(
<LocaleSelect
items={[]}
value="en-US"
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button'))
expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,175 @@
import type { Option } from './pure'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PureSelect from './pure'
const options: Option[] = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Citrus', value: 'citrus' },
]
describe('PureSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering and placeholder behavior in single/multiple modes.
describe('Rendering', () => {
it('should render i18n placeholder when single value is empty', () => {
render(<PureSelect options={options} />)
expect(screen.getByTitle(/select/i)).toBeInTheDocument()
})
it('should render custom placeholder when provided', () => {
render(<PureSelect options={options} placeholder="Choose value" />)
expect(screen.getByTitle('Choose value')).toBeInTheDocument()
})
it('should render selected option label in single mode', () => {
render(<PureSelect options={options} value="banana" />)
expect(screen.getByTitle('Banana')).toBeInTheDocument()
})
it('should render selected count text in multiple mode', () => {
render(<PureSelect options={options} multiple={true} value={['apple', 'banana']} />)
expect(screen.getByText(/selected/i)).toBeInTheDocument()
})
})
// Interaction behavior in single and multiple selection modes.
describe('User Interactions', () => {
it('should call onChange and close popup when selecting an option in single mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<PureSelect options={options} onChange={onChange} />)
await user.click(screen.getByTitle(/select/i))
expect(screen.getByTitle('Banana')).toBeInTheDocument()
await user.click(screen.getByTitle('Banana'))
expect(onChange).toHaveBeenCalledWith('banana')
expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument()
})
it('should append a new value in multiple mode when clicking an unselected option', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<PureSelect
options={options}
multiple={true}
value={['apple']}
onChange={onChange}
/>,
)
await user.click(screen.getByText(/common\.dynamicSelect\.selected/i))
await user.click(screen.getAllByTitle('Banana')[0])
expect(onChange).toHaveBeenCalledWith(['apple', 'banana'])
})
it('should remove an existing value in multiple mode when clicking a selected option', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<PureSelect
options={options}
multiple={true}
value={['apple', 'banana']}
onChange={onChange}
/>,
)
await user.click(screen.getByText(/common\.dynamicSelect\.selected/i))
await user.click(screen.getAllByTitle('Apple')[0])
expect(onChange).toHaveBeenCalledWith(['banana'])
})
})
// Controlled open state and disabled behavior.
describe('Container And Disabled Props', () => {
it('should call containerProps.onOpenChange when trigger is clicked in controlled mode', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
render(
<PureSelect
options={options}
containerProps={{ open: true, onOpenChange }}
/>,
)
expect(screen.getByTitle('Apple')).toBeInTheDocument()
await user.click(screen.getByTitle(/select/i))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('should not open popup when disabled', async () => {
const user = userEvent.setup()
render(
<PureSelect
options={options}
disabled={true}
/>,
)
await user.click(screen.getByTitle(/select/i))
expect(screen.queryByTitle('Apple')).not.toBeInTheDocument()
})
it('should ignore option clicks when disabled even if popup is open', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<PureSelect
options={options}
disabled={true}
onChange={onChange}
containerProps={{ open: true }}
/>,
)
await user.click(screen.getAllByTitle('Apple')[0])
expect(onChange).not.toHaveBeenCalled()
})
})
// Style and popup customization props.
describe('Style Props', () => {
it('should apply trigger and popup class names and render popup title', () => {
render(
<PureSelect
options={options}
triggerProps={{ className: 'trigger-class' }}
popupProps={{
wrapperClassName: 'wrapper-class',
className: 'popup-class',
itemClassName: 'item-class',
title: 'Available options',
titleClassName: 'title-class',
}}
containerProps={{ open: true }}
/>,
)
const triggerLabel = screen.getByTitle(/select/i)
const trigger = triggerLabel.parentElement
expect(trigger).toHaveClass('trigger-class')
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
expect(document.querySelector('.popup-class')).toBeInTheDocument()
expect(document.querySelectorAll('.item-class')).toHaveLength(options.length)
expect(screen.getByText('Available options')).toHaveClass('title-class')
})
})
})

View File

@ -0,0 +1,347 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import * as React from 'react'
import TagFilter from './filter'
import { useStore as useTagStore } from './store'
const { fetchTagList } = vi.hoisted(() => ({
fetchTagList: vi.fn(),
}))
// Mock the tag service (API layer)
vi.mock('@/service/tag', () => ({
fetchTagList,
}))
// Mock ahooks to avoid timer-related issues in tests
vi.mock('ahooks', () => {
return {
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const ref = React.useRef(fn)
ref.current = fn
const stableRun = React.useRef((...args: unknown[]) => {
// Schedule to run after current event handler finishes,
// allowing React to process pending state updates first
Promise.resolve().then(() => ref.current(...args))
})
return { run: stableRun.current }
},
useMount: (fn: () => void) => {
React.useEffect(() => {
fn()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
},
}
})
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-3', name: 'Database', type: 'knowledge', binding_count: 2 },
{ id: 'tag-4', name: 'API Design', type: 'app', binding_count: 1 },
]
const defaultProps = {
type: 'app' as const,
value: [] as string[],
onChange: vi.fn(),
}
// Helper: the i18n mock renders "ns.key" format (dot-separated)
const i18n = {
placeholder: 'common.tag.placeholder',
noTag: 'common.tag.noTag',
manageTags: 'common.tag.manageTags',
}
describe('TagFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fetchTagList).mockResolvedValue(mockTags)
// Pre-populate the Zustand store with tags so dropdown content is available
act(() => {
useTagStore.setState({ tagList: mockTags, showTagManagementModal: false })
})
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TagFilter {...defaultProps} />)
expect(screen.getByText(i18n.placeholder)).toBeInTheDocument()
})
it('should render the tag icon', () => {
render(<TagFilter {...defaultProps} />)
expect(screen.getByTestId('tag-filter-trigger-icon')).toBeInTheDocument()
})
it('should render the arrow down icon when no tags are selected', () => {
render(<TagFilter {...defaultProps} />)
expect(screen.getByText(i18n.placeholder)).toBeInTheDocument()
expect(screen.getByTestId('tag-filter-trigger-icon')).toBeInTheDocument()
expect(screen.getByTestId('tag-filter-arrow-down-icon')).toBeInTheDocument()
})
it('should display the first selected tag name when tags are selected', () => {
render(<TagFilter {...defaultProps} value={['tag-1']} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
it('should display the count badge when multiple tags are selected', () => {
render(<TagFilter {...defaultProps} value={['tag-1', 'tag-2']} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('should display correct count badge for three selected tags', () => {
render(<TagFilter {...defaultProps} value={['tag-1', 'tag-2', 'tag-4']} />)
expect(screen.getByText('+2')).toBeInTheDocument()
})
it('should not show placeholder when tags are selected', () => {
render(<TagFilter {...defaultProps} value={['tag-1']} />)
expect(screen.queryByText(i18n.placeholder)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should filter tags by type prop', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} type="knowledge" />)
await user.click(screen.getByText(i18n.placeholder))
// Only knowledge-type tags should appear
expect(screen.getByText('Database')).toBeInTheDocument()
expect(screen.queryByText('Frontend')).not.toBeInTheDocument()
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
})
it('should call onChange when a tag is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<TagFilter {...defaultProps} onChange={onChange} />)
await user.click(screen.getByText(i18n.placeholder))
await user.click(screen.getByText('Frontend'))
expect(onChange).toHaveBeenCalledWith(['tag-1'])
})
it('should call onChange to deselect when an already-selected tag is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<TagFilter {...defaultProps} value={['tag-1']} onChange={onChange} />)
// Open dropdown — trigger shows the tag name "Frontend"
await user.click(screen.getByText('Frontend'))
// Click the tag in the dropdown (it has a title attribute)
await user.click(screen.getByTitle('Frontend'))
expect(onChange).toHaveBeenCalledWith([])
})
})
describe('User Interactions', () => {
it('should open dropdown on trigger click', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
// Dropdown content should appear with tags
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('API Design')).toBeInTheDocument()
})
it('should show only tags matching the type filter', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} type="app" />)
await user.click(screen.getByText(i18n.placeholder))
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('API Design')).toBeInTheDocument()
expect(screen.queryByText('Database')).not.toBeInTheDocument()
})
it('should add a tag to the selection', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<TagFilter {...defaultProps} value={['tag-1']} onChange={onChange} />)
await user.click(screen.getByText('Frontend'))
await user.click(screen.getByTitle('Backend'))
expect(onChange).toHaveBeenCalledWith(['tag-1', 'tag-2'])
})
it('should show check icon for selected tags in dropdown', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} value={['tag-1']} />)
await user.click(screen.getByText('Frontend'))
// The Check icon should be rendered for the selected tag
const tagItem = screen.getByTitle('Frontend')
expect(tagItem).toBeInTheDocument()
// The parent container of the tag has a Check SVG sibling
const checkIcons = screen.getAllByTestId('tag-filter-selected-icon')
expect(checkIcons?.length).toBeGreaterThanOrEqual(1)
})
it('should clear all selected tags when clear button is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<TagFilter {...defaultProps} value={['tag-1', 'tag-2']} onChange={onChange} />)
const clearButton = screen.getByTestId('tag-filter-clear-button')
expect(clearButton).toBeInTheDocument()
await user.click(clearButton!)
expect(onChange).toHaveBeenCalledWith([])
})
it('should open manage tags modal and close dropdown', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
await user.click(screen.getByText(i18n.manageTags))
expect(useTagStore.getState().showTagManagementModal).toBe(true)
})
})
describe('Search', () => {
it('should filter tags by search keywords', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
const searchInput = screen.getByRole('textbox')
await user.type(searchInput, 'Front')
// With debounce mocked to be synchronous, results should be immediate
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
expect(screen.queryByText('API Design')).not.toBeInTheDocument()
})
it('should show no tags message when search has no results', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
const searchInput = screen.getByRole('textbox')
await user.type(searchInput, 'NonExistentTag')
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
it('should clear search and show all tags when clear icon is clicked', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
const searchInput = screen.getByRole('textbox')
await user.type(searchInput, 'Front')
// Wait for the debounced search to filter
await waitFor(() => {
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
})
// Clear the search using the Input's clear button
const clearButton = screen.getByTestId('input-clear')
await user.click(clearButton)
// The input value should be cleared
expect(searchInput).toHaveValue('')
// After the clear + microtask re-render, all app tags should be visible again
await waitFor(() => {
expect(screen.getByText('Backend')).toBeInTheDocument()
})
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('API Design')).toBeInTheDocument()
})
})
describe('Data Fetching', () => {
it('should fetch tag list on mount', () => {
render(<TagFilter {...defaultProps} />)
expect(fetchTagList).toHaveBeenCalledWith('app')
})
it('should fetch with correct type parameter', () => {
render(<TagFilter {...defaultProps} type="knowledge" />)
expect(fetchTagList).toHaveBeenCalledWith('knowledge')
})
it('should update the store with fetched tags', async () => {
const freshTags: Tag[] = [
{ id: 'new-1', name: 'NewTag', type: 'app', binding_count: 0 },
]
vi.mocked(fetchTagList).mockResolvedValue(freshTags)
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<TagFilter {...defaultProps} />)
await waitFor(() => {
expect(useTagStore.getState().tagList).toEqual(freshTags)
})
})
})
describe('Edge Cases', () => {
it('should show no tag message when tag list is completely empty', async () => {
const user = userEvent.setup()
// Mock fetchTagList to return empty so useMount doesn't repopulate
vi.mocked(fetchTagList).mockResolvedValue([])
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<TagFilter {...defaultProps} />)
await user.click(screen.getByText(i18n.placeholder))
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
it('should handle value with non-existent tag ids gracefully', () => {
render(<TagFilter {...defaultProps} value={['non-existent-id']} />)
expect(screen.queryByText(i18n.placeholder)).not.toBeInTheDocument()
})
it('should not show count badge when only one tag is selected', () => {
render(<TagFilter {...defaultProps} value={['tag-1']} />)
expect(screen.queryByText(/\+\d/)).not.toBeInTheDocument()
})
it('should clear selection without opening dropdown', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<TagFilter {...defaultProps} value={['tag-1']} onChange={onChange} />)
const clearButton = screen.getByTestId('tag-filter-clear-button')
expect(clearButton).toBeInTheDocument()
await user.click(clearButton)
expect(onChange).toHaveBeenCalledWith([])
})
})
})

View File

@ -1,12 +1,9 @@
import type { FC } from 'react'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn, useMount } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
@ -85,7 +82,7 @@ const TagFilter: FC<TagFilterProps> = ({
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
</div>
<div className="text-[13px] leading-[18px] text-text-secondary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
@ -96,7 +93,7 @@ const TagFilter: FC<TagFilterProps> = ({
)}
{!value.length && (
<div className="p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
</div>
)}
{!!value.length && (
@ -106,8 +103,9 @@ const TagFilter: FC<TagFilterProps> = ({
e.stopPropagation()
onChange([])
}}
data-testid="tag-filter-clear-button"
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
<span className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</div>
)}
</div>
@ -131,7 +129,7 @@ const TagFilter: FC<TagFilterProps> = ({
onClick={() => selectTag(tag)}
>
<div title={tag.name} className="grow truncate text-sm leading-5 text-text-tertiary">{tag.name}</div>
{value.includes(tag.id) && <Check className="h-4 w-4 shrink-0 text-text-secondary" />}
{value.includes(tag.id) && <span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-secondary" data-testid="tag-filter-selected-icon" />}
</div>
))}
{!filteredTagList.length && (

View File

@ -0,0 +1,351 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { act } from 'react'
import TagManagementModal from './index'
import { useStore as useTagStore } from './store'
// Hoisted mocks
const { fetchTagList, createTag } = vi.hoisted(() => ({
fetchTagList: vi.fn(),
createTag: vi.fn(),
}))
const mockNotify = vi.fn()
vi.mock('@/service/tag', () => ({
fetchTagList,
createTag,
}))
// Mock use-context-selector for ToastContext
vi.mock('use-context-selector', () => ({
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
useContext: () => ({
notify: mockNotify,
}),
}))
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-3', name: 'Database', type: 'knowledge', binding_count: 2 },
]
const defaultProps = {
type: 'app' as const,
show: true,
}
// i18n mock renders "ns.key" format (dot-separated)
const i18n = {
manageTags: 'common.tag.manageTags',
addNew: 'common.tag.addNew',
created: 'common.tag.created',
failed: 'common.tag.failed',
}
describe('TagManagementModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fetchTagList).mockResolvedValue(mockTags)
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
act(() => {
useTagStore.setState({ tagList: mockTags, showTagManagementModal: false })
})
})
describe('Rendering', () => {
it('should render the modal title when show is true', () => {
render(<TagManagementModal {...defaultProps} />)
expect(screen.getByText(i18n.manageTags)).toBeInTheDocument()
})
it('should render the close button', () => {
render(<TagManagementModal {...defaultProps} />)
const closeIcon = screen.getByTestId('tag-management-modal-close-button')
expect(closeIcon).toBeTruthy()
})
it('should render the new tag input with placeholder', () => {
render(<TagManagementModal {...defaultProps} />)
expect(screen.getByPlaceholderText(i18n.addNew)).toBeInTheDocument()
})
it('should render existing tags from the store', () => {
render(<TagManagementModal {...defaultProps} />)
// TagItemEditor renders each tag's name
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
})
it('should not render content when show is false', () => {
render(<TagManagementModal {...defaultProps} show={false} />)
// The Modal component hides content when isShow is false
expect(screen.queryByText(i18n.manageTags)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should fetch tags for the given type on mount', async () => {
render(<TagManagementModal {...defaultProps} type="app" />)
await waitFor(() => {
expect(fetchTagList).toHaveBeenCalledWith('app')
})
})
it('should fetch knowledge tags when type is knowledge', async () => {
render(<TagManagementModal {...defaultProps} type="knowledge" />)
await waitFor(() => {
expect(fetchTagList).toHaveBeenCalledWith('knowledge')
})
})
})
describe('User Interactions', () => {
it('should close modal when close button is clicked', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const closeIcon = screen.getByTestId('tag-management-modal-close-button')
const closeButton = closeIcon.parentElement!
await user.click(closeButton)
expect(useTagStore.getState().showTagManagementModal).toBe(false)
})
it('should update input value when typing', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
expect(input).toHaveValue('NewTag')
})
it('should create a new tag on Enter key press', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('NewTag', 'app')
})
})
it('should show success notification after creating a tag', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: i18n.created,
})
})
})
it('should clear input after successful tag creation', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(input).toHaveValue('')
})
})
it('should add the new tag to the store tag list', async () => {
const user = userEvent.setup()
const newTag = { id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 }
vi.mocked(createTag).mockResolvedValue(newTag)
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
await user.keyboard('{Enter}')
await waitFor(() => {
const storeTagList = useTagStore.getState().tagList
expect(storeTagList).toContainEqual(newTag)
})
})
it('should prepend the new tag to the beginning of the list', async () => {
const user = userEvent.setup()
const newTag = { id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 }
vi.mocked(createTag).mockResolvedValue(newTag)
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
await user.keyboard('{Enter}')
await waitFor(() => {
const storeTagList = useTagStore.getState().tagList
expect(storeTagList[0]).toEqual(newTag)
})
})
it('should create a tag on input blur', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
// Click outside to trigger blur
await user.click(document.body)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('NewTag', 'app')
})
})
})
describe('Error Handling', () => {
it('should not create tag when name is empty', async () => {
const user = userEvent.setup()
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
// Focus and press Enter without typing
await user.click(input)
await user.keyboard('{Enter}')
expect(createTag).not.toHaveBeenCalled()
})
it('should show error notification when tag creation fails', async () => {
const user = userEvent.setup()
vi.mocked(createTag).mockRejectedValue(new Error('Creation failed'))
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'FailTag')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: i18n.failed,
})
})
})
it('should not allow duplicate creation while pending', async () => {
const user = userEvent.setup()
// Make createTag slow to simulate pending
let resolveCreate: (value: Tag) => void
vi.mocked(createTag).mockImplementation(() => new Promise((resolve) => {
resolveCreate = resolve
}))
render(<TagManagementModal {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'NewTag')
await user.keyboard('{Enter}')
// First call should go through
expect(createTag).toHaveBeenCalledTimes(1)
// Attempt second creation while first is pending — need to type again + enter
// But the component sets pending=true, so the second call is blocked.
// The input value was cleared? No — pending is set before clearing.
// Actually the component does: setPending(true) -> await createTag -> setName('') -> setPending(false)
// So while pending, name is still 'NewTag', but calling createNewTag again does nothing.
// We can trigger via blur
await user.click(document.body)
// Should still be only 1 call because pending guard blocks it
expect(createTag).toHaveBeenCalledTimes(1)
// Resolve the pending promise
await act(async () => {
resolveCreate!({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
})
})
})
describe('Data Fetching', () => {
it('should update store with fetched tags', async () => {
const freshTags: Tag[] = [
{ id: 'fresh-1', name: 'FreshTag', type: 'app', binding_count: 0 },
]
vi.mocked(fetchTagList).mockResolvedValue(freshTags)
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<TagManagementModal {...defaultProps} />)
await waitFor(() => {
expect(useTagStore.getState().tagList).toEqual(freshTags)
})
})
it('should refetch when type prop changes', () => {
const { rerender } = render(<TagManagementModal {...defaultProps} type="app" />)
expect(fetchTagList).toHaveBeenCalledWith('app')
vi.clearAllMocks()
rerender(<TagManagementModal {...defaultProps} type="knowledge" />)
expect(fetchTagList).toHaveBeenCalledWith('knowledge')
})
})
describe('Edge Cases', () => {
it('should handle empty tag list', () => {
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<TagManagementModal {...defaultProps} />)
// Should still render the input
expect(screen.getByPlaceholderText(i18n.addNew)).toBeInTheDocument()
})
it('should handle tag creation with knowledge type', async () => {
const user = userEvent.setup()
vi.mocked(createTag).mockResolvedValue({ id: 'new-k', name: 'KnowledgeTag', type: 'knowledge', binding_count: 0 })
render(<TagManagementModal {...defaultProps} type="knowledge" />)
const input = screen.getByPlaceholderText(i18n.addNew)
await user.type(input, 'KnowledgeTag')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('KnowledgeTag', 'knowledge')
})
})
it('should close modal via the Modal onClose callback', async () => {
const user = userEvent.setup()
act(() => {
useTagStore.setState({ showTagManagementModal: true })
})
render(<TagManagementModal {...defaultProps} />)
await user.keyboard('{Escape}')
await waitFor(() => {
expect(useTagStore.getState().showTagManagementModal).toBe(false)
})
})
})
})

View File

@ -1,6 +1,5 @@
'use client'
import { RiCloseLine } from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@ -66,11 +65,11 @@ const TagManagementModal = ({ show, type }: TagManagementModalProps) => {
>
<div className="relative pb-2 text-xl font-semibold leading-[30px] text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
<div className="absolute right-4 top-4 cursor-pointer p-2" onClick={() => setShowTagManagementModal(false)}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="tag-management-modal-close-button" />
</div>
<div className="mt-3 flex flex-wrap gap-2">
<input
className="w-[100px] shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary focus:border-solid"
className="w-[100px] shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary focus:border-solid"
placeholder={t('tag.addNew', { ns: 'common' }) || ''}
autoFocus
value={name}

View File

@ -0,0 +1,603 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { act } from 'react'
import { ToastContext } from '@/app/components/base/toast'
import Panel from './panel'
import { useStore as useTagStore } from './store'
// Hoisted mocks
const { createTag, bindTag, unBindTag, contextOverrides } = vi.hoisted(() => ({
createTag: vi.fn(),
bindTag: vi.fn(),
unBindTag: vi.fn(),
contextOverrides: new Map<object, unknown>(),
}))
const mockNotify = vi.fn()
vi.mock('@/service/tag', () => ({
createTag,
bindTag,
unBindTag,
}))
// Mock use-context-selector with context-aware values and toast notify override.
vi.mock('use-context-selector', () => ({
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
useContext: <T,>(context: React.Context<T>) => {
const contextValue = React.useContext(context)
const override = contextOverrides.get(context as unknown as object)
if (override)
return override as T
return contextValue
},
}))
// i18n mock renders "ns.key" format (dot-separated)
const i18n = {
selectorPlaceholder: 'common.tag.selectorPlaceholder',
create: 'common.tag.create',
created: 'common.tag.created',
failed: 'common.tag.failed',
noTag: 'common.tag.noTag',
manageTags: 'common.tag.manageTags',
modifiedSuccessfully: 'common.actionMsg.modifiedSuccessfully',
modifiedUnsuccessfully: 'common.actionMsg.modifiedUnsuccessfully',
}
const appTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-3', name: 'API', type: 'app', binding_count: 1 },
]
const knowledgeTag: Tag = { id: 'tag-k1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 }
const defaultProps = {
targetID: 'target-1',
type: 'app' as const,
value: ['tag-1'], // tag-1 is already selected/bound
selectedTags: [appTags[0]], // pre-selected tags shown separately
onCacheUpdate: vi.fn<(tags: Tag[]) => void>(),
onChange: vi.fn<() => void>(),
onCreate: vi.fn<() => void>(),
}
describe('Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
contextOverrides.clear()
contextOverrides.set(ToastContext as unknown as object, {
notify: mockNotify,
close: vi.fn(),
})
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
vi.mocked(bindTag).mockResolvedValue(undefined)
vi.mocked(unBindTag).mockResolvedValue(undefined)
act(() => {
useTagStore.setState({ tagList: [...appTags, knowledgeTag], showTagManagementModal: false })
})
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Panel {...defaultProps} />)
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder)).toBeInTheDocument()
})
it('should render the search input', () => {
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
expect(input).toBeInTheDocument()
expect(input.tagName).toBe('INPUT')
})
it('should render selected tags from selectedTags prop', () => {
render(<Panel {...defaultProps} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
it('should render unselected tags matching the type', () => {
render(<Panel {...defaultProps} />)
// tag-2 and tag-3 are app type and not in value[]
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('API')).toBeInTheDocument()
})
it('should not render tags of a different type', () => {
render(<Panel {...defaultProps} />)
// knowledgeTag is type 'knowledge', should not appear
expect(screen.queryByText('KnowledgeDB')).not.toBeInTheDocument()
})
it('should render the manage tags button', () => {
render(<Panel {...defaultProps} />)
expect(screen.getByText(i18n.manageTags)).toBeInTheDocument()
})
it('should show no-tag message when there are no tags', () => {
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<Panel {...defaultProps} value={[]} selectedTags={[]} />)
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
it('should not show no-tag message when tags exist', () => {
render(<Panel {...defaultProps} />)
expect(screen.queryByText(i18n.noTag)).not.toBeInTheDocument()
})
})
describe('Search / Filter', () => {
it('should filter tags by keyword', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Back')
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.queryByText('API')).not.toBeInTheDocument()
})
it('should filter selected tags by keyword', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Front')
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
})
it('should show create option when keyword does not match any tag', async () => {
const user = userEvent.setup()
// notExisted uses .every(tag => tag.type === type && tag.name !== keywords)
// so store must only contain same-type tags for notExisted to be true
act(() => {
useTagStore.setState({ tagList: appTags })
})
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
// The create row shows "Create 'BrandNewTag'"
expect(screen.getByText(/BrandNewTag/)).toBeInTheDocument()
expect(screen.getByText(i18n.create, { exact: false })).toBeInTheDocument()
})
it('should not show create option when keyword matches an existing tag name', async () => {
const user = userEvent.setup()
// Use only same-type tags so we can verify name matching specifically
act(() => {
useTagStore.setState({ tagList: appTags })
})
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Frontend')
// 'Frontend' matches tag-1 name, so notExisted = false
expect(screen.queryByText(i18n.create, { exact: false })).not.toBeInTheDocument()
})
it('should clear search when clear button is clicked', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Back')
expect(input).toHaveValue('Back')
// The Input component renders a clear icon with data-testid="input-clear"
const clearButton = screen.getByTestId('input-clear')
await user.click(clearButton)
expect(input).toHaveValue('')
// All tags should be visible again
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('API')).toBeInTheDocument()
})
})
describe('Tag Selection', () => {
const getTagRow = (tagName: string) => {
const row = screen.getByText(tagName).closest('[data-testid="tag-row"]')
expect(row).not.toBeNull()
return row as HTMLElement
}
it('should select an unselected tag when clicked', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const backendRowBeforeSelect = getTagRow('Backend')
expect(within(backendRowBeforeSelect).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
await user.click(screen.getByText('Backend'))
const backendRowAfterSelect = getTagRow('Backend')
expect(within(backendRowAfterSelect).getByTestId('check-icon-tag-2')).toBeInTheDocument()
})
it('should deselect a selected tag when clicked', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const frontendRowBeforeDeselect = getTagRow('Frontend')
expect(within(frontendRowBeforeDeselect).getByTestId('check-icon-tag-1')).toBeInTheDocument()
await user.click(screen.getByText('Frontend'))
const frontendRowAfterDeselect = getTagRow('Frontend')
expect(within(frontendRowAfterDeselect).queryByTestId('check-icon-tag-1')).not.toBeInTheDocument()
})
it('should toggle tag selection on multiple clicks', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const backendRowBeforeToggle = getTagRow('Backend')
expect(within(backendRowBeforeToggle).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
await user.click(screen.getByText('Backend'))
const backendRowAfterFirstClick = getTagRow('Backend')
expect(within(backendRowAfterFirstClick).getByTestId('check-icon-tag-2')).toBeInTheDocument()
await user.click(screen.getByText('Backend'))
const backendRowAfterSecondClick = getTagRow('Backend')
expect(within(backendRowAfterSecondClick).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
})
})
describe('Tag Creation', () => {
beforeEach(() => {
// notExisted requires all tags to be same type, so remove knowledgeTag
act(() => {
useTagStore.setState({ tagList: appTags })
})
})
it('should create a new tag when clicking the create option', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('BrandNewTag', 'app')
})
})
it('should show success notification after tag creation', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: i18n.created,
})
})
})
it('should clear keywords after successful tag creation', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(input).toHaveValue('')
})
})
it('should call onCreate callback after successful tag creation', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(defaultProps.onCreate).toHaveBeenCalled()
})
})
it('should add new tag to the store tag list', async () => {
const user = userEvent.setup()
const newTag: Tag = { id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 }
vi.mocked(createTag).mockResolvedValue(newTag)
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
const storeTagList = useTagStore.getState().tagList
expect(storeTagList).toContainEqual(newTag)
})
})
it('should show error notification when tag creation fails', async () => {
const user = userEvent.setup()
vi.mocked(createTag).mockRejectedValue(new Error('Creation failed'))
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'FailTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: i18n.failed,
})
})
})
it('should not create tag when keywords is empty', () => {
render(<Panel {...defaultProps} />)
// The create option should not appear when no keywords
expect(screen.queryByText(i18n.create, { exact: false })).not.toBeInTheDocument()
expect(createTag).not.toHaveBeenCalled()
})
it('should not allow duplicate creation while pending', async () => {
const user = userEvent.setup()
let resolveCreate!: (value: Tag) => void
vi.mocked(createTag).mockImplementation(() => new Promise((resolve) => {
resolveCreate = resolve
}))
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
expect(createTag).toHaveBeenCalledTimes(1)
// Try clicking again while still pending
await user.click(createOption)
// Should still be only 1 call because creating guard blocks it
expect(createTag).toHaveBeenCalledTimes(1)
// Resolve the pending promise
await act(async () => {
resolveCreate({ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 })
})
})
})
describe('Bind/Unbind on Unmount', () => {
it('should call bindTag for newly selected tags on unmount', async () => {
const user = userEvent.setup()
const { unmount } = render(<Panel {...defaultProps} />)
// Select 'Backend' (tag-2) — currently not in value[]
await user.click(screen.getByText('Backend'))
unmount()
await waitFor(() => {
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
})
})
it('should call unBindTag for deselected tags on unmount', async () => {
const user = userEvent.setup()
const { unmount } = render(<Panel {...defaultProps} />)
// Deselect 'Frontend' (tag-1) — currently in value[]
await user.click(screen.getByText('Frontend'))
unmount()
await waitFor(() => {
expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app')
})
})
it('should call onCacheUpdate with selected tags on unmount when value changed', async () => {
const user = userEvent.setup()
const { unmount } = render(<Panel {...defaultProps} />)
// Select 'Backend' (tag-2)
await user.click(screen.getByText('Backend'))
unmount()
await waitFor(() => {
expect(defaultProps.onCacheUpdate).toHaveBeenCalledTimes(1)
})
const [updatedTags] = vi.mocked(defaultProps.onCacheUpdate).mock.calls[0]
expect(updatedTags.map(tag => tag.id)).toEqual(['tag-1', 'tag-2'])
})
it('should not call bind/unbind when value has not changed', async () => {
const { unmount } = render(<Panel {...defaultProps} />)
unmount()
await act(async () => {})
expect(bindTag).not.toHaveBeenCalled()
expect(unBindTag).not.toHaveBeenCalled()
})
it('should call onChange after all operations complete on unmount', async () => {
const user = userEvent.setup()
const { unmount } = render(<Panel {...defaultProps} />)
await user.click(screen.getByText('Backend'))
unmount()
await waitFor(() => {
expect(defaultProps.onChange).toHaveBeenCalled()
})
})
it('should show success notification after successful bind', async () => {
const user = userEvent.setup()
const { unmount } = render(<Panel {...defaultProps} />)
await user.click(screen.getByText('Backend'))
unmount()
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: i18n.modifiedSuccessfully,
})
})
})
it('should show error notification when bind fails', async () => {
const user = userEvent.setup()
vi.mocked(bindTag).mockRejectedValue(new Error('Bind failed'))
const { unmount } = render(<Panel {...defaultProps} />)
await user.click(screen.getByText('Backend'))
unmount()
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: i18n.modifiedUnsuccessfully,
})
})
})
it('should show error notification when unbind fails', async () => {
const user = userEvent.setup()
vi.mocked(unBindTag).mockRejectedValue(new Error('Unbind failed'))
const { unmount } = render(<Panel {...defaultProps} />)
await user.click(screen.getByText('Frontend'))
unmount()
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: i18n.modifiedUnsuccessfully,
})
})
})
})
describe('Manage Tags Modal', () => {
it('should open the tag management modal when manage tags is clicked', async () => {
const user = userEvent.setup()
render(<Panel {...defaultProps} />)
await user.click(screen.getByText(i18n.manageTags))
expect(useTagStore.getState().showTagManagementModal).toBe(true)
})
})
describe('Edge Cases', () => {
it('should handle empty value array', () => {
render(<Panel {...defaultProps} value={[]} selectedTags={[]} />)
// All app-type tags should appear in the unselected list
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('API')).toBeInTheDocument()
})
it('should handle empty tagList in store', () => {
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<Panel {...defaultProps} value={[]} selectedTags={[]} />)
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
it('should handle all tags already selected', () => {
render(
<Panel
{...defaultProps}
value={['tag-1', 'tag-2', 'tag-3']}
selectedTags={appTags}
/>,
)
// All app tags appear in selectedTags, filteredTagList should be empty
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('API')).toBeInTheDocument()
})
it('should show divider between create option and tag list when both present', async () => {
const user = userEvent.setup()
// Only same-type tags for notExisted to work
act(() => {
useTagStore.setState({ tagList: appTags })
})
render(<Panel {...defaultProps} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Back')
// 'Back' matches Backend (unselected), notExisted is true (no tag named 'Back')
// filteredTagList has items, so the conditional divider between create-option and tag-list renders
const dividers = screen.getAllByTestId('divider')
expect(dividers.length).toBeGreaterThanOrEqual(2)
})
it('should handle knowledge type tags correctly', () => {
act(() => {
useTagStore.setState({ tagList: [knowledgeTag] })
})
render(
<Panel
{...defaultProps}
type="knowledge"
value={[]}
selectedTags={[]}
/>,
)
expect(screen.getByText('KnowledgeDB')).toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,6 @@
import type { TagSelectorProps } from './selector'
import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiAddLine, RiPriceTag3Line } from '@remixicon/react'
import { useUnmount } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
@ -131,10 +130,11 @@ const Panel = (props: PanelProps) => {
<div className="p-1">
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
data-testid="create-tag-option"
onClick={createNewTag}
>
<RiAddLine className="h-4 w-4 text-text-tertiary" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">
<span className="i-ri-add-line h-4 w-4 text-text-tertiary" />
<div className="grow truncate px-1 text-text-secondary system-md-regular">
{`${t('tag.create', { ns: 'common' })} `}
<span className="system-md-medium">{`'${keywords}'`}</span>
</div>
@ -151,15 +151,17 @@ const Panel = (props: PanelProps) => {
key={tag.id}
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => selectTag(tag)}
data-testid="tag-row"
>
<Checkbox
className="shrink-0"
checked={selectedTagIDs.includes(tag.id)}
onCheck={noop}
id={tag.id}
/>
<div
title={tag.name}
className="system-md-regular grow truncate px-1 text-text-secondary"
className="grow truncate px-1 text-text-secondary system-md-regular"
>
{tag.name}
</div>
@ -170,15 +172,17 @@ const Panel = (props: PanelProps) => {
key={tag.id}
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => selectTag(tag)}
data-testid="tag-row"
>
<Checkbox
className="shrink-0"
checked={selectedTagIDs.includes(tag.id)}
onCheck={noop}
id={tag.id}
/>
<div
title={tag.name}
className="system-md-regular grow truncate px-1 text-text-secondary"
className="grow truncate px-1 text-text-secondary system-md-regular"
>
{tag.name}
</div>
@ -189,8 +193,8 @@ const Panel = (props: PanelProps) => {
{!keywords && !filteredTagList.length && !filteredSelectedTagList.length && (
<div className="p-1">
<div className="flex flex-col items-center gap-y-1 p-3">
<RiPriceTag3Line className="h-6 w-6 text-text-quaternary" />
<div className="system-xs-regular text-text-tertiary">{t('tag.noTag', { ns: 'common' })}</div>
<span className="i-ri-price-tag-3-line h-6 w-6 text-text-quaternary" />
<div className="text-text-tertiary system-xs-regular">{t('tag.noTag', { ns: 'common' })}</div>
</div>
</div>
)}
@ -200,8 +204,8 @@ const Panel = (props: PanelProps) => {
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => setShowTagManagementModal(true)}
>
<RiPriceTag3Line className="h-4 w-4 text-text-tertiary" />
<div className="system-md-regular grow truncate px-1 text-text-secondary">
<span className="i-ri-price-tag-3-line h-4 w-4 text-text-tertiary" />
<div className="grow truncate px-1 text-text-secondary system-md-regular">
{t('tag.manageTags', { ns: 'common' })}
</div>
</div>

View File

@ -0,0 +1,347 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { act } from 'react'
import { ToastContext } from '@/app/components/base/toast'
import TagSelector from './selector'
import { useStore as useTagStore } from './store'
// Hoisted mocks
const { fetchTagList, createTag, bindTag, unBindTag } = vi.hoisted(() => ({
fetchTagList: vi.fn(),
createTag: vi.fn(),
bindTag: vi.fn(),
unBindTag: vi.fn(),
}))
const mockNotify = vi.fn()
vi.mock('@/service/tag', () => ({
fetchTagList,
createTag,
bindTag,
unBindTag,
}))
// Mock popover for deterministic open/close behavior in unit tests.
vi.mock('@/app/components/base/popover', () => {
type PopoverContentProps = {
open?: boolean
onClose?: () => void
}
type MockPopoverProps = {
htmlContent: React.ReactNode
btnElement?: React.ReactNode
btnClassName?: string | ((open: boolean) => string)
}
const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => {
const [isOpen, setIsOpen] = React.useState(false)
const computedClassName = typeof btnClassName === 'function'
? btnClassName(isOpen)
: btnClassName
const content = React.isValidElement(htmlContent)
? React.cloneElement(htmlContent as React.ReactElement<PopoverContentProps>, {
open: isOpen,
onClose: () => setIsOpen(false),
})
: htmlContent
return (
<div data-testid="custom-popover">
<button
type="button"
aria-expanded={isOpen}
className={computedClassName}
onClick={() => setIsOpen(prev => !prev)}
>
{btnElement}
</button>
{isOpen && (
<div data-testid="popover-content">
{content}
</div>
)}
</div>
)
}
return { __esModule: true, default: MockPopover }
})
// Mock use-context-selector for ToastContext
vi.mock('use-context-selector', () => ({
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
useContext: <T,>(ctx: React.Context<T>) => {
if (ctx === (ToastContext as unknown as React.Context<T>))
return { notify: mockNotify, close: vi.fn() } as T
// eslint-disable-next-line react-hooks/rules-of-hooks
return React.useContext(ctx)
},
}))
// i18n keys rendered in "ns.key" format
const i18n = {
addTag: 'common.tag.addTag',
selectorPlaceholder: 'common.tag.selectorPlaceholder',
manageTags: 'common.tag.manageTags',
noTag: 'common.tag.noTag',
}
const appTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
]
const defaultProps = {
targetID: 'target-1',
type: 'app' as const,
value: ['tag-1'],
selectedTags: [appTags[0]],
onCacheUpdate: vi.fn(),
onChange: vi.fn(),
}
describe('TagSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fetchTagList).mockResolvedValue(appTags)
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
vi.mocked(bindTag).mockResolvedValue(undefined)
vi.mocked(unBindTag).mockResolvedValue(undefined)
act(() => {
useTagStore.setState({ tagList: appTags, showTagManagementModal: false })
})
})
describe('Rendering', () => {
it('should render TagSelector trigger with selected tag names from defaultProps when isPopover defaults to true', () => {
render(<TagSelector {...defaultProps} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
it('should render TagSelector add-tag placeholder when defaultProps are overridden with empty selectedTags and value', () => {
render(<TagSelector {...defaultProps} selectedTags={[]} value={[]} />)
expect(screen.getByText(i18n.addTag)).toBeInTheDocument()
})
it('should render nothing when isPopover is false', () => {
const { container } = render(<TagSelector {...defaultProps} isPopover={false} />)
// Only the empty fragment wrapper
expect(container).toBeEmptyDOMElement()
})
it('should render the popover trigger button', () => {
render(<TagSelector {...defaultProps} />)
// The trigger is wrapped in a PopoverButton
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should filter selectedTags to only those present in store tagList', () => {
const unknownTag: Tag = { id: 'unknown', name: 'Unknown', type: 'app', binding_count: 0 }
render(
<TagSelector
{...defaultProps}
selectedTags={[appTags[0], unknownTag]}
value={['tag-1', 'unknown']}
/>,
)
// 'Frontend' is in tagList, 'Unknown' is not
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
})
it('should display multiple tag names when multiple are selected', () => {
render(
<TagSelector
{...defaultProps}
selectedTags={appTags}
value={['tag-1', 'tag-2']}
/>,
)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
})
})
describe('Popover Interaction', () => {
it('should show the panel when the trigger is clicked', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
await user.click(screen.getByRole('button'))
// Panel renders the search input and manage tags
await waitFor(() => {
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder)).toBeInTheDocument()
expect(screen.getByText(i18n.manageTags)).toBeInTheDocument()
})
})
it('should show unselected tags in the panel', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('Backend')).toBeInTheDocument()
})
})
it('should show the no-tag message when tag list is empty', async () => {
const user = userEvent.setup()
act(() => {
useTagStore.setState({ tagList: [] })
})
render(<TagSelector {...defaultProps} selectedTags={[]} value={[]} />)
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
})
it('should bind a newly selected tag and update cache when closing the panel', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
const popoverContent = await screen.findByTestId('popover-content')
await user.click(within(popoverContent).getByText('Backend'))
// Close panel to trigger unmount side effects.
await user.click(triggerButton)
await waitFor(() => {
expect(bindTag).toHaveBeenCalledTimes(1)
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
expect(defaultProps.onCacheUpdate).toHaveBeenCalledTimes(1)
expect(defaultProps.onCacheUpdate).toHaveBeenCalledWith(appTags)
})
})
it('should unbind a deselected tag and update cache when closing the panel', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
const popoverContent = await screen.findByTestId('popover-content')
await user.click(within(popoverContent).getByText('Frontend'))
// Close panel to trigger unmount side effects.
await user.click(triggerButton)
await waitFor(() => {
expect(unBindTag).toHaveBeenCalledTimes(1)
expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app')
expect(defaultProps.onCacheUpdate).toHaveBeenCalledTimes(1)
expect(defaultProps.onCacheUpdate).toHaveBeenCalledWith([])
})
})
})
describe('Data Fetching (getTagList / onCreate)', () => {
it('should update the store tagList after fetching', async () => {
const user = userEvent.setup()
const freshTags: Tag[] = [
...appTags,
{ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 },
]
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 })
vi.mocked(fetchTagList).mockResolvedValue(freshTags)
render(<TagSelector {...defaultProps} />)
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder)).toBeInTheDocument()
})
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('BrandNewTag', 'app')
})
await waitFor(() => {
expect(fetchTagList).toHaveBeenCalled()
})
await waitFor(() => {
expect(useTagStore.getState().tagList).toEqual(freshTags)
})
})
})
describe('Edge Cases', () => {
it('should handle selectedTags with no matching tags in store', () => {
const orphanTags: Tag[] = [
{ id: 'orphan-1', name: 'Orphan', type: 'app', binding_count: 0 },
]
render(
<TagSelector
{...defaultProps}
selectedTags={orphanTags}
value={['orphan-1']}
/>,
)
// Orphan tag is not in store tagList, so tags memo returns []
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.addTag)).toBeInTheDocument()
})
it('should handle knowledge type', async () => {
const user = userEvent.setup()
const knowledgeTags: Tag[] = [
{ id: 'k-1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 },
]
vi.mocked(fetchTagList).mockResolvedValue(knowledgeTags)
act(() => {
useTagStore.setState({ tagList: knowledgeTags })
})
render(
<TagSelector
{...defaultProps}
type="knowledge"
selectedTags={knowledgeTags}
value={['k-1']}
/>,
)
expect(screen.getByText('KnowledgeDB')).toBeInTheDocument()
// Open popover and verify panel uses knowledge type
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder)).toBeInTheDocument()
})
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'NewKnowledgeTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('NewKnowledgeTag', 'knowledge')
})
})
})
})

View File

@ -0,0 +1,236 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { act } from 'react'
import { useStore as useTagStore } from './store'
import TagItemEditor from './tag-item-editor'
const { updateTag, deleteTag, mockNotify } = vi.hoisted(() => ({
updateTag: vi.fn(),
deleteTag: vi.fn(),
mockNotify: vi.fn(),
}))
vi.mock('@/service/tag', () => ({
updateTag,
deleteTag,
}))
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: (...args: unknown[]) => fn(...args),
}),
}
})
vi.mock('use-context-selector', () => ({
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
useContext: () => ({
notify: mockNotify,
}),
}))
const baseTag: Tag = {
id: 'tag-1',
name: 'Frontend',
type: 'app',
binding_count: 3,
}
const anotherTag: Tag = {
id: 'tag-2',
name: 'Backend',
type: 'app',
binding_count: 1,
}
describe('TagItemEditor', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(updateTag).mockResolvedValue(undefined)
vi.mocked(deleteTag).mockResolvedValue(undefined)
act(() => {
useTagStore.setState({
tagList: [baseTag, anotherTag],
showTagManagementModal: false,
})
})
})
// Rendering behavior for initial tag display.
describe('Rendering', () => {
it('should render tag name and binding count', () => {
render(<TagItemEditor tag={baseTag} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
})
// Edit flow behavior: enter editing, save, and validation/error cases.
describe('Edit Flow', () => {
it('should enter editing mode when edit icon is clicked', async () => {
const user = userEvent.setup()
render(<TagItemEditor tag={baseTag} />)
const editButton = screen.getByTestId('tag-item-editor-edit-button')
expect(editButton).toBeInTheDocument()
await user.click(editButton as HTMLElement)
expect(screen.getByRole('textbox')).toHaveValue('Frontend')
})
it('should update tag and notify success when submitting a new name', async () => {
const user = userEvent.setup()
render(<TagItemEditor tag={baseTag} />)
const editButton = screen.getByTestId('tag-item-editor-edit-button')
await user.click(editButton as HTMLElement)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Frontend V2')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(updateTag).toHaveBeenCalledWith('tag-1', 'Frontend V2')
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')?.name).toBe('Frontend V2')
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should show validation error and skip update when name is empty', async () => {
const user = userEvent.setup()
render(<TagItemEditor tag={baseTag} />)
const editButton = screen.getByTestId('tag-item-editor-edit-button')
await user.click(editButton as HTMLElement)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.click(document.body)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'tag name is empty',
})
})
expect(updateTag).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
it('should recover and notify error when update request fails', async () => {
const user = userEvent.setup()
vi.mocked(updateTag).mockRejectedValueOnce(new Error('update failed'))
render(<TagItemEditor tag={baseTag} />)
const editButton = screen.getByTestId('tag-item-editor-edit-button')
await user.click(editButton as HTMLElement)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Broken Name')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(updateTag).toHaveBeenCalledWith('tag-1', 'Broken Name')
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')?.name).toBe('Frontend')
})
})
// Remove behavior for direct delete and confirm modal paths.
describe('Remove Flow', () => {
it('should delete immediately when binding count is zero', async () => {
const user = userEvent.setup()
const removableTag: Tag = { ...baseTag, binding_count: 0 }
act(() => {
useTagStore.setState({ tagList: [removableTag, anotherTag] })
})
render(<TagItemEditor tag={removableTag} />)
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
expect(removeButton).toBeInTheDocument()
await user.click(removeButton as HTMLElement)
await waitFor(() => {
expect(deleteTag).toHaveBeenCalledWith('tag-1')
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')).toBeUndefined()
})
it('should open confirm modal and delete on confirm when binding count is non-zero', async () => {
const user = userEvent.setup()
render(<TagItemEditor tag={baseTag} />)
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
await user.click(removeButton as HTMLElement)
expect(screen.getByText('common.tag.delete "Frontend"')).toBeInTheDocument()
await user.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(deleteTag).toHaveBeenCalledWith('tag-1')
})
await waitFor(() => {
expect(screen.queryByText('common.tag.delete "Frontend"')).not.toBeInTheDocument()
})
})
it('should close confirm modal without deleting when cancel is clicked', async () => {
const user = userEvent.setup()
render(<TagItemEditor tag={baseTag} />)
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
await user.click(removeButton as HTMLElement)
expect(screen.getByText('common.tag.delete "Frontend"')).toBeInTheDocument()
await user.click(screen.getByText('common.operation.cancel'))
expect(deleteTag).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.queryByText('common.tag.delete "Frontend"')).not.toBeInTheDocument()
})
})
it('should notify error and keep tag when delete request fails', async () => {
const user = userEvent.setup()
vi.mocked(deleteTag).mockRejectedValueOnce(new Error('delete failed'))
const removableTag: Tag = { ...baseTag, binding_count: 0 }
act(() => {
useTagStore.setState({ tagList: [removableTag, anotherTag] })
})
render(<TagItemEditor tag={removableTag} />)
const removeButton = screen.getByTestId('tag-item-editor-remove-button')
await user.click(removeButton as HTMLElement)
await waitFor(() => {
expect(deleteTag).toHaveBeenCalledWith('tag-1')
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
expect(useTagStore.getState().tagList.find(tag => tag.id === 'tag-1')).toBeDefined()
})
})
})

View File

@ -1,9 +1,6 @@
import type { FC } from 'react'
import type { Tag } from '@/app/components/base/tag-management/constant'
import {
RiDeleteBinLine,
RiEditLine,
} from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -119,7 +116,7 @@ const TagItemEditor: FC<TagItemEditorProps> = ({
<div className="leading-4.5 shrink-0 px-1 text-sm font-medium text-text-tertiary">{tag.binding_count}</div>
</Tooltip>
<div className="group/edit shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover" onClick={() => setIsEditing(true)}>
<RiEditLine className="h-3 w-3 text-text-tertiary group-hover/edit:text-text-secondary" />
<span className="i-ri-edit-line h-3 w-3 text-text-tertiary group-hover/edit:text-text-secondary" data-testid="tag-item-editor-edit-button" />
</div>
<div
className="group/remove shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
@ -130,7 +127,7 @@ const TagItemEditor: FC<TagItemEditorProps> = ({
handleRemove()
}}
>
<RiDeleteBinLine className="h-3 w-3 text-text-tertiary group-hover/remove:text-text-secondary" />
<span className="i-ri-delete-bin-line h-3 w-3 text-text-tertiary group-hover/remove:text-text-secondary" data-testid="tag-item-editor-remove-button" />
</div>
</>
)}

View File

@ -0,0 +1,123 @@
import type { Tag } from './constant'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagRemoveModal from './tag-remove-modal'
const mockTag: Tag = {
id: 'tag-1',
name: 'Frontend',
type: 'app',
binding_count: 3,
}
describe('TagRemoveModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior and visibility control.
describe('Rendering', () => {
it('should render modal content when show is true', () => {
render(
<TagRemoveModal
show={true}
tag={mockTag}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('common.tag.delete')).toBeInTheDocument()
expect(screen.getByText('"Frontend"')).toBeInTheDocument()
expect(screen.getByText('common.tag.deleteTip')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
render(
<TagRemoveModal
show={false}
tag={mockTag}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.queryByText('common.tag.delete')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.deleteTip')).not.toBeInTheDocument()
})
})
// User interactions for closing and confirming actions.
describe('User Interactions', () => {
it('should call onClose when top-right close icon is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<TagRemoveModal
show={true}
tag={mockTag}
onConfirm={vi.fn()}
onClose={onClose}
/>,
)
const closeIconButton = screen.getByTestId('tag-remove-modal-close-button')
expect(closeIconButton).toBeInTheDocument()
await user.click(closeIconButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when cancel button is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<TagRemoveModal
show={true}
tag={mockTag}
onConfirm={vi.fn()}
onClose={onClose}
/>,
)
await user.click(screen.getByText('common.operation.cancel'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onConfirm when delete button is clicked', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
render(
<TagRemoveModal
show={true}
tag={mockTag}
onConfirm={onConfirm}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByText('common.operation.delete'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
// Edge case for unusual tag names in the title.
describe('Edge Cases', () => {
it('should render quoted empty tag name safely', () => {
render(
<TagRemoveModal
show={true}
tag={{ ...mockTag, name: '' }}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('""')).toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,6 @@
'use client'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@ -25,8 +24,8 @@ const TagRemoveModal = ({ show, tag, onConfirm, onClose }: TagRemoveModalProps)
isShow={show}
onClose={noop}
>
<div className="absolute right-4 top-4 cursor-pointer p-2" onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
<div className="absolute right-4 top-4 cursor-pointer p-2" onClick={onClose} data-testid="tag-remove-modal-close-button">
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl">
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />

View File

@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import Trigger from './trigger'
describe('Trigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for empty and populated states.
describe('Rendering', () => {
it('should render add-tag placeholder when tags are empty', () => {
render(<Trigger tags={[]} />)
expect(screen.getByText('common.tag.addTag')).toBeInTheDocument()
})
it('should render all tags when tags are provided', () => {
render(<Trigger tags={['Frontend', 'Backend']} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
})
// Prop-driven rendering updates.
describe('Props', () => {
it('should update from placeholder to tag badges when tags prop changes', () => {
const { rerender } = render(<Trigger tags={[]} />)
expect(screen.getByText('common.tag.addTag')).toBeInTheDocument()
rerender(<Trigger tags={['Database']} />)
expect(screen.getByText('Database')).toBeInTheDocument()
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
})
// Edge behavior for unusual but valid tag arrays.
describe('Edge Cases', () => {
it('should render a badge even when a tag label is an empty string', () => {
render(<Trigger tags={['']} />)
// One outer container + one tag badge.
expect(screen.getAllByTestId(/^tag-badge-/)).toHaveLength(1)
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
it('should render one badge per tag for longer tag lists', () => {
const tags = ['A', 'B', 'C', 'D', 'E']
render(<Trigger tags={tags} />)
tags.forEach(tag => expect(screen.getByText(tag)).toBeInTheDocument())
expect(screen.getAllByTestId(/^tag-badge-/)).toHaveLength(tags.length)
})
})
})

View File

@ -1,4 +1,3 @@
import { RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -16,8 +15,8 @@ const Trigger = ({
{!tags.length
? (
<div className="flex items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
<div className="system-2xs-medium-uppercase text-nowrap text-text-tertiary">
<span className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
<div className="text-nowrap text-text-tertiary system-2xs-medium-uppercase">
{t('tag.addTag', { ns: 'common' })}
</div>
</div>
@ -30,9 +29,10 @@ const Trigger = ({
<div
key={index}
className="flex items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
data-testid={`tag-badge-${index}`}
>
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
<div className="system-2xs-medium-uppercase text-nowrap text-text-tertiary">
<span className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
<div className="text-nowrap text-text-tertiary system-2xs-medium-uppercase">
{content}
</div>
</div>

View File

@ -1681,14 +1681,6 @@
"count": 1
}
},
"app/components/base/divider/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/base/drawer-plus/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
@ -2111,9 +2103,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 11
}
@ -2369,11 +2358,6 @@
"count": 2
}
},
"app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2599,21 +2583,6 @@
"count": 1
}
},
"app/components/base/tag-management/index.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/base/tag-management/panel.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/base/tag-management/trigger.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/text-generation/hooks.ts": {
"ts/no-explicit-any": {
"count": 1