mirror of
https://github.com/langgenius/dify.git
synced 2026-03-13 19:17:43 +08:00
test: improve coverage for some files (#33218)
This commit is contained in:
@ -151,6 +151,43 @@ describe('BlockInput', () => {
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle change when onConfirm is not provided', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'Hello World' } })
|
||||
|
||||
expect(textarea).toHaveValue('Hello World')
|
||||
})
|
||||
|
||||
it('should enter edit mode when clicked with empty value', async () => {
|
||||
render(<BlockInput value="" />)
|
||||
const contentArea = screen.getByTestId('block-input').firstChild as Element
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should exit edit mode on blur', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
|
||||
fireEvent.blur(textarea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
@ -168,8 +205,9 @@ describe('BlockInput', () => {
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
render(<BlockInput value="line1\nline2" />)
|
||||
const { container } = render(<BlockInput value={`line1\nline2`} />)
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
expect(container.querySelector('br')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple same variables', () => {
|
||||
|
||||
@ -40,7 +40,7 @@ const createMockEmblaApi = (): MockEmblaApi => ({
|
||||
canScrollPrev: vi.fn(() => mockCanScrollPrev),
|
||||
canScrollNext: vi.fn(() => mockCanScrollNext),
|
||||
slideNodes: vi.fn(() =>
|
||||
Array.from({ length: mockSlideCount }, () => document.createElement('div')),
|
||||
Array.from({ length: mockSlideCount }).fill(document.createElement('div')),
|
||||
),
|
||||
on: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event].push(callback)
|
||||
@ -50,12 +50,13 @@ const createMockEmblaApi = (): MockEmblaApi => ({
|
||||
}),
|
||||
})
|
||||
|
||||
const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => {
|
||||
function emitEmblaEvent(event: EmblaEventName, api?: MockEmblaApi) {
|
||||
const resolvedApi = arguments.length === 1 ? mockApi : api
|
||||
|
||||
listeners[event].forEach((callback) => {
|
||||
callback(api)
|
||||
callback(resolvedApi)
|
||||
})
|
||||
}
|
||||
|
||||
const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => {
|
||||
return render(
|
||||
<Carousel orientation={orientation}>
|
||||
@ -133,6 +134,24 @@ describe('Carousel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Ref API exposes embla and controls.
|
||||
describe('Ref API', () => {
|
||||
it('should expose carousel API and controls via ref', () => {
|
||||
type CarouselRef = { api: unknown, selectedIndex: number }
|
||||
const ref = { current: null as CarouselRef | null }
|
||||
|
||||
render(
|
||||
<Carousel ref={(r) => { ref.current = r as unknown as CarouselRef }}>
|
||||
<Carousel.Content />
|
||||
</Carousel>,
|
||||
)
|
||||
|
||||
expect(ref.current).toBeDefined()
|
||||
expect(ref.current?.api).toBe(mockApi)
|
||||
expect(ref.current?.selectedIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Users can move slides through previous and next controls.
|
||||
describe('User interactions', () => {
|
||||
it('should call scroll handlers when previous and next buttons are clicked', () => {
|
||||
|
||||
@ -54,6 +54,26 @@ describe('AgentContent', () => {
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content')
|
||||
})
|
||||
|
||||
it('renders empty string if logAnnotation content is missing', () => {
|
||||
const itemWithEmptyAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: { content: '' },
|
||||
},
|
||||
}
|
||||
const { rerender } = render(<AgentContent item={itemWithEmptyAnnotation as ChatItem} />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveAttribute('data-content', '')
|
||||
|
||||
const itemWithUndefinedAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: {},
|
||||
},
|
||||
}
|
||||
rerender(<AgentContent item={itemWithUndefinedAnnotation as ChatItem} />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveAttribute('data-content', '')
|
||||
})
|
||||
|
||||
it('renders content prop if provided and no annotation', () => {
|
||||
render(<AgentContent item={mockItem} content="Direct Content" />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content')
|
||||
|
||||
@ -39,6 +39,28 @@ describe('BasicContent', () => {
|
||||
expect(markdown).toHaveAttribute('data-content', 'Annotated Content')
|
||||
})
|
||||
|
||||
it('renders empty string if logAnnotation content is missing', () => {
|
||||
const itemWithEmptyAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
const { rerender } = render(<BasicContent item={itemWithEmptyAnnotation as ChatItem} />)
|
||||
expect(screen.getByTestId('basic-content-markdown')).toHaveAttribute('data-content', '')
|
||||
|
||||
const itemWithUndefinedAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: {},
|
||||
},
|
||||
}
|
||||
rerender(<BasicContent item={itemWithUndefinedAnnotation as ChatItem} />)
|
||||
expect(screen.getByTestId('basic-content-markdown')).toHaveAttribute('data-content', '')
|
||||
})
|
||||
|
||||
it('wraps Windows UNC paths in backticks', () => {
|
||||
const itemWithUNC = {
|
||||
...mockItem,
|
||||
|
||||
@ -0,0 +1,376 @@
|
||||
import type { ChatItem } from '../../../types'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Answer from '../index'
|
||||
|
||||
// Mock the chat context
|
||||
vi.mock('../context', () => ({
|
||||
useChatContext: vi.fn(() => ({
|
||||
getHumanInputNodeData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('Answer Component', () => {
|
||||
const defaultProps = {
|
||||
item: {
|
||||
id: 'msg-1',
|
||||
content: 'Test response',
|
||||
isAnswer: true,
|
||||
} as unknown as ChatItem,
|
||||
question: 'Hello?',
|
||||
index: 0,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 500,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render basic content correctly', async () => {
|
||||
render(<Answer {...defaultProps} />)
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading animation when responding and content is empty', () => {
|
||||
const { container } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{ id: '1', content: '', isAnswer: true } as unknown as ChatItem}
|
||||
responding={true}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Blocks', () => {
|
||||
it('should render workflow process', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
workflowProcess: { status: 'running', tracing: [], steps: [] },
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render agent thoughts', () => {
|
||||
const { container } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
agent_thoughts: [{ id: '1', thought: 'Thinking...' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('.group')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file lists', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
allFiles: [{ id: 'f1', type: 'image', name: 'test.png' }],
|
||||
message_files: [{ id: 'f2', type: 'document', name: 'doc.pdf' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('file-list')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render annotation edit title', async () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
annotation: { id: 'a1', authorName: 'John Doe' },
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(await screen.findByText(/John Doe/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render citations', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
citation: [{ id: 'c1', title: 'Source 1' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Human Inputs Layout', () => {
|
||||
it('should render human input form data list', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
humanInputFormDataList: [{ id: 'form1' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render human input filled form data list', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
humanInputFilledFormDataList: [{ id: 'form1_filled' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should handle switch sibling', () => {
|
||||
const mockSwitchSibling = vi.fn()
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
siblingCount: 3,
|
||||
siblingIndex: 1,
|
||||
prevSibling: 'msg-0',
|
||||
nextSibling: 'msg-2',
|
||||
} as unknown as ChatItem}
|
||||
switchSibling={mockSwitchSibling}
|
||||
/>,
|
||||
)
|
||||
|
||||
const prevBtn = screen.getByRole('button', { name: 'Previous' })
|
||||
fireEvent.click(prevBtn)
|
||||
expect(mockSwitchSibling).toHaveBeenCalledWith('msg-0')
|
||||
|
||||
// reset mock for next sibling click
|
||||
const nextBtn = screen.getByRole('button', { name: 'Next' })
|
||||
fireEvent.click(nextBtn)
|
||||
expect(mockSwitchSibling).toHaveBeenCalledWith('msg-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Props', () => {
|
||||
it('should handle hideAvatar properly', () => {
|
||||
render(<Answer {...defaultProps} hideAvatar={true} />)
|
||||
expect(screen.queryByTestId('emoji')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom answerIcon', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
answerIcon={<div data-testid="custom-answer-icon">Custom Icon</div>}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('custom-answer-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle hideProcessDetail with appData', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
hideProcessDetail={true}
|
||||
appData={{ site: { show_workflow_steps: false } } as unknown as AppData}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
workflowProcess: { status: 'running', tracing: [], steps: [] },
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render More component', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
more: { messages: [{ text: 'more content' }] },
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('more-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content with hasHumanInput but contentIsEmpty and no agent_thoughts', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
content: '',
|
||||
humanInputFormDataList: [{ id: 'form1' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container-humaninput')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content switch within hasHumanInput but contentIsEmpty', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
content: '',
|
||||
siblingCount: 2,
|
||||
siblingIndex: 1,
|
||||
prevSibling: 'msg-0',
|
||||
humanInputFormDataList: [{ id: 'form1' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container-humaninput')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle responding=true in human inputs layout block 2', () => {
|
||||
const { container } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
responding={true}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
content: '',
|
||||
humanInputFormDataList: [{ id: 'form1' }],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ResizeObserver callback', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
let triggerResize = () => { }
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
constructor(callback: unknown) {
|
||||
triggerResize = callback as () => void
|
||||
}
|
||||
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect() { }
|
||||
} as unknown as typeof ResizeObserver
|
||||
|
||||
render(<Answer {...defaultProps} />)
|
||||
|
||||
// Trigger the callback to cover getContentWidth and getHumanInputFormContainerWidth
|
||||
act(() => {
|
||||
triggerResize()
|
||||
})
|
||||
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
// Verify component still renders correctly after resize callback
|
||||
expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all component blocks within human inputs layout to cover missing branches', () => {
|
||||
const { container } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
humanInputFilledFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>],
|
||||
humanInputFormDataList: [], // hits length > 0 false branch
|
||||
agent_thoughts: [{ id: 'thought1', thought: 'thinking' }],
|
||||
allFiles: [{ _id: 'file1', name: 'file1.txt', type: 'document' } as unknown as Record<string, unknown>],
|
||||
message_files: [{ id: 'file2', url: 'http://test.com', type: 'image/png' } as unknown as Record<string, unknown>],
|
||||
annotation: { id: 'anno1', authorName: 'Author' } as unknown as Record<string, unknown>,
|
||||
citation: [{ item: { title: 'cite 1' } }] as unknown as Record<string, unknown>[],
|
||||
siblingCount: 2,
|
||||
siblingIndex: 1,
|
||||
prevSibling: 'msg-0',
|
||||
nextSibling: 'msg-2',
|
||||
more: { messages: [{ text: 'more content' }] },
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle hideProcessDetail with NO appData', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
hideProcessDetail={true}
|
||||
appData={undefined}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
workflowProcess: { status: 'running', tracing: [], steps: [] },
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle hideProcessDetail branches in human inputs layout', () => {
|
||||
// Branch: hideProcessDetail=true, appData=undefined
|
||||
const { container: c1 } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
hideProcessDetail={true}
|
||||
appData={undefined}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
workflowProcess: { status: 'running', tracing: [], steps: [] },
|
||||
humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Branch: hideProcessDetail=true, appData provided
|
||||
const { container: c2 } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
hideProcessDetail={true}
|
||||
appData={{ site: { show_workflow_steps: false } } as unknown as AppData}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
workflowProcess: { status: 'running', tracing: [], steps: [] },
|
||||
humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Branch: hideProcessDetail=false
|
||||
const { container: c3 } = render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
hideProcessDetail={false}
|
||||
appData={{ site: { show_workflow_steps: true } } as unknown as AppData}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
workflowProcess: { status: 'running', tracing: [], steps: [] },
|
||||
humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>],
|
||||
} as unknown as ChatItem}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(c1).toBeInTheDocument()
|
||||
expect(c2).toBeInTheDocument()
|
||||
expect(c3).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -3,8 +3,6 @@ import type { ChatContextValue } from '../../context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Operation from '../operation'
|
||||
@ -98,12 +96,8 @@ vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/annot
|
||||
return (
|
||||
<div data-testid="annotation-ctrl">
|
||||
{cached
|
||||
? (
|
||||
<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>
|
||||
)
|
||||
: (
|
||||
<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>
|
||||
)}
|
||||
? (<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>)
|
||||
: (<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -440,6 +434,17 @@ describe('Operation', () => {
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
|
||||
})
|
||||
|
||||
it('should test feedback modal translation fallbacks', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockT.mockImplementation((_key: string): string => '')
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
// Check if modal title/labels fallback works
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
mockT.mockImplementation(key => key)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin feedback (with annotation support)', () => {
|
||||
@ -538,6 +543,19 @@ describe('Operation', () => {
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
|
||||
})
|
||||
|
||||
it('should render action buttons with Default state when feedback rating is undefined', () => {
|
||||
// Setting a malformed feedback object with no rating but triggers the wrapper to see undefined fallbacks
|
||||
const item = {
|
||||
...baseItem,
|
||||
feedback: {} as unknown as Record<string, unknown>,
|
||||
adminFeedback: {} as unknown as Record<string, unknown>,
|
||||
} as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
// Since it renders the 'else' block for hasAdminFeedback (which is false due to !)
|
||||
// the like/dislike regular ActionButtons should hit the Default state
|
||||
expect(screen.getByTestId('operation-bar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Positioning and layout', () => {
|
||||
@ -595,6 +613,60 @@ describe('Operation', () => {
|
||||
// Reset to default behavior
|
||||
mockT.mockImplementation(key => key)
|
||||
})
|
||||
|
||||
it('should handle buildFeedbackTooltip with empty translation fallbacks', () => {
|
||||
// Mock t to return empty string for 'like' and 'dislike' to hit fallback branches:
|
||||
mockT.mockImplementation((key: string): string => {
|
||||
if (key.includes('operation.like'))
|
||||
return ''
|
||||
if (key.includes('operation.dislike'))
|
||||
return ''
|
||||
return key
|
||||
})
|
||||
const itemLike = { ...baseItem, feedback: { rating: 'like' as const, content: 'test content' } }
|
||||
const { rerender } = renderOperation({ ...baseProps, item: itemLike })
|
||||
expect(screen.getByTestId('operation-bar')).toBeInTheDocument()
|
||||
|
||||
const itemDislike = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'test content' } }
|
||||
rerender(
|
||||
<div className="group">
|
||||
<Operation {...baseProps} item={itemDislike} />
|
||||
</div>,
|
||||
)
|
||||
expect(screen.getByTestId('operation-bar')).toBeInTheDocument()
|
||||
|
||||
mockT.mockImplementation(key => key)
|
||||
})
|
||||
|
||||
it('should handle buildFeedbackTooltip without rating', () => {
|
||||
// Mock tooltip display without rating to hit: 'if (!feedbackData?.rating) return label'
|
||||
const item = { ...baseItem, feedback: { rating: null } as unknown as Record<string, unknown> } as unknown as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing onFeedback gracefully in handleFeedback', async () => {
|
||||
const user = userEvent.setup()
|
||||
// First, render with feedback enabled to get the DOM node
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
mockContextValue.onFeedback = vi.fn()
|
||||
const { rerender } = renderOperation()
|
||||
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
|
||||
// Then, disable the context callback to hit the `if (!onFeedback) return` early exit internally upon rerender/click
|
||||
mockContextValue.onFeedback = undefined
|
||||
// Rerender to ensure the component closure gets the updated undefined value from the mock context
|
||||
rerender(
|
||||
<div className="group">
|
||||
<Operation {...baseProps} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Annotation integration', () => {
|
||||
@ -722,5 +794,53 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello world')
|
||||
})
|
||||
|
||||
it('should handle editing annotation missing onAnnotationEdited gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
appId: 'test-app',
|
||||
})
|
||||
mockContextValue.onAnnotationEdited = undefined
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } as unknown as Record<string, unknown> } as unknown as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-edit'))
|
||||
expect(mockContextValue.onAnnotationEdited).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle adding annotation missing onAnnotationAdded gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
appId: 'test-app',
|
||||
})
|
||||
mockContextValue.onAnnotationAdded = undefined
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } as unknown as Record<string, unknown> } as unknown as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-add'))
|
||||
expect(mockContextValue.onAnnotationAdded).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle removing annotation missing onAnnotationRemoved gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
appId: 'test-app',
|
||||
})
|
||||
mockContextValue.onAnnotationRemoved = undefined
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } as unknown as Record<string, unknown> } as unknown as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-remove'))
|
||||
expect(mockContextValue.onAnnotationRemoved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -152,10 +152,10 @@ const Answer: FC<AnswerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef} data-testid="chat-answer-container">
|
||||
{/* Block 1: Workflow Process + Human Input Forms */}
|
||||
{hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)} data-testid="chat-answer-container-humaninput">
|
||||
<div
|
||||
ref={humanInputFormContainerRef}
|
||||
className={cn('relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary body-lg-regular')}
|
||||
@ -319,7 +319,7 @@ const Answer: FC<AnswerProps> = ({
|
||||
|
||||
{/* Original single block layout (when no human inputs) */}
|
||||
{!hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)} data-testid="chat-answer-container-inner">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary body-lg-regular', workflowProcess && 'w-full')}
|
||||
|
||||
@ -192,4 +192,226 @@ describe('checkbox list component', () => {
|
||||
await userEvent.click(screen.getByText('common.operation.resetKeywords'))
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('does not toggle disabled option when clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
const disabledOptions = [
|
||||
{ label: 'Enabled', value: 'enabled' },
|
||||
{ label: 'Disabled', value: 'disabled', disabled: true },
|
||||
]
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={disabledOptions}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const disabledCheckbox = screen.getByTestId('checkbox-disabled')
|
||||
await userEvent.click(disabledCheckbox)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not toggle option when component is disabled and option is clicked via div', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find option and click the div container
|
||||
const optionLabels = screen.getAllByText('Option 1')
|
||||
const optionDiv = optionLabels[0].closest('[data-testid="option-item"]')
|
||||
expect(optionDiv).toBeInTheDocument()
|
||||
await userEvent.click(optionDiv as HTMLElement)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders with label prop', () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
label="Test Label"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without showSelectAll, showCount, showSearch', () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
showSelectAll={false}
|
||||
showCount={false}
|
||||
showSearch={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
|
||||
options.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with custom containerClassName', () => {
|
||||
const { container } = render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
containerClassName="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies maxHeight style to options container', () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
maxHeight="200px"
|
||||
/>,
|
||||
)
|
||||
const optionsContainer = screen.getByTestId('options-container')
|
||||
expect(optionsContainer).toHaveStyle({ maxHeight: '200px', overflowY: 'auto' })
|
||||
})
|
||||
|
||||
it('shows indeterminate state when some options are selected', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={['option1', 'option2']}
|
||||
onChange={onChange}
|
||||
showSelectAll
|
||||
/>,
|
||||
)
|
||||
// When some but not all options are selected, clicking select-all should select all remaining options
|
||||
const selectAll = screen.getByTestId('checkbox-selectAll')
|
||||
expect(selectAll).toBeInTheDocument()
|
||||
expect(selectAll).toHaveAttribute('aria-checked', 'mixed')
|
||||
|
||||
await userEvent.click(selectAll)
|
||||
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
|
||||
})
|
||||
|
||||
it('filters options correctly when searching', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'option')
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Apple')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no data message when no options match search', async () => {
|
||||
render(<CheckboxList options={options} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'xyz')
|
||||
|
||||
expect(screen.getByText(/common.operation.noSearchResults/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles option by clicking option row', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
showSelectAll={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const optionLabel = screen.getByText('Option 1')
|
||||
const optionRow = optionLabel.closest('div[data-testid="option-item"]')
|
||||
expect(optionRow).toBeInTheDocument()
|
||||
await userEvent.click(optionRow as HTMLElement)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['option1'])
|
||||
})
|
||||
|
||||
it('does not toggle when clicking disabled option row', async () => {
|
||||
const onChange = vi.fn()
|
||||
const disabledOptions = [
|
||||
{ label: 'Option 1', value: 'option1', disabled: true },
|
||||
]
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={disabledOptions}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const optionRow = screen.getByText('Option 1').closest('div[data-testid="option-item"]')
|
||||
expect(optionRow).toBeInTheDocument()
|
||||
await userEvent.click(optionRow as HTMLElement)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders without title and description', () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
title=""
|
||||
description=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText(/Test Title/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Test Description/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct filtered count message when searching', async () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
options={options}
|
||||
title="Items"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'opt')
|
||||
|
||||
expect(screen.getByText(/operation.searchCount/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no data message when no options are provided', () => {
|
||||
render(
|
||||
<CheckboxList
|
||||
options={[]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('common.noData')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not toggle option when component is disabled even with enabled option', async () => {
|
||||
const onChange = vi.fn()
|
||||
const disabledOptions = [
|
||||
{ label: 'Option', value: 'option' },
|
||||
]
|
||||
|
||||
render(
|
||||
<CheckboxList
|
||||
options={disabledOptions}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-option')
|
||||
await userEvent.click(checkbox)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -161,6 +161,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
<div
|
||||
className="p-1"
|
||||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
|
||||
data-testid="options-container"
|
||||
>
|
||||
{!filteredOptions.length
|
||||
? (
|
||||
@ -183,6 +184,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
data-testid="option-item"
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
|
||||
option.disabled && 'cursor-not-allowed opacity-50',
|
||||
|
||||
@ -64,4 +64,47 @@ describe('Checkbox Component', () => {
|
||||
expect(checkbox).toHaveClass('bg-components-checkbox-bg-disabled')
|
||||
expect(checkbox).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('handles keyboard events (Space and Enter) when not disabled', () => {
|
||||
const onCheck = vi.fn()
|
||||
render(<Checkbox {...mockProps} onCheck={onCheck} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
|
||||
fireEvent.keyDown(checkbox, { key: ' ' })
|
||||
expect(onCheck).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(checkbox, { key: 'Enter' })
|
||||
expect(onCheck).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not handle keyboard events when disabled', () => {
|
||||
const onCheck = vi.fn()
|
||||
render(<Checkbox {...mockProps} disabled onCheck={onCheck} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test')
|
||||
|
||||
fireEvent.keyDown(checkbox, { key: ' ' })
|
||||
expect(onCheck).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(checkbox, { key: 'Enter' })
|
||||
expect(onCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes aria-disabled attribute', () => {
|
||||
const { rerender } = render(<Checkbox {...mockProps} />)
|
||||
expect(screen.getByTestId('checkbox-test')).toHaveAttribute('aria-disabled', 'false')
|
||||
|
||||
rerender(<Checkbox {...mockProps} disabled />)
|
||||
expect(screen.getByTestId('checkbox-test')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('normalizes aria-checked attribute', () => {
|
||||
const { rerender } = render(<Checkbox {...mockProps} />)
|
||||
expect(screen.getByTestId('checkbox-test')).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
rerender(<Checkbox {...mockProps} checked />)
|
||||
expect(screen.getByTestId('checkbox-test')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
rerender(<Checkbox {...mockProps} indeterminate />)
|
||||
expect(screen.getByTestId('checkbox-test')).toHaveAttribute('aria-checked', 'mixed')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import IndeterminateIcon from './assets/indeterminate-icon'
|
||||
|
||||
type CheckboxProps = {
|
||||
id?: string
|
||||
checked?: boolean
|
||||
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onCheck?: (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
indeterminate?: boolean
|
||||
@ -40,10 +39,23 @@ const Checkbox = ({
|
||||
return
|
||||
onCheck?.(event)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
if (event.key === ' ')
|
||||
event.preventDefault()
|
||||
onCheck?.(event)
|
||||
}
|
||||
}}
|
||||
data-testid={`checkbox-${id}`}
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? 'mixed' : !!checked}
|
||||
aria-disabled={!!disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{!checked && indeterminate && <IndeterminateIcon />}
|
||||
{checked && <RiCheckLine className="h-3 w-3" data-testid={`check-icon-${id}`} />}
|
||||
{checked && <div className="i-ri-check-line h-3 w-3" data-testid={`check-icon-${id}`} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -61,6 +61,11 @@ describe('CopyFeedbackNew', () => {
|
||||
expect(container.querySelector('.cursor-pointer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom className', () => {
|
||||
const { container } = render(<CopyFeedbackNew content="test content" className="test-class" />)
|
||||
expect(container.querySelector('.test-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies copied CSS class when copied is true', () => {
|
||||
mockCopied = true
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
|
||||
@ -21,17 +21,19 @@ const CopyFeedback = ({ content }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { copied, copy, reset } = useClipboard()
|
||||
|
||||
const tooltipText = copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })
|
||||
/* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */
|
||||
const safeText = tooltipText || ''
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(content)
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
popupContent={safeText}
|
||||
>
|
||||
<ActionButton>
|
||||
<div
|
||||
@ -52,27 +54,27 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className'
|
||||
const { t } = useTranslation()
|
||||
const { copied, copy, reset } = useClipboard()
|
||||
|
||||
const tooltipText = copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })
|
||||
/* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */
|
||||
const safeText = tooltipText || ''
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(content)
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
popupContent={safeText}
|
||||
>
|
||||
<div
|
||||
className={`h-8 w-8 cursor-pointer rounded-lg hover:bg-components-button-ghost-bg-hover ${className ?? ''
|
||||
}`}
|
||||
className={`h-8 w-8 cursor-pointer rounded-lg hover:bg-components-button-ghost-bg-hover ${className ?? ''}`}
|
||||
>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
onMouseLeave={reset}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''
|
||||
}`}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CopyIcon from '..'
|
||||
|
||||
const copy = vi.fn()
|
||||
@ -20,33 +20,28 @@ describe('copy icon component', () => {
|
||||
})
|
||||
|
||||
it('renders normally', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows copy icon initially', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="Copy"]')
|
||||
render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = screen.getByTestId('copy-icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows copy check icon when copied', () => {
|
||||
copied = true
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="CopyCheck"]')
|
||||
render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = screen.getByTestId('copied-icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles copy when clicked', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="Copy"]')
|
||||
render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = screen.getByTestId('copy-icon')
|
||||
fireEvent.click(icon as Element)
|
||||
expect(copy).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets on mouse leave', () => {
|
||||
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = container.querySelector('[data-icon="Copy"]')
|
||||
render(<CopyIcon content="this is some test content for the copy icon component" />)
|
||||
const icon = screen.getByTestId('copy-icon')
|
||||
const div = icon?.parentElement as HTMLElement
|
||||
fireEvent.mouseLeave(div)
|
||||
expect(reset).toBeCalledTimes(1)
|
||||
|
||||
@ -2,10 +2,6 @@
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
} from '@/app/components/base/icons/src/vender/line/files'
|
||||
import Tooltip from '../tooltip'
|
||||
|
||||
type Props = {
|
||||
@ -22,22 +18,20 @@ const CopyIcon = ({ content }: Props) => {
|
||||
copy(content)
|
||||
}, [copy, content])
|
||||
|
||||
const tooltipText = copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })
|
||||
/* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */
|
||||
const safeTooltipText = tooltipText || ''
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
popupContent={safeTooltipText}
|
||||
>
|
||||
<div onMouseLeave={reset}>
|
||||
{!copied
|
||||
? (
|
||||
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={handleCopy} />
|
||||
)
|
||||
: (
|
||||
<CopyCheck className="mx-1 h-3.5 w-3.5 text-text-tertiary" />
|
||||
)}
|
||||
? (<span className="i-custom-vender-line-files-copy mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={handleCopy} data-testid="copy-icon" />)
|
||||
: (<span className="i-custom-vender-line-files-copy-check mx-1 h-3.5 w-3.5 text-text-tertiary" data-testid="copied-icon" />)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@ -11,10 +11,10 @@ export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Elem
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center space-x-1">
|
||||
<div className="system-sm-semibold py-1 text-text-secondary">{title}</div>
|
||||
<div className="py-1 text-text-secondary system-sm-semibold">{title}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="system-sm-regular max-w-[200px] text-text-secondary">{tooltip}</div>
|
||||
<div className="max-w-[200px] text-text-secondary system-sm-regular">{tooltip}</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -92,20 +92,20 @@ const AnnotationReply = ({
|
||||
>
|
||||
<>
|
||||
{!annotationReply?.enabled && (
|
||||
<div className="system-xs-regular line-clamp-2 min-h-8 text-text-tertiary">{t('feature.annotation.description', { ns: 'appDebug' })}</div>
|
||||
<div className="line-clamp-2 min-h-8 text-text-tertiary system-xs-regular">{t('feature.annotation.description', { ns: 'appDebug' })}</div>
|
||||
)}
|
||||
{!!annotationReply?.enabled && (
|
||||
<>
|
||||
{!isHovering && (
|
||||
<div className="flex items-center gap-4 pt-0.5">
|
||||
<div className="">
|
||||
<div className="system-2xs-medium-uppercase mb-0.5 text-text-tertiary">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold || '-'}</div>
|
||||
<div className="mb-0.5 text-text-tertiary system-2xs-medium-uppercase">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
|
||||
<div className="text-text-secondary system-xs-regular">{annotationReply.score_threshold || '-'}</div>
|
||||
</div>
|
||||
<div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div>
|
||||
<div className="">
|
||||
<div className="system-2xs-medium-uppercase mb-0.5 text-text-tertiary">{t('modelProvider.embeddingModel.key', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.embedding_model?.embedding_model_name}</div>
|
||||
<div className="mb-0.5 text-text-tertiary system-2xs-medium-uppercase">{t('modelProvider.embeddingModel.key', { ns: 'common' })}</div>
|
||||
<div className="text-text-secondary system-xs-regular">{annotationReply.embedding_model?.embedding_model_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -26,7 +26,7 @@ export const FileList = ({
|
||||
canPreview = true,
|
||||
}: FileListProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
<div className={cn('flex flex-wrap gap-2', className)} data-testid="file-list">
|
||||
{
|
||||
files.map((file) => {
|
||||
if (file.supportFileType === SupportUploadFileTypes.image) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import BaseField from '../base-field'
|
||||
|
||||
@ -35,7 +35,7 @@ const renderBaseField = ({
|
||||
const TestComponent = () => {
|
||||
const form = useForm({
|
||||
defaultValues: defaultValues ?? { [formSchema.name]: '' },
|
||||
onSubmit: async () => {},
|
||||
onSubmit: async () => { },
|
||||
})
|
||||
|
||||
return (
|
||||
@ -72,7 +72,7 @@ describe('BaseField', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should render text input and propagate changes', () => {
|
||||
it('should render text input and propagate changes', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
@ -88,13 +88,15 @@ describe('BaseField', () => {
|
||||
const input = screen.getByDisplayValue('Hello')
|
||||
expect(input).toHaveValue('Hello')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Updated' } })
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'Updated' } })
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith('title', 'Updated')
|
||||
expect(screen.getByText('Title')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('*')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render only options that satisfy show_on conditions', () => {
|
||||
it('should render only options that satisfy show_on conditions', async () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.select,
|
||||
@ -109,7 +111,9 @@ describe('BaseField', () => {
|
||||
defaultValues: { mode: 'alpha', enabled: 'no' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Alpha'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Alpha'))
|
||||
})
|
||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -133,7 +137,7 @@ describe('BaseField', () => {
|
||||
expect(screen.getByText('common.dynamicSelect.loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update value when users click a radio option', () => {
|
||||
it('should update value when users click a radio option', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
@ -150,7 +154,9 @@ describe('BaseField', () => {
|
||||
onChange,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Private'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Private'))
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith('visibility', 'private')
|
||||
})
|
||||
|
||||
@ -231,7 +237,7 @@ describe('BaseField', () => {
|
||||
expect(screen.getByText('Localized title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dynamic options and allow selecting one', () => {
|
||||
it('should render dynamic options and allow selecting one', async () => {
|
||||
mockDynamicOptions.mockReturnValue({
|
||||
data: {
|
||||
options: [
|
||||
@ -252,12 +258,16 @@ describe('BaseField', () => {
|
||||
defaultValues: { plugin_option: '' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.placeholder.input'))
|
||||
fireEvent.click(screen.getByText('Option A'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('common.placeholder.input'))
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Option A'))
|
||||
})
|
||||
expect(screen.getByText('Option A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update boolean field when users choose false', () => {
|
||||
it('should update boolean field when users choose false', async () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.boolean,
|
||||
@ -270,7 +280,9 @@ describe('BaseField', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('field-value')).toHaveTextContent('true')
|
||||
fireEvent.click(screen.getByText('False'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('False'))
|
||||
})
|
||||
expect(screen.getByTestId('field-value')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
@ -290,4 +302,144 @@ describe('BaseField', () => {
|
||||
|
||||
expect(screen.getByText('This is a warning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip when provided', async () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'info',
|
||||
label: 'Info',
|
||||
required: false,
|
||||
tooltip: 'Extra info',
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Info')).toBeInTheDocument()
|
||||
|
||||
const tooltipTrigger = screen.getByTestId('base-field-tooltip-trigger')
|
||||
fireEvent.mouseEnter(tooltipTrigger)
|
||||
|
||||
expect(screen.getByText('Extra info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render checkbox list and handle changes', async () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.checkbox,
|
||||
name: 'features',
|
||||
label: 'Features',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'Feature A', value: 'a' },
|
||||
{ label: 'Feature B', value: 'b' },
|
||||
],
|
||||
},
|
||||
defaultValues: { features: ['a'] },
|
||||
})
|
||||
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Feature B'))
|
||||
})
|
||||
|
||||
const checkboxB = screen.getByTestId('checkbox-b')
|
||||
expect(checkboxB).toBeChecked()
|
||||
})
|
||||
|
||||
it('should handle dynamic select error state', () => {
|
||||
mockDynamicOptions.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed'),
|
||||
})
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.dynamicSelect,
|
||||
name: 'ds_error',
|
||||
label: 'DS Error',
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('common.placeholder.input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle dynamic select no data state', () => {
|
||||
mockDynamicOptions.mockReturnValue({
|
||||
data: { options: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.dynamicSelect,
|
||||
name: 'ds_empty',
|
||||
label: 'DS Empty',
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('common.placeholder.input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render radio buttons in vertical layout when length >= 3', () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.radio,
|
||||
name: 'vertical_radio',
|
||||
label: 'Vertical',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'O1', value: '1' },
|
||||
{ label: 'O2', value: '2' },
|
||||
{ label: 'O3', value: '3' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('O1')).toBeInTheDocument()
|
||||
expect(screen.getByText('O2')).toBeInTheDocument()
|
||||
expect(screen.getByText('O3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render radio UI when showRadioUI is true', () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.radio,
|
||||
name: 'ui_radio',
|
||||
label: 'UI Radio',
|
||||
required: false,
|
||||
showRadioUI: true,
|
||||
options: [{ label: 'Option 1', value: '1' }],
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('radio-group')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styles', () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.radio,
|
||||
name: 'disabled_radio',
|
||||
label: 'Disabled',
|
||||
required: false,
|
||||
options: [{ label: 'Option 1', value: '1' }],
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
// In radio, the option itself has the disabled class
|
||||
expect(screen.getByText('Option 1')).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should return empty string for null content in getTranslatedContent', () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'null_label',
|
||||
label: null as unknown as string,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
// Expecting translatedLabel to be '' so title block only renders required * if applicable
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,30 @@
|
||||
import type { AnyFieldApi, AnyFormApi } from '@tanstack/react-form'
|
||||
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import BaseForm from '../base-form'
|
||||
|
||||
vi.mock('@tanstack/react-form', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-form')>()
|
||||
return {
|
||||
...actual,
|
||||
useStore: vi.fn((store, selector) => {
|
||||
// If a selector is provided, apply it to a mocked state or the store directly
|
||||
if (selector) {
|
||||
// If the store is a mock with state, use it; otherwise provide a default
|
||||
try {
|
||||
return selector(store?.state || { values: {} })
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return store?.state?.values || {}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({
|
||||
data: undefined,
|
||||
@ -54,7 +76,7 @@ describe('BaseForm', () => {
|
||||
expect(screen.queryByDisplayValue('Hidden title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prevent default submit behavior when preventDefaultSubmit is true', () => {
|
||||
it('should prevent default submit behavior when preventDefaultSubmit is true', async () => {
|
||||
const onSubmit = vi.fn((event: React.FormEvent<HTMLFormElement>) => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
@ -66,11 +88,15 @@ describe('BaseForm', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
||||
await act(async () => {
|
||||
fireEvent.submit(container.querySelector('form') as HTMLFormElement, {
|
||||
defaultPrevented: true,
|
||||
})
|
||||
})
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose ref API for updating values and field states', () => {
|
||||
it('should expose ref API for updating values and field states', async () => {
|
||||
const formRef = { current: null } as { current: FormRefObject | null }
|
||||
render(
|
||||
<BaseForm
|
||||
@ -81,7 +107,7 @@ describe('BaseForm', () => {
|
||||
|
||||
expect(formRef.current).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
formRef.current?.setFields([
|
||||
{
|
||||
name: 'title',
|
||||
@ -97,7 +123,7 @@ describe('BaseForm', () => {
|
||||
expect(formRef.current?.getFormValues({})).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should derive warning status when setFields receives warnings only', () => {
|
||||
it('should derive warning status when setFields receives warnings only', async () => {
|
||||
const formRef = { current: null } as { current: FormRefObject | null }
|
||||
render(
|
||||
<BaseForm
|
||||
@ -106,7 +132,7 @@ describe('BaseForm', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
formRef.current?.setFields([
|
||||
{
|
||||
name: 'title',
|
||||
@ -117,4 +143,179 @@ describe('BaseForm', () => {
|
||||
|
||||
expect(screen.getByText('Title warning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use formFromProps if provided', () => {
|
||||
const mockState = { values: { kind: 'show' } }
|
||||
const mockStore = {
|
||||
state: mockState,
|
||||
}
|
||||
vi.mocked(useStore).mockReturnValueOnce(mockState.values)
|
||||
const mockForm = {
|
||||
store: mockStore,
|
||||
Field: ({ children, name }: { children: (field: AnyFieldApi) => React.ReactNode, name: string }) => children({
|
||||
name,
|
||||
state: { value: mockState.values[name as keyof typeof mockState.values], meta: { isTouched: false, errorMap: {} } },
|
||||
form: { store: mockStore },
|
||||
} as unknown as AnyFieldApi),
|
||||
setFieldValue: vi.fn(),
|
||||
}
|
||||
render(<BaseForm formSchemas={baseSchemas} formFromProps={mockForm as unknown as AnyFormApi} />)
|
||||
expect(screen.getByText('Kind')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle setFields with explicit validateStatus', async () => {
|
||||
const formRef = { current: null } as { current: FormRefObject | null }
|
||||
render(<BaseForm formSchemas={baseSchemas} ref={formRef} />)
|
||||
|
||||
await act(async () => {
|
||||
formRef.current?.setFields([{
|
||||
name: 'kind',
|
||||
validateStatus: FormItemValidateStatusEnum.Error,
|
||||
errors: ['Explicit error'],
|
||||
}])
|
||||
})
|
||||
expect(screen.getByText('Explicit error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle setFields with no value change', async () => {
|
||||
const formRef = { current: null } as { current: FormRefObject | null }
|
||||
render(<BaseForm formSchemas={baseSchemas} ref={formRef} />)
|
||||
|
||||
await act(async () => {
|
||||
formRef.current?.setFields([{
|
||||
name: 'kind',
|
||||
errors: ['Error only'],
|
||||
}])
|
||||
})
|
||||
expect(screen.getByText('Error only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default values from schema when defaultValues prop is missing', () => {
|
||||
render(<BaseForm formSchemas={baseSchemas} />)
|
||||
expect(screen.getByDisplayValue('show')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle submit without preventDefaultSubmit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { container } = render(<BaseForm formSchemas={baseSchemas} onSubmit={onSubmit} />)
|
||||
await act(async () => {
|
||||
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
||||
})
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render nothing if field name does not match schema in renderField', () => {
|
||||
const mockState = { values: { unknown: 'value' } }
|
||||
const mockStore = {
|
||||
state: mockState,
|
||||
}
|
||||
vi.mocked(useStore).mockReturnValueOnce(mockState.values)
|
||||
const mockForm = {
|
||||
store: mockStore,
|
||||
Field: ({ children }: { children: (field: AnyFieldApi) => React.ReactNode }) => children({
|
||||
name: 'unknown', // field name not in baseSchemas
|
||||
state: { value: 'value', meta: { isTouched: false, errorMap: {} } },
|
||||
form: { store: mockStore },
|
||||
} as unknown as AnyFieldApi),
|
||||
setFieldValue: vi.fn(),
|
||||
}
|
||||
render(<BaseForm formSchemas={baseSchemas} formFromProps={mockForm as unknown as AnyFormApi} />)
|
||||
expect(screen.queryByText('Kind')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined formSchemas', () => {
|
||||
const { container } = render(<BaseForm formSchemas={undefined as unknown as FormSchema[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle empty array formSchemas', () => {
|
||||
const { container } = render(<BaseForm formSchemas={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should fallback to schema class names if props are missing', () => {
|
||||
const schemaWithClasses: FormSchema[] = [{
|
||||
...baseSchemas[0],
|
||||
fieldClassName: 'schema-field',
|
||||
labelClassName: 'schema-label',
|
||||
}]
|
||||
render(<BaseForm formSchemas={schemaWithClasses} />)
|
||||
expect(screen.getByText('Kind')).toHaveClass('schema-label')
|
||||
expect(screen.getByText('Kind').parentElement).toHaveClass('schema-field')
|
||||
})
|
||||
|
||||
it('should handle preventDefaultSubmit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { container } = render(
|
||||
<BaseForm
|
||||
formSchemas={baseSchemas}
|
||||
onSubmit={onSubmit}
|
||||
preventDefaultSubmit={true}
|
||||
/>,
|
||||
)
|
||||
const event = new Event('submit', { cancelable: true, bubbles: true })
|
||||
const spy = vi.spyOn(event, 'preventDefault')
|
||||
const form = container.querySelector('form') as HTMLFormElement
|
||||
await act(async () => {
|
||||
fireEvent(form, event)
|
||||
})
|
||||
expect(spy).toHaveBeenCalled()
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing onSubmit prop', async () => {
|
||||
const { container } = render(<BaseForm formSchemas={baseSchemas} />)
|
||||
await act(async () => {
|
||||
expect(() => {
|
||||
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange when field value changes', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<BaseForm formSchemas={baseSchemas} onChange={onChange} />)
|
||||
const input = screen.getByDisplayValue('show')
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'new-value' } })
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith('kind', 'new-value')
|
||||
})
|
||||
|
||||
it('should handle setFields with no status, errors, or warnings', async () => {
|
||||
const formRef = { current: null } as { current: FormRefObject | null }
|
||||
render(<BaseForm formSchemas={baseSchemas} ref={formRef} />)
|
||||
|
||||
await act(async () => {
|
||||
formRef.current?.setFields([{
|
||||
name: 'kind',
|
||||
value: 'new-show',
|
||||
}])
|
||||
})
|
||||
expect(screen.getByDisplayValue('new-show')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle schema without show_on in showOnValues', () => {
|
||||
const schemaNoShowOn: FormSchema[] = [{
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'test',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
}]
|
||||
// Simply rendering should trigger showOnValues selector
|
||||
render(<BaseForm formSchemas={schemaNoShowOn} />)
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply prop-based class names', () => {
|
||||
render(
|
||||
<BaseForm
|
||||
formSchemas={baseSchemas}
|
||||
fieldClassName="custom-field"
|
||||
labelClassName="custom-label"
|
||||
/>,
|
||||
)
|
||||
const label = screen.getByText('Kind')
|
||||
expect(label).toHaveClass('custom-label')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import {
|
||||
isValidElement,
|
||||
@ -198,6 +197,7 @@ const BaseField = ({
|
||||
}
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
triggerTestId="base-field-tooltip-trigger"
|
||||
popupContent={<div className="w-[200px]">{translatedTooltip}</div>}
|
||||
triggerClassName="ml-0.5 w-4 h-4"
|
||||
/>
|
||||
@ -270,16 +270,18 @@ const BaseField = ({
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.radio && (
|
||||
<div className={cn(
|
||||
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
|
||||
)}
|
||||
data-testid="radio-group"
|
||||
>
|
||||
{
|
||||
memorizedOptions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
|
||||
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
@ -315,7 +317,7 @@ const BaseField = ({
|
||||
}
|
||||
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
|
||||
<div className={cn(
|
||||
'system-xs-regular mt-1 px-0 py-[2px]',
|
||||
'mt-1 px-0 py-[2px] system-xs-regular',
|
||||
VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].textClassName,
|
||||
)}
|
||||
>
|
||||
@ -325,21 +327,21 @@ const BaseField = ({
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="system-xs-regular mt-4 text-text-tertiary">
|
||||
<div className="mt-4 text-text-tertiary system-xs-regular">
|
||||
{translatedDescription}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
url && (
|
||||
<a
|
||||
className="system-xs-regular mt-4 flex items-center text-text-accent"
|
||||
className="mt-4 flex items-center text-text-accent system-xs-regular"
|
||||
href={url}
|
||||
target="_blank"
|
||||
>
|
||||
<span className="break-all">
|
||||
{translatedHelp}
|
||||
</span>
|
||||
<RiExternalLinkLine className="ml-1 h-3 w-3 shrink-0" />
|
||||
<div className="i-ri-external-link-line ml-1 h-3 w-3 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@ -147,4 +147,32 @@ describe('input-field scenario schema generator', () => {
|
||||
other: { key: 'value' },
|
||||
}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('should ignore constraints for irrelevant field types', () => {
|
||||
const schema = generateZodSchema([
|
||||
{
|
||||
type: InputFieldType.numberInput,
|
||||
variable: 'num',
|
||||
label: 'Num',
|
||||
required: true,
|
||||
maxLength: 10, // maxLength is for textInput, should be ignored
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.textInput,
|
||||
variable: 'text',
|
||||
label: 'Text',
|
||||
required: true,
|
||||
min: 1, // min is for numberInput, should be ignored
|
||||
max: 5, // max is for numberInput, should be ignored
|
||||
showConditions: [],
|
||||
},
|
||||
])
|
||||
|
||||
// Should still work based on their base types
|
||||
// num: 12345678901 (violates maxLength: 10 if it were applied)
|
||||
// text: 'long string here' (violates max: 5 if it were applied)
|
||||
expect(schema.safeParse({ num: 12345678901, text: 'long string here' }).success).toBe(true)
|
||||
expect(schema.safeParse({ num: 'not a number', text: 'hello' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -28,18 +28,21 @@ describe('useCheckValidated', () => {
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should notify and return false when visible field has errors', () => {
|
||||
it.each([
|
||||
{ fieldName: 'name', label: 'Name', message: 'Name is required' },
|
||||
{ fieldName: 'field1', label: 'Field 1', message: 'Field is required' },
|
||||
])('should notify and return false when visible field has errors (show_on: []) for $fieldName', ({ fieldName, label, message }) => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
name: { errors: ['Name is required'] },
|
||||
[fieldName]: { errors: [message] },
|
||||
},
|
||||
}),
|
||||
state: { values: {} },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
name: fieldName,
|
||||
label,
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
@ -50,7 +53,7 @@ describe('useCheckValidated', () => {
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Name is required',
|
||||
message,
|
||||
})
|
||||
})
|
||||
|
||||
@ -102,4 +105,208 @@ describe('useCheckValidated', () => {
|
||||
message: 'Secret is required',
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify with first error when multiple fields have errors', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
name: { errors: ['Name error'] },
|
||||
email: { errors: ['Email error'] },
|
||||
},
|
||||
}),
|
||||
state: { values: {} },
|
||||
}
|
||||
const schemas = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Name error',
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify when multiple conditions all match', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
advancedOption: { errors: ['Advanced is required'] },
|
||||
},
|
||||
}),
|
||||
state: { values: { enabled: 'true', level: 'advanced' } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'advancedOption',
|
||||
label: 'Advanced Option',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [
|
||||
{ variable: 'enabled', value: 'true' },
|
||||
{ variable: 'level', value: 'advanced' },
|
||||
],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Advanced is required',
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore error when one of multiple conditions does not match', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
advancedOption: { errors: ['Advanced is required'] },
|
||||
},
|
||||
}),
|
||||
state: { values: { enabled: 'true', level: 'basic' } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'advancedOption',
|
||||
label: 'Advanced Option',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [
|
||||
{ variable: 'enabled', value: 'true' },
|
||||
{ variable: 'level', value: 'advanced' },
|
||||
],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(true)
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle field with error when schema is not found', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
unknownField: { errors: ['Unknown error'] },
|
||||
},
|
||||
}),
|
||||
state: { values: {} },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'knownField',
|
||||
label: 'Known Field',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Unknown error',
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle field with multiple errors and notify only first one', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
field1: { errors: ['First error', 'Second error'] },
|
||||
},
|
||||
}),
|
||||
state: { values: {} },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'field1',
|
||||
label: 'Field 1',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'First error',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return true when all visible fields have no errors', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
visibleField: { errors: [] },
|
||||
hiddenField: { errors: [] },
|
||||
},
|
||||
}),
|
||||
state: { values: { showHidden: 'false' } },
|
||||
}
|
||||
const schemas = [
|
||||
{
|
||||
name: 'visibleField',
|
||||
label: 'Visible Field',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
},
|
||||
{
|
||||
name: 'hiddenField',
|
||||
label: 'Hidden Field',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [{ variable: 'showHidden', value: 'true' }],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(true)
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should properly evaluate show_on conditions with different values', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
numericField: { errors: ['Numeric error'] },
|
||||
},
|
||||
}),
|
||||
state: { values: { threshold: '100' } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'numericField',
|
||||
label: 'Numeric Field',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [{ variable: 'threshold', value: '100' }],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Numeric error',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -71,4 +71,149 @@ describe('useGetFormValues', () => {
|
||||
isCheckValidated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return raw values when validation passes but no transformation is requested', () => {
|
||||
const form = {
|
||||
store: { state: { values: { email: 'test@example.com' } } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
}]
|
||||
mockCheckValidated.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: false,
|
||||
})).toEqual({
|
||||
values: { email: 'test@example.com' },
|
||||
isCheckValidated: true,
|
||||
})
|
||||
expect(mockTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return raw values when validation passes and transformation is undefined', () => {
|
||||
const form = {
|
||||
store: { state: { values: { username: 'john_doe' } } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
}]
|
||||
mockCheckValidated.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: undefined,
|
||||
})).toEqual({
|
||||
values: { username: 'john_doe' },
|
||||
isCheckValidated: true,
|
||||
})
|
||||
expect(mockTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty form values when validation check is disabled', () => {
|
||||
const form = {
|
||||
store: { state: { values: {} } },
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, []))
|
||||
|
||||
expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({
|
||||
values: {},
|
||||
isCheckValidated: true,
|
||||
})
|
||||
expect(mockCheckValidated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle null form values gracefully', () => {
|
||||
const form = {
|
||||
store: { state: { values: null } },
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, []))
|
||||
|
||||
expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({
|
||||
values: {},
|
||||
isCheckValidated: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call transform with correct arguments when transformation is requested', () => {
|
||||
const form = {
|
||||
store: { state: { values: { password: 'secret' } } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
required: true,
|
||||
type: FormTypeEnum.secretInput,
|
||||
}]
|
||||
mockCheckValidated.mockReturnValue(true)
|
||||
mockTransform.mockReturnValue({ password: 'encrypted' })
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
result.current.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
})
|
||||
|
||||
expect(mockTransform).toHaveBeenCalledWith(schemas, form)
|
||||
})
|
||||
|
||||
it('should return validation failure before attempting transformation', () => {
|
||||
const form = {
|
||||
store: { state: { values: { password: 'secret' } } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
required: true,
|
||||
type: FormTypeEnum.secretInput,
|
||||
}]
|
||||
mockCheckValidated.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
})).toEqual({
|
||||
values: {},
|
||||
isCheckValidated: false,
|
||||
})
|
||||
expect(mockTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle complex nested values with validation check disabled', () => {
|
||||
const form = {
|
||||
store: {
|
||||
state: {
|
||||
values: {
|
||||
user: { name: 'Alice', age: 30 },
|
||||
settings: { theme: 'dark' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, []))
|
||||
|
||||
expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({
|
||||
values: {
|
||||
user: { name: 'Alice', age: 30 },
|
||||
settings: { theme: 'dark' },
|
||||
},
|
||||
isCheckValidated: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -75,4 +75,59 @@ describe('useGetValidators', () => {
|
||||
expect(changeMessage).toContain('"field":"Workspace"')
|
||||
expect(nonRequiredValidators).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when value is truthy (onMount, onChange, onBlur)', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
const validators = result.current.getValidators({
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
expect(validators?.onMount?.({ value: 'some value' })).toBeUndefined()
|
||||
expect(validators?.onChange?.({ value: 'some value' })).toBeUndefined()
|
||||
expect(validators?.onBlur?.({ value: 'some value' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle null/missing labels correctly', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
|
||||
// Explicitly test fallback to name when label is missing
|
||||
const validators = result.current.getValidators({
|
||||
name: 'id_field',
|
||||
label: null as unknown as string,
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
const mountMessage = validators?.onMount?.({ value: '' })
|
||||
expect(mountMessage).toContain('"field":"id_field"')
|
||||
})
|
||||
|
||||
it('should handle onChange message with fallback to name', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
const validators = result.current.getValidators({
|
||||
name: 'desc',
|
||||
label: createElement('span'), // results in '' label
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
const changeMessage = validators?.onChange?.({ value: '' })
|
||||
expect(changeMessage).toContain('"field":"desc"')
|
||||
})
|
||||
|
||||
it('should handle onBlur message specifically', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
const validators = result.current.getValidators({
|
||||
name: 'email',
|
||||
label: 'Email Address',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
const blurMessage = validators?.onBlur?.({ value: '' })
|
||||
expect(blurMessage).toContain('"field":"Email Address"')
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,6 +24,28 @@ describe('zodSubmitValidator', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should only keep the first error when multiple errors occur for the same field', () => {
|
||||
// Both string() empty check and email() validation will fail here conceptually,
|
||||
// but Zod aborts early on type errors sometimes. Let's use custom refinements that both trigger
|
||||
const schema = z.object({
|
||||
email: z.string().superRefine((val, ctx) => {
|
||||
if (!val.includes('@')) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format' })
|
||||
}
|
||||
if (val.length < 10) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email too short' })
|
||||
}
|
||||
}),
|
||||
})
|
||||
const validator = zodSubmitValidator(schema)
|
||||
// "bad" triggers both missing '@' and length < 10
|
||||
expect(validator({ value: { email: 'bad' } })).toEqual({
|
||||
fields: {
|
||||
email: 'Invalid email format',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore root-level issues without a field path', () => {
|
||||
const schema = z.object({ value: z.number() }).superRefine((_value, ctx) => {
|
||||
ctx.addIssue({
|
||||
|
||||
@ -51,4 +51,64 @@ describe('secret input utilities', () => {
|
||||
apiKey: 'secret',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not mask when secret name is not in the values object', () => {
|
||||
expect(transformFormSchemasSecretInput(['missing'], {
|
||||
apiKey: 'secret',
|
||||
})).toEqual({
|
||||
apiKey: 'secret',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not mask falsy values like 0 or null', () => {
|
||||
expect(transformFormSchemasSecretInput(['zeroVal', 'nullVal'], {
|
||||
zeroVal: 0,
|
||||
nullVal: null,
|
||||
})).toEqual({
|
||||
zeroVal: 0,
|
||||
nullVal: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty object when form values are undefined', () => {
|
||||
const formSchemas = [
|
||||
{ name: 'apiKey', type: FormTypeEnum.secretInput, label: 'API Key', required: true },
|
||||
]
|
||||
const form = {
|
||||
store: { state: { values: undefined } },
|
||||
getFieldMeta: () => ({ isPristine: true }),
|
||||
}
|
||||
|
||||
expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle fieldMeta being undefined', () => {
|
||||
const formSchemas = [
|
||||
{ name: 'apiKey', type: FormTypeEnum.secretInput, label: 'API Key', required: true },
|
||||
]
|
||||
const form = {
|
||||
store: { state: { values: { apiKey: 'secret' } } },
|
||||
getFieldMeta: () => undefined,
|
||||
}
|
||||
|
||||
expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({
|
||||
apiKey: 'secret',
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip non-secretInput schema types entirely', () => {
|
||||
const formSchemas = [
|
||||
{ name: 'name', type: FormTypeEnum.textInput, label: 'Name', required: true },
|
||||
{ name: 'desc', type: FormTypeEnum.textInput, label: 'Desc', required: false },
|
||||
]
|
||||
const form = {
|
||||
store: { state: { values: { name: 'Alice', desc: 'Test' } } },
|
||||
getFieldMeta: () => ({ isPristine: true }),
|
||||
}
|
||||
|
||||
expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({
|
||||
name: 'Alice',
|
||||
desc: 'Test',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import InputWithCopy from '../index'
|
||||
|
||||
// Create a controllable mock for useClipboard
|
||||
@ -16,14 +14,6 @@ vi.mock('foxact/use-clipboard', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the i18n hook with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'operation.copy': 'Copy',
|
||||
'operation.copied': 'Copied',
|
||||
'overview.appInfo.embedded.copy': 'Copy',
|
||||
'overview.appInfo.embedded.copied': 'Copied',
|
||||
}))
|
||||
|
||||
describe('InputWithCopy component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -145,4 +135,98 @@ describe('InputWithCopy component', () => {
|
||||
// Input should maintain focus after copy
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
it('converts non-string value to string for copying', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
// number value triggers String(value || '') branch where typeof value !== 'string'
|
||||
render(<InputWithCopy value={12345} onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('12345')
|
||||
})
|
||||
|
||||
it('handles undefined value by converting to empty string', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
// undefined value triggers String(value || '') where value is falsy
|
||||
render(<InputWithCopy value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('shows copied tooltip text when copied state is true', () => {
|
||||
mockCopied = true
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
// The tooltip content should use the 'copied' translation
|
||||
const copyButton = screen.getByRole('button')
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
|
||||
// Verify the filled clipboard icon is rendered (not the line variant)
|
||||
const filledIcon = screen.getByTestId('copied-icon')
|
||||
expect(filledIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows copy tooltip text when copied state is false', () => {
|
||||
mockCopied = false
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
|
||||
const lineIcon = screen.getByTestId('copy-icon')
|
||||
expect(lineIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls reset on mouse leave from copy button wrapper', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const wrapper = screen.getByTestId('copy-button-wrapper')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
|
||||
expect(mockReset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies wrapperClassName to the outer container', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputWithCopy value="test" onChange={mockOnChange} wrapperClassName="my-wrapper" />,
|
||||
)
|
||||
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('my-wrapper')
|
||||
})
|
||||
|
||||
it('copies copyValue over non-string input value when both provided', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(
|
||||
<InputWithCopy value={42} onChange={mockOnChange} copyValue="override-copy" />,
|
||||
)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('override-copy')
|
||||
})
|
||||
|
||||
it('invokes onCopy with copyValue when copyValue is provided', () => {
|
||||
const onCopyMock = vi.fn()
|
||||
const mockOnChange = vi.fn()
|
||||
render(
|
||||
<InputWithCopy value="display" onChange={mockOnChange} copyValue="custom" onCopy={onCopyMock} />,
|
||||
)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(onCopyMock).toHaveBeenCalledWith('custom')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { InputProps } from '../input'
|
||||
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -39,13 +38,19 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
|
||||
onCopy?.(finalCopyValue)
|
||||
}
|
||||
|
||||
const tooltipText = copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })
|
||||
/* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */
|
||||
const safeTooltipText = tooltipText || ''
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-full', wrapperClassName)}>
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'radius-md system-sm-regular px-3',
|
||||
'px-3 system-sm-regular radius-md',
|
||||
showCopyButton && 'pr-8',
|
||||
inputProps.disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
|
||||
inputProps.className,
|
||||
@ -57,13 +62,10 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
|
||||
<div
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onMouseLeave={reset}
|
||||
data-testid="copy-button-wrapper"
|
||||
>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
popupContent={safeTooltipText}
|
||||
>
|
||||
<ActionButton
|
||||
size="xs"
|
||||
@ -71,12 +73,8 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
|
||||
className="hover:bg-components-button-ghost-bg-hover"
|
||||
>
|
||||
{copied
|
||||
? (
|
||||
<RiClipboardFill className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
)
|
||||
: (
|
||||
<RiClipboardLine className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
)}
|
||||
? (<span className="i-ri-clipboard-fill h-3.5 w-3.5 text-text-tertiary" data-testid="copied-icon" />)
|
||||
: (<span className="i-ri-clipboard-line h-3.5 w-3.5 text-text-tertiary" data-testid="copy-icon" />)}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -115,6 +115,41 @@ describe('Input component', () => {
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Additional Layout Branches', () => {
|
||||
it('applies pl-7 when showLeftIcon and size is large', () => {
|
||||
render(<Input showLeftIcon size="large" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pl-7')
|
||||
})
|
||||
|
||||
it('applies pr-7 when showClearIcon, has value, and size is large', () => {
|
||||
render(<Input showClearIcon value="123" size="large" onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pr-7')
|
||||
})
|
||||
|
||||
it('applies pr-7 when destructive and size is large', () => {
|
||||
render(<Input destructive size="large" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pr-7')
|
||||
})
|
||||
|
||||
it('shows copy icon and applies pr-[26px] when showCopyIcon is true', () => {
|
||||
render(<Input showCopyIcon />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pr-[26px]')
|
||||
// Assert that CopyFeedbackNew wrapper is present
|
||||
const copyWrapper = document.querySelector('.group.absolute.right-0')
|
||||
expect(copyWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows copy icon and applies pr-7 when showCopyIcon and size is large', () => {
|
||||
render(<Input showCopyIcon size="large" value="my-val" onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('pr-7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Number Input Formatting', () => {
|
||||
it('removes leading zeros on change when current value is zero', () => {
|
||||
let changedValue = ''
|
||||
@ -130,6 +165,17 @@ describe('Input component', () => {
|
||||
expect(changedValue).toBe('42')
|
||||
})
|
||||
|
||||
it('does not normalize when value is 0 and input value is already normalized', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<Input type="number" value={0} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton') as HTMLInputElement
|
||||
// The event value ('1') is already normalized, preventing e.target.value reassignment
|
||||
fireEvent.change(input, { target: { value: '1' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps typed value on change when current value is not zero', () => {
|
||||
let changedValue = ''
|
||||
const onChange = vi.fn((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@ -25,4 +25,9 @@ describe('Loading Component', () => {
|
||||
const svgElement = container.querySelector('svg')
|
||||
expect(svgElement).toHaveClass('spin-animation')
|
||||
})
|
||||
|
||||
it('handles undefined props correctly', () => {
|
||||
const { container } = render(Loading() as unknown as React.ReactElement)
|
||||
expect(container.firstChild).toHaveClass('flex w-full items-center justify-center')
|
||||
})
|
||||
})
|
||||
|
||||
@ -42,7 +42,7 @@ export const Markdown = memo((props: MarkdownProps) => {
|
||||
const latexContent = useMemo(() => preprocess(content), [content])
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', className)}>
|
||||
<div className={cn('markdown-body', '!text-text-primary', className)} data-testid="markdown-body">
|
||||
<StreamdownWrapper
|
||||
pluginInfo={pluginInfo}
|
||||
latexContent={latexContent}
|
||||
|
||||
@ -4,9 +4,11 @@ import { useStore } from '@/app/components/app/store'
|
||||
import MessageLogModal from '../index'
|
||||
|
||||
let clickAwayHandler: (() => void) | null = null
|
||||
let clickAwayHandlers: (() => void)[] = []
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: (fn: () => void) => {
|
||||
clickAwayHandler = fn
|
||||
clickAwayHandlers.push(fn)
|
||||
},
|
||||
}))
|
||||
|
||||
@ -38,6 +40,7 @@ describe('MessageLogModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickAwayHandler = null
|
||||
clickAwayHandlers = []
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
vi.mocked(useStore).mockImplementation((selector: any) => selector({
|
||||
appDetail: { id: 'app-1' },
|
||||
@ -100,5 +103,12 @@ describe('MessageLogModal', () => {
|
||||
clickAwayHandler!()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onCancel when clicked away if not mounted', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
|
||||
expect(clickAwayHandlers.length).toBeGreaterThan(0)
|
||||
clickAwayHandlers[0]() // This is the closure from the initial render, where mounted is false
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -81,7 +81,11 @@ describe('NotionPageSelector Base', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useModalContextSelector).mockReturnValue(mockSetShowAccountSettingModal)
|
||||
vi.mocked(useModalContextSelector).mockImplementation((selector) => {
|
||||
// Execute the selector to get branch/func coverage for the inline function
|
||||
selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal } as unknown as Parameters<Parameters<typeof useModalContextSelector>[0]>[0])
|
||||
return mockSetShowAccountSettingModal
|
||||
})
|
||||
vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages)
|
||||
})
|
||||
|
||||
@ -268,4 +272,57 @@ describe('NotionPageSelector Base', () => {
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} canPreview={false} />)
|
||||
expect(screen.queryByTestId('notion-page-preview-root-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined data gracefully during loading', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue({
|
||||
data: undefined,
|
||||
isFetching: true,
|
||||
isError: false,
|
||||
} as unknown as ReturnType<typeof usePreImportNotionPages>)
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
expect(screen.getByTestId('notion-page-selector-loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle credential with empty id', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const onSelectCredential = vi.fn()
|
||||
render(
|
||||
<NotionPageSelector
|
||||
credentialList={[buildCredential('', 'Empty', 'Empty Workspace')]}
|
||||
onSelect={vi.fn()}
|
||||
onSelectCredential={onSelectCredential}
|
||||
/>,
|
||||
)
|
||||
expect(onSelectCredential).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should render empty page selector when notion_info is empty', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue({
|
||||
data: { notion_info: undefined },
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
} as unknown as ReturnType<typeof usePreImportNotionPages>)
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
expect(screen.getByTestId('notion-page-selector-base')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should run credential effect fallback when onSelectCredential is not provided', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const { rerender } = render(
|
||||
<NotionPageSelector
|
||||
credentialList={mockCredentialList}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with a new credentialList but same credential to hit the else block without onSelectCredential
|
||||
rerender(
|
||||
<NotionPageSelector
|
||||
credentialList={[...mockCredentialList, buildCredential('c3', 'Cred 3', 'Workspace 3')]}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('notion-page-selector-base')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PageSelector from '../index'
|
||||
|
||||
const buildPage = (overrides: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({
|
||||
@ -18,12 +17,16 @@ const mockList: DataSourceNotionPage[] = [
|
||||
buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }),
|
||||
buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }),
|
||||
buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }),
|
||||
buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }),
|
||||
buildPage({ page_id: 'root-2', page_name: 'Root 2', parent_id: 'root' }),
|
||||
]
|
||||
|
||||
const mockPagesMap: DataSourceNotionPageMap = {
|
||||
'root-1': { ...mockList[0], workspace_id: 'workspace-1' },
|
||||
'child-1': { ...mockList[1], workspace_id: 'workspace-1' },
|
||||
'grandchild-1': { ...mockList[2], workspace_id: 'workspace-1' },
|
||||
'child-2': { ...mockList[3], workspace_id: 'workspace-1' },
|
||||
'root-2': { ...mockList[4], workspace_id: 'workspace-1' },
|
||||
}
|
||||
|
||||
describe('PageSelector', () => {
|
||||
@ -51,7 +54,7 @@ describe('PageSelector', () => {
|
||||
it('should call onSelect with descendants when parent is selected', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={[mockList[0], mockList[1], mockList[2]]} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
|
||||
await user.click(checkbox)
|
||||
@ -124,4 +127,190 @@ describe('PageSelector', () => {
|
||||
await user.click(toggleBtn) // Collapse
|
||||
await waitFor(() => expect(screen.queryByText('Child 1')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should disable checkbox when page is in disabledValue', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set(['root-1'])} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
|
||||
await user.click(checkbox)
|
||||
expect(handleSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render preview button when canPreview is false', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={false} />)
|
||||
|
||||
expect(screen.queryByTestId('notion-page-preview-root-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render preview button when canPreview is true', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} />)
|
||||
|
||||
expect(screen.getByTestId('notion-page-preview-root-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use previewPageId prop when provided', () => {
|
||||
const { rerender } = render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} previewPageId="root-1" />)
|
||||
|
||||
let row = screen.getByTestId('notion-page-row-root-1')
|
||||
expect(row).toHaveClass('bg-state-base-hover')
|
||||
|
||||
rerender(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} previewPageId="root-2" />)
|
||||
|
||||
row = screen.getByTestId('notion-page-row-root-1')
|
||||
expect(row).not.toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should handle selection of multiple pages independently when searching', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox1 = screen.getByTestId('checkbox-notion-page-checkbox-child-1')
|
||||
const checkbox2 = screen.getByTestId('checkbox-notion-page-checkbox-child-2')
|
||||
|
||||
await user.click(checkbox1)
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set(['child-1']))
|
||||
|
||||
// Simulate parent component updating the value prop
|
||||
rerender(<PageSelector value={new Set(['child-1'])} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
await user.click(checkbox2)
|
||||
expect(handleSelect).toHaveBeenLastCalledWith(new Set(['child-1', 'child-2']))
|
||||
})
|
||||
|
||||
it('should expand and show all children when parent is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
const toggle = screen.getByTestId('notion-page-toggle-root-1')
|
||||
await user.click(toggle)
|
||||
|
||||
// Both children should be visible
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand nested children when toggling parent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
// Expand root-1
|
||||
let toggle = screen.getByTestId('notion-page-toggle-root-1')
|
||||
await user.click(toggle)
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument()
|
||||
|
||||
// Expand child-1
|
||||
toggle = screen.getByTestId('notion-page-toggle-child-1')
|
||||
await user.click(toggle)
|
||||
expect(screen.getByText('Grandchild 1')).toBeInTheDocument()
|
||||
|
||||
// Collapse child-1
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(screen.queryByText('Grandchild 1')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should deselect all descendants when parent is deselected with descendants', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('should only select the item when searching (no descendants)', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={[mockList[1]]} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-child-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
// When searching, only the item itself is selected, not descendants
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set(['child-1']))
|
||||
})
|
||||
|
||||
it('should deselect only the item when searching (no descendants)', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set(['child-1'])} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={[mockList[1]]} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-child-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('should handle multiple root pages', async () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Root 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Root 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update preview when clicking preview button with onPreview provided', async () => {
|
||||
const handlePreview = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} onPreview={handlePreview} />)
|
||||
|
||||
const previewBtn = screen.getByTestId('notion-page-preview-root-2')
|
||||
await user.click(previewBtn)
|
||||
|
||||
expect(handlePreview).toHaveBeenCalledWith('root-2')
|
||||
})
|
||||
|
||||
it('should update local preview state when preview button clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} />)
|
||||
|
||||
const previewBtn1 = screen.getByTestId('notion-page-preview-root-1')
|
||||
await user.click(previewBtn1)
|
||||
|
||||
// The preview should now show the hover state for root-1
|
||||
rerender(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} />)
|
||||
|
||||
const row = screen.getByTestId('notion-page-row-root-1')
|
||||
expect(row).toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should render page name with correct title attribute', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
const pageName = screen.getByTestId('notion-page-name-root-1')
|
||||
expect(pageName).toHaveAttribute('title', 'Root 1')
|
||||
})
|
||||
|
||||
it('should handle empty list gracefully', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={[]} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter search results correctly with partial matches', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="1" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
// Should show Root 1, Child 1, and Grandchild 1
|
||||
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notion-page-name-child-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notion-page-name-grandchild-1')).toBeInTheDocument()
|
||||
// Should not show Root 2, Child 2
|
||||
expect(screen.queryByTestId('notion-page-name-root-2')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('notion-page-name-child-2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle disabled parent when selecting child', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set(['root-1'])} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const toggle = screen.getByTestId('notion-page-toggle-root-1')
|
||||
await user.click(toggle)
|
||||
|
||||
// Should expand even though parent is disabled
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -133,6 +133,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
<div
|
||||
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover', previewPageId === current.page_id && 'bg-state-base-hover')}
|
||||
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
|
||||
data-testid={`notion-page-row-${current.page_id}`}
|
||||
>
|
||||
<Checkbox
|
||||
className="mr-2 shrink-0"
|
||||
|
||||
@ -17,6 +17,10 @@ const SearchInput = ({
|
||||
onChange('')
|
||||
}, [onChange])
|
||||
|
||||
const placeholderText = t('dataSource.notion.selector.searchPages', { ns: 'common' })
|
||||
/* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */
|
||||
const safePlaceholderText = placeholderText || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-8 w-[200px] items-center rounded-lg bg-components-input-bg-normal p-2')}
|
||||
@ -27,7 +31,7 @@ const SearchInput = ({
|
||||
className="min-w-0 grow appearance-none border-0 bg-transparent px-1 text-[13px] leading-[16px] text-components-input-text-filled outline-0 placeholder:text-components-input-text-placeholder"
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
||||
placeholder={t('dataSource.notion.selector.searchPages', { ns: 'common' }) || ''}
|
||||
placeholder={safePlaceholderText}
|
||||
data-testid="notion-search-input"
|
||||
/>
|
||||
{
|
||||
|
||||
@ -185,12 +185,30 @@ describe('CustomizedPagination', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should ignore non-numeric input', () => {
|
||||
it('should ignore non-numeric input and empty input', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should show per page tip on hover and hide on leave', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
|
||||
const container = screen.getByText('25').closest('div.flex.items-center.gap-\\[1px\\]')!
|
||||
|
||||
fireEvent.mouseEnter(container)
|
||||
// I18n mock returns ns.key
|
||||
expect(screen.getByText('common.pagination.perPage')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(container)
|
||||
expect(screen.queryByText('common.pagination.perPage')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onLimitChange when limit option is clicked', () => {
|
||||
@ -200,6 +218,17 @@ describe('CustomizedPagination', () => {
|
||||
expect(onLimitChange).toHaveBeenCalledWith(25)
|
||||
})
|
||||
|
||||
it('should call onLimitChange with 10 when 10 option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
// The limit selector contains options 10, 25, 50.
|
||||
// Query specifically within the limit container
|
||||
const container = screen.getByText('25').closest('div.flex.items-center.gap-\\[1px\\]')!
|
||||
const option10 = Array.from(container.children).find(el => el.textContent === '10')!
|
||||
fireEvent.click(option10)
|
||||
expect(onLimitChange).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('should call onLimitChange with 50 when 50 option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
@ -213,6 +242,18 @@ describe('CustomizedPagination', () => {
|
||||
fireEvent.click(screen.getByText('3'))
|
||||
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should correctly select active limit style for 25 and 50', () => {
|
||||
// Test limit 25
|
||||
const { container: containerA } = render(<CustomizedPagination current={0} total={100} limit={25} onChange={vi.fn()} onLimitChange={vi.fn()} />)
|
||||
const wrapper25 = Array.from(containerA.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '25')!
|
||||
expect(wrapper25).toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
|
||||
// Test limit 50
|
||||
const { container: containerB } = render(<CustomizedPagination current={0} total={100} limit={50} onChange={vi.fn()} onLimitChange={vi.fn()} />)
|
||||
const wrapper50 = Array.from(containerB.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '50')!
|
||||
expect(wrapper50).toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
@ -221,6 +262,66 @@ describe('CustomizedPagination', () => {
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle confirm when input value is unchanged (covers false branch of empty string check)', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
// Blur without changing anything
|
||||
fireEvent.blur(input)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
// onChange should NOT be called
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore other keys in handleInputKeyDown (covers false branch of Escape check)', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger handleInputConfirm with empty string specifically on keydown Enter', async () => {
|
||||
const { userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '{Enter}')
|
||||
|
||||
// Wait for debounce 500ms
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should explicitly trigger Escape key logic in handleInputKeyDown', async () => {
|
||||
const { userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.type(input, '{Escape}')
|
||||
|
||||
// Wait for debounce 500ms
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle single page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
|
||||
// totalPages = 1, both buttons should be disabled
|
||||
|
||||
@ -372,5 +372,178 @@ describe('Pagination', () => {
|
||||
})
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cover undefined active/inactive dataTestIds', () => {
|
||||
// Re-render PageButton without active/inactive data test ids to hit the undefined branch in cn() fallback
|
||||
renderPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
renderExtraProps={page => ({ 'aria-label': `Page ${page}` })}
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('2')).toHaveAttribute('aria-label', 'Page 2')
|
||||
})
|
||||
|
||||
it('should cover nextPages when edge pages fall perfectly into middle Pages', () => {
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 10,
|
||||
edgePageCount: 8, // Very large edge page count to hit the filter(!middlePages.includes) branches
|
||||
middlePagesSiblingCount: 1,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide truncation element if truncable is false', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 1,
|
||||
// When we are at page 2, middle pages are [2, 3, 4] (if 0-indexed, wait, currentPage is 0-indexed in hook?)
|
||||
// Let's just render the component which calls the internal TruncableElement, when previous/next are NOT truncable
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Truncation only happens if middlePages > previousPages.last + 1
|
||||
expect(screen.queryByText('...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hit getAllPreviousPages with less than 1 element', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 0,
|
||||
children: <Pagination.PageButton className="btn" activeClassName="act" inactiveClassName="inact" />,
|
||||
})
|
||||
// With currentPage = 0, middlePages = [1], getAllPreviousPages() -> slice(0, 0) -> []
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fire previous() keyboard event even if it does nothing without crashing', () => {
|
||||
// Line 38: pagination.currentPage + 1 > 1 check is usually guarded by disabled, but we can verify it explicitly.
|
||||
const setCurrentPage = vi.fn()
|
||||
// Use a span so that 'disabled' attribute doesn't prevent fireEvent.click from firing
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton as={<span />}>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Prev'))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire next() even if it does nothing without crashing', () => {
|
||||
// Line 73: pagination.currentPage + 1 < pages.length verify
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 10,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton as={<span />}>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to undefined when truncableClassName is empty', () => {
|
||||
// Line 115: `<li className={truncableClassName || undefined}>{truncableText}</li>`
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 10,
|
||||
truncableClassName: '',
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Should not have a class attribute
|
||||
const truncableElements = screen.getAllByText('...')
|
||||
expect(truncableElements[0]).not.toHaveAttribute('class')
|
||||
})
|
||||
|
||||
it('should handle dataTestIdActive and dataTestIdInactive completely', () => {
|
||||
// Lines 137-144
|
||||
renderPagination({
|
||||
currentPage: 1, // 0-indexed, so page 2 is active
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdActive="active-test-id"
|
||||
dataTestIdInactive="inactive-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
const activeBtn = screen.getByTestId('active-test-id')
|
||||
expect(activeBtn).toHaveTextContent('2')
|
||||
|
||||
const inactiveBtn = screen.getByTestId('inactive-test-id-1') // page 1
|
||||
expect(inactiveBtn).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should hit getAllNextPages.length < 1 in hook', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 3,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 0,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Current is 3 (index 2). middlePages = [3]. getAllNextPages = slice(3, 3) = []
|
||||
// This will trigger the `getAllNextPages.length < 1` branch
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only dataTestIdInactive without dataTestIdActive', () => {
|
||||
renderPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdInactive="inactive-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// Missing dataTestIdActive branch coverage on line 144
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only dataTestIdActive without dataTestIdInactive', () => {
|
||||
renderPagination({
|
||||
currentPage: 1, // page 2 is active
|
||||
totalPages: 3,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdActive="active-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// This hits the branch where dataTestIdActive exists but not dataTestIdInactive
|
||||
expect(screen.getByTestId('active-test-id')).toHaveTextContent('2')
|
||||
expect(screen.queryByTestId('inactive-test-id-1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,15 +2,32 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '..'
|
||||
|
||||
type MockFloatingData = {
|
||||
middlewareData?: {
|
||||
hide?: {
|
||||
referenceHidden?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mockFloatingData: MockFloatingData = {}
|
||||
const useFloatingMock = vi.fn()
|
||||
|
||||
vi.mock('@floating-ui/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@floating-ui/react')>()
|
||||
return {
|
||||
...actual,
|
||||
useFloating: (...args: Parameters<typeof actual.useFloating>) => {
|
||||
useFloatingMock(...args)
|
||||
return actual.useFloating(...args)
|
||||
useFloating: (options: unknown) => {
|
||||
useFloatingMock(options)
|
||||
const data = actual.useFloating(options as Parameters<typeof actual.useFloating>[0])
|
||||
return {
|
||||
...data,
|
||||
...mockFloatingData,
|
||||
middlewareData: {
|
||||
...data.middlewareData,
|
||||
...mockFloatingData.middlewareData,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -123,8 +140,91 @@ describe('PortalToFollowElem', () => {
|
||||
placement: 'top-start',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
useFloatingMock.mockRestore()
|
||||
it('should handle triggerPopupSameWidth prop', () => {
|
||||
render(
|
||||
<PortalToFollowElem triggerPopupSameWidth>
|
||||
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>Content</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
type SizeMiddleware = {
|
||||
name: 'size'
|
||||
options: [{
|
||||
apply: (args: {
|
||||
elements: { floating: { style: Record<string, string> } }
|
||||
rects: { reference: { width: number } }
|
||||
availableHeight: number
|
||||
}) => void
|
||||
}]
|
||||
}
|
||||
|
||||
const sizeMiddleware = useFloatingMock.mock.calls[0][0].middleware.find(
|
||||
(m: { name: string }) => m.name === 'size',
|
||||
) as SizeMiddleware
|
||||
expect(sizeMiddleware).toBeDefined()
|
||||
|
||||
// Manually trigger the apply function to cover line 81-82
|
||||
const mockElements = {
|
||||
floating: { style: {} as Record<string, string> },
|
||||
}
|
||||
const mockRects = {
|
||||
reference: { width: 100 },
|
||||
}
|
||||
sizeMiddleware.options[0].apply({
|
||||
elements: mockElements,
|
||||
rects: mockRects,
|
||||
availableHeight: 500,
|
||||
})
|
||||
|
||||
expect(mockElements.floating.style.width).toBe('100px')
|
||||
expect(mockElements.floating.style.maxHeight).toBe('500px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PortalToFollowElemTrigger asChild', () => {
|
||||
it('should render correct data-state when open', () => {
|
||||
const { getByRole } = render(
|
||||
<PortalToFollowElem open={true}>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button>Trigger</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
expect(getByRole('button')).toHaveAttribute('data-state', 'open')
|
||||
})
|
||||
|
||||
it('should handle missing ref on child', () => {
|
||||
const { getByRole } = render(
|
||||
<PortalToFollowElem>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button>Trigger</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
expect(getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visibility', () => {
|
||||
it('should hide content when reference is hidden', () => {
|
||||
mockFloatingData = {
|
||||
middlewareData: {
|
||||
hide: { referenceHidden: true },
|
||||
},
|
||||
}
|
||||
|
||||
const { getByTestId } = render(
|
||||
<PortalToFollowElem open={true}>
|
||||
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent data-testid="content">Hidden Content</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
expect(getByTestId('content')).toHaveStyle('visibility: hidden')
|
||||
mockFloatingData = {}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -179,6 +179,96 @@ describe('HITLInputVariableBlockComponent', () => {
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should show valid state when conversation variables array is undefined', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['conversation', 'session_id'],
|
||||
workflowNodesMap: {},
|
||||
conversationVariables: undefined,
|
||||
})
|
||||
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should show valid state when env variables array is undefined', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['env', 'api_key'],
|
||||
workflowNodesMap: {},
|
||||
environmentVariables: undefined,
|
||||
})
|
||||
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should show valid state when rag variables array is undefined', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['rag', 'node-rag', 'chunk'],
|
||||
workflowNodesMap: createWorkflowNodesMap(),
|
||||
ragVariables: undefined,
|
||||
})
|
||||
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate env variable when matching entry exists in multi-element array', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['env', 'api_key'],
|
||||
workflowNodesMap: {},
|
||||
environmentVariables: [
|
||||
{ variable: 'env.other_key', type: 'string' } as Var,
|
||||
{ variable: 'env.api_key', type: 'string' } as Var,
|
||||
],
|
||||
})
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate conversation variable when matching entry exists in multi-element array', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['conversation', 'session_id'],
|
||||
workflowNodesMap: {},
|
||||
conversationVariables: [
|
||||
{ variable: 'conversation.other', type: 'string' } as Var,
|
||||
{ variable: 'conversation.session_id', type: 'string' } as Var,
|
||||
],
|
||||
})
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate rag variable when matching entry exists in multi-element array', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['rag', 'node-rag', 'chunk'],
|
||||
workflowNodesMap: createWorkflowNodesMap(),
|
||||
ragVariables: [
|
||||
{ variable: 'rag.node-rag.other', type: 'string', isRagVariable: true } as Var,
|
||||
{ variable: 'rag.node-rag.chunk', type: 'string', isRagVariable: true } as Var,
|
||||
],
|
||||
})
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined indices in variables array gracefully', () => {
|
||||
// Testing the `variables?.[1] ?? ''` fallback logic
|
||||
const { container: envContainer } = renderVariableBlock({
|
||||
variables: ['env'], // missing second part
|
||||
workflowNodesMap: {},
|
||||
environmentVariables: [{ variable: 'env.', type: 'string' } as Var],
|
||||
})
|
||||
expect(hasErrorIcon(envContainer)).toBe(false)
|
||||
|
||||
const { container: chatContainer } = renderVariableBlock({
|
||||
variables: ['conversation'],
|
||||
workflowNodesMap: {},
|
||||
conversationVariables: [{ variable: 'conversation.', type: 'string' } as Var],
|
||||
})
|
||||
expect(hasErrorIcon(chatContainer)).toBe(false)
|
||||
|
||||
const { container: ragContainer } = renderVariableBlock({
|
||||
variables: ['rag', 'node-rag'], // missing third part
|
||||
workflowNodesMap: createWorkflowNodesMap(),
|
||||
ragVariables: [{ variable: 'rag.node-rag.', type: 'string', isRagVariable: true } as Var],
|
||||
})
|
||||
expect(hasErrorIcon(ragContainer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep global system variable valid without workflow node mapping', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['sys', 'global_name'],
|
||||
@ -188,6 +278,25 @@ describe('HITLInputVariableBlockComponent', () => {
|
||||
expect(screen.getByText('sys.global_name')).toBeInTheDocument()
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
})
|
||||
|
||||
it('should format system variable names with sys. prefix correctly', () => {
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['sys', 'query'],
|
||||
workflowNodesMap: {},
|
||||
})
|
||||
// 'query' exception variable is valid sys variable
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(hasErrorIcon(container)).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply exception styling for recognized exception variables', () => {
|
||||
renderVariableBlock({
|
||||
variables: ['node-1', 'error_message'],
|
||||
workflowNodesMap: createWorkflowNodesMap(),
|
||||
})
|
||||
expect(screen.getByText('error_message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('exception-variable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip payload', () => {
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import PromptLogModal from '..'
|
||||
|
||||
let clickAwayHandlers: (() => void)[] = []
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useClickAway: vi.fn((fn: () => void) => {
|
||||
clickAwayHandlers.push(fn)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PromptLogModal', () => {
|
||||
const defaultProps = {
|
||||
width: 1000,
|
||||
@ -10,9 +21,14 @@ describe('PromptLogModal', () => {
|
||||
id: '1',
|
||||
content: 'test',
|
||||
log: [{ role: 'user', text: 'Hello' }],
|
||||
} as Parameters<typeof PromptLogModal>[0]['currentLogItem'],
|
||||
} as unknown as Parameters<typeof PromptLogModal>[0]['currentLogItem'],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickAwayHandlers = []
|
||||
})
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders correctly when currentLogItem is provided', () => {
|
||||
render(<PromptLogModal {...defaultProps} />)
|
||||
@ -29,6 +45,28 @@ describe('PromptLogModal', () => {
|
||||
render(<PromptLogModal {...defaultProps} />)
|
||||
expect(screen.getByTestId('close-btn-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple logs in Card correctly', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
currentLogItem: {
|
||||
...defaultProps.currentLogItem,
|
||||
log: [
|
||||
{ role: 'user', text: 'Hello' },
|
||||
{ role: 'assistant', text: 'Hi there' },
|
||||
],
|
||||
},
|
||||
} as unknown as Parameters<typeof PromptLogModal>[0]
|
||||
render(<PromptLogModal {...props} />)
|
||||
expect(screen.getByText('USER')).toBeInTheDocument()
|
||||
expect(screen.getByText('ASSISTANT')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hi there')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when currentLogItem.log is missing', () => {
|
||||
const { container } = render(<PromptLogModal {...defaultProps} currentLogItem={{ id: '1' } as unknown as Parameters<typeof PromptLogModal>[0]['currentLogItem']} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
@ -41,20 +79,27 @@ describe('PromptLogModal', () => {
|
||||
})
|
||||
|
||||
it('calls onCancel when clicking outside', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="outside">Outside</div>
|
||||
<PromptLogModal {...defaultProps} onCancel={onCancel} />
|
||||
</div>,
|
||||
<PromptLogModal {...defaultProps} onCancel={onCancel} />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
|
||||
})
|
||||
expect(useClickAway).toHaveBeenCalled()
|
||||
expect(clickAwayHandlers.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(screen.getByTestId('outside'))
|
||||
// Call the last registered handler (simulating click away)
|
||||
clickAwayHandlers[clickAwayHandlers.length - 1]()
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onCancel when clicking outside if not mounted', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<PromptLogModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
expect(clickAwayHandlers.length).toBeGreaterThan(0)
|
||||
// The first handler in the array is captured during the initial render before useEffect runs
|
||||
clickAwayHandlers[0]()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -90,5 +90,45 @@ describe('ShareQRCode', () => {
|
||||
HTMLCanvasElement.prototype.toDataURL = originalToDataURL
|
||||
}
|
||||
})
|
||||
|
||||
it('does not call downloadUrl when canvas is not found', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ShareQRCode content={content} />)
|
||||
|
||||
const trigger = screen.getByTestId('qrcode-container')
|
||||
await user.click(trigger)
|
||||
|
||||
// Override querySelector on the panel to simulate canvas not being found
|
||||
const panel = screen.getByRole('img').parentElement!
|
||||
const origQuerySelector = panel.querySelector.bind(panel)
|
||||
panel.querySelector = ((sel: string) => {
|
||||
if (sel === 'canvas')
|
||||
return null
|
||||
return origQuerySelector(sel)
|
||||
}) as typeof panel.querySelector
|
||||
|
||||
try {
|
||||
const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download')
|
||||
await user.click(downloadBtn)
|
||||
expect(downloadUrl).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
panel.querySelector = origQuerySelector
|
||||
}
|
||||
})
|
||||
|
||||
it('does not close when clicking inside the qrcode ref area', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ShareQRCode content={content} />)
|
||||
|
||||
const trigger = screen.getByTestId('qrcode-container')
|
||||
await user.click(trigger)
|
||||
|
||||
// Click on the scan text inside the panel — panel should remain open
|
||||
const scanText = screen.getByText('appOverview.overview.appInfo.qrcode.scan')
|
||||
await user.click(scanText)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -25,6 +25,7 @@ const ShareQRCode = ({ content }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
/* v8 ignore next 2 -- this handler can fire during open/close transitions where the panel ref is temporarily null; guard is defensive. @preserve */
|
||||
if (qrCodeRef.current && !qrCodeRef.current.contains(event.target as Node))
|
||||
setIsShow(false)
|
||||
}
|
||||
@ -48,9 +49,13 @@ const ShareQRCode = ({ content }: Props) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const tooltipText = t(`${prefixEmbedded}`, { ns: 'appOverview' })
|
||||
/* v8 ignore next -- react-i18next returns a non-empty key/string in configured runtime; empty fallback protects against missing i18n payloads. @preserve */
|
||||
const safeTooltipText = tooltipText || ''
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={t(`${prefixEmbedded}`, { ns: 'appOverview' }) || ''}
|
||||
popupContent={safeTooltipText}
|
||||
>
|
||||
<div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container">
|
||||
<ActionButton>
|
||||
|
||||
@ -94,4 +94,21 @@ describe('SegmentedControl', () => {
|
||||
const selectedOption = screen.getByText('Option 1').closest('button')?.closest('div')
|
||||
expect(selectedOption).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('renders Icon when provided', () => {
|
||||
const MockIcon = () => <svg data-testid="mock-icon" />
|
||||
const optionsWithIcon = [
|
||||
{ value: 'option1', text: 'Option 1', Icon: MockIcon },
|
||||
]
|
||||
render(<SegmentedControl options={optionsWithIcon} value="option1" onChange={onSelectMock} />)
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count when provided and size is large', () => {
|
||||
const optionsWithCount = [
|
||||
{ value: 'option1', text: 'Option 1', count: 42 },
|
||||
]
|
||||
render(<SegmentedControl options={optionsWithCount} value="option1" onChange={onSelectMock} size="large" />)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,8 +7,10 @@ const SVGRenderer = ({ content }: { content: string }) => {
|
||||
const svgRef = useRef<HTMLDivElement>(null)
|
||||
const [imagePreview, setImagePreview] = useState('')
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
/* v8 ignore start -- this client component can still be evaluated in non-browser contexts (SSR/type tooling); window fallback prevents reference errors. @preserve */
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
/* v8 ignore stop */
|
||||
})
|
||||
|
||||
const svgToDataURL = (svgElement: Element): string => {
|
||||
@ -27,34 +29,38 @@ const SVGRenderer = ({ content }: { content: string }) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current) {
|
||||
try {
|
||||
svgRef.current.innerHTML = ''
|
||||
const draw = SVG().addTo(svgRef.current)
|
||||
/* v8 ignore next 2 -- ref is expected after mount, but null can occur during rapid mount/unmount timing in React lifecycle edges. @preserve */
|
||||
if (!svgRef.current)
|
||||
return
|
||||
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(content, 'image/svg+xml')
|
||||
const svgElement = svgDoc.documentElement
|
||||
try {
|
||||
svgRef.current.innerHTML = ''
|
||||
const draw = SVG().addTo(svgRef.current)
|
||||
|
||||
if (!(svgElement instanceof SVGElement))
|
||||
throw new Error('Invalid SVG content')
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(content, 'image/svg+xml')
|
||||
const svgElement = svgDoc.documentElement
|
||||
|
||||
const originalWidth = Number.parseInt(svgElement.getAttribute('width') || '400', 10)
|
||||
const originalHeight = Number.parseInt(svgElement.getAttribute('height') || '600', 10)
|
||||
draw.viewbox(0, 0, originalWidth, originalHeight)
|
||||
if (!(svgElement instanceof SVGElement))
|
||||
throw new Error('Invalid SVG content')
|
||||
|
||||
svgRef.current.style.width = `${Math.min(originalWidth, 298)}px`
|
||||
const originalWidth = Number.parseInt(svgElement.getAttribute('width') || '400', 10)
|
||||
const originalHeight = Number.parseInt(svgElement.getAttribute('height') || '600', 10)
|
||||
draw.viewbox(0, 0, originalWidth, originalHeight)
|
||||
|
||||
const rootElement = draw.svg(DOMPurify.sanitize(content))
|
||||
svgRef.current.style.width = `${Math.min(originalWidth, 298)}px`
|
||||
|
||||
rootElement.click(() => {
|
||||
setImagePreview(svgToDataURL(svgElement as Element))
|
||||
})
|
||||
}
|
||||
catch {
|
||||
if (svgRef.current)
|
||||
svgRef.current.innerHTML = '<span style="padding: 1rem;">Error rendering SVG. Wait for the image content to complete.</span>'
|
||||
}
|
||||
const rootElement = draw.svg(DOMPurify.sanitize(content))
|
||||
|
||||
rootElement.click(() => {
|
||||
setImagePreview(svgToDataURL(svgElement as Element))
|
||||
})
|
||||
}
|
||||
catch {
|
||||
/* v8 ignore next 2 -- if unmounted while handling parser/render errors, ref becomes null; guard avoids writing to a detached node. @preserve */
|
||||
if (!svgRef.current)
|
||||
return
|
||||
svgRef.current.innerHTML = '<span style="padding: 1rem;">Error rendering SVG. Wait for the image content to complete.</span>'
|
||||
}
|
||||
}, [content, windowSize])
|
||||
|
||||
|
||||
@ -104,4 +104,44 @@ describe('TabSlider Component', () => {
|
||||
expect(slider.style.transform).toBe('translateX(120px)')
|
||||
expect(slider.style.width).toBe('80px')
|
||||
})
|
||||
|
||||
it('does not call onChange when clicking the already active tab', () => {
|
||||
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
|
||||
const activeTab = screen.getByTestId('tab-item-all')
|
||||
fireEvent.click(activeTab)
|
||||
expect(onChangeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles invalid value gracefully', () => {
|
||||
const { container, rerender } = render(<TabSlider value="invalid" options={mockOptions} onChange={onChangeMock} />)
|
||||
const activeTabs = container.querySelectorAll('.text-text-primary')
|
||||
expect(activeTabs.length).toBe(0)
|
||||
|
||||
// Changing to a valid value should work
|
||||
rerender(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
|
||||
expect(screen.getByTestId('tab-item-all')).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('supports string itemClassName', () => {
|
||||
render(
|
||||
<TabSlider
|
||||
value="all"
|
||||
options={mockOptions}
|
||||
onChange={onChangeMock}
|
||||
itemClassName="custom-static-class"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('tab-item-all')).toHaveClass('custom-static-class')
|
||||
expect(screen.getByTestId('tab-item-settings')).toHaveClass('custom-static-class')
|
||||
})
|
||||
|
||||
it('handles missing pluginList data gracefully', () => {
|
||||
vi.mocked(useInstalledPluginList).mockReturnValue({
|
||||
data: undefined as unknown as { total: number },
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useInstalledPluginList>)
|
||||
|
||||
render(<TabSlider value="plugins" options={mockOptions} onChange={onChangeMock} />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument() // Badge shouldn't render
|
||||
})
|
||||
})
|
||||
|
||||
@ -55,6 +55,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
/* v8 ignore next 2 -- video element is expected post-mount; null guard protects against lifecycle timing during mount/unmount. @preserve */
|
||||
if (!video)
|
||||
return
|
||||
|
||||
@ -99,6 +100,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
/* v8 ignore next -- click handler can race with unmount in tests/runtime; guard prevents calling methods on a detached video node. @preserve */
|
||||
if (video) {
|
||||
if (isPlaying)
|
||||
video.pause()
|
||||
@ -109,6 +111,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
/* v8 ignore next -- defensive null-check for ref lifecycle edges before mutating media properties. @preserve */
|
||||
if (video) {
|
||||
const newMutedState = !video.muted
|
||||
video.muted = newMutedState
|
||||
@ -120,6 +123,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
/* v8 ignore next -- defensive null-check so fullscreen calls are skipped if video ref is detached. @preserve */
|
||||
if (video) {
|
||||
if (document.fullscreenElement)
|
||||
document.exitFullscreen()
|
||||
@ -136,6 +140,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
const updateVideoProgress = useCallback((clientX: number, updateTime = false) => {
|
||||
const progressBar = progressRef.current
|
||||
const video = videoRef.current
|
||||
/* v8 ignore next -- progress callbacks may fire while refs are not yet attached or already torn down; guard avoids invalid DOM access. @preserve */
|
||||
if (progressBar && video) {
|
||||
const rect = progressBar.getBoundingClientRect()
|
||||
const pos = (clientX - rect.left) / rect.width
|
||||
@ -170,6 +175,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
/* v8 ignore next -- global mousemove listener remains registered briefly; skip updates once dragging has ended. @preserve */
|
||||
if (isDragging)
|
||||
updateVideoProgress(e.clientX)
|
||||
}
|
||||
@ -191,6 +197,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
}, [isDragging, updateVideoProgress])
|
||||
|
||||
const checkSize = useCallback(() => {
|
||||
/* v8 ignore next 2 -- container ref may be null before first paint or after unmount while resize events are in flight. @preserve */
|
||||
if (containerRef.current)
|
||||
setIsSmallSize(containerRef.current.offsetWidth < 400)
|
||||
}, [])
|
||||
@ -204,6 +211,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
const handleVolumeChange = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const volumeBar = volumeRef.current
|
||||
const video = videoRef.current
|
||||
/* v8 ignore next -- defensive check for ref availability during drag/click lifecycle transitions. @preserve */
|
||||
if (volumeBar && video) {
|
||||
const rect = volumeBar.getBoundingClientRect()
|
||||
const newVolume = (e.clientX - rect.left) / rect.width
|
||||
@ -222,7 +230,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
<source key={index} src={srcUrl} />
|
||||
))}
|
||||
</video>
|
||||
<div className={`${styles.controls} ${isControlsVisible ? styles.visible : styles.hidden} ${isSmallSize ? styles.smallSize : ''}`}>
|
||||
<div className={`${styles.controls} ${isControlsVisible ? styles.visible : styles.hidden} ${isSmallSize ? styles.smallSize : ''}`} data-testid="video-controls" data-is-visible={isControlsVisible}>
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div
|
||||
|
||||
@ -7,6 +7,20 @@ describe('VideoPlayer', () => {
|
||||
const mockSrc = 'video.mp4'
|
||||
const mockSrcs = ['video1.mp4', 'video2.mp4']
|
||||
|
||||
const mockBoundingRect = (element: Element) => {
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
width: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 10,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => { },
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
@ -32,34 +46,34 @@ describe('VideoPlayer', () => {
|
||||
get() { return 100 },
|
||||
})
|
||||
|
||||
type MockVideoElement = HTMLVideoElement & {
|
||||
_currentTime?: number
|
||||
_volume?: number
|
||||
_muted?: boolean
|
||||
}
|
||||
|
||||
// Use a descriptor check to avoid re-defining if it exists
|
||||
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'currentTime')) {
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', {
|
||||
configurable: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
get() { return (this as any)._currentTime || 0 },
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
set(v) { (this as any)._currentTime = v },
|
||||
get() { return (this as MockVideoElement)._currentTime || 0 },
|
||||
set(v) { (this as MockVideoElement)._currentTime = v },
|
||||
})
|
||||
}
|
||||
|
||||
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'volume')) {
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'volume', {
|
||||
configurable: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
get() { return (this as any)._volume || 1 },
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
set(v) { (this as any)._volume = v },
|
||||
get() { return (this as MockVideoElement)._volume ?? 1 },
|
||||
set(v) { (this as MockVideoElement)._volume = v },
|
||||
})
|
||||
}
|
||||
|
||||
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'muted')) {
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'muted', {
|
||||
configurable: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
get() { return (this as any)._muted || false },
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
set(v) { (this as any)._muted = v },
|
||||
get() { return (this as MockVideoElement)._muted || false },
|
||||
set(v) { (this as MockVideoElement)._muted = v },
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -96,10 +110,23 @@ describe('VideoPlayer', () => {
|
||||
it('should toggle mute on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
const muteBtn = screen.getByTestId('video-mute-button')
|
||||
|
||||
// Ensure volume is positive before muting
|
||||
video.volume = 0.7
|
||||
|
||||
// First click mutes
|
||||
await user.click(muteBtn)
|
||||
expect(muteBtn).toBeInTheDocument()
|
||||
expect(video.muted).toBe(true)
|
||||
|
||||
// Set volume back to a positive value to test the volume > 0 branch in unmute
|
||||
video.volume = 0.7
|
||||
|
||||
// Second click unmutes — since volume > 0, the ternary should keep video.volume
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(false)
|
||||
expect(video.volume).toBe(0.7)
|
||||
})
|
||||
|
||||
it('should toggle fullscreen on button click', async () => {
|
||||
@ -167,17 +194,7 @@ describe('VideoPlayer', () => {
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
vi.spyOn(progressBar, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
width: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 10,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => { },
|
||||
} as DOMRect)
|
||||
mockBoundingRect(progressBar)
|
||||
|
||||
// Hover
|
||||
fireEvent.mouseMove(progressBar, { clientX: 50 })
|
||||
@ -207,17 +224,7 @@ describe('VideoPlayer', () => {
|
||||
const volumeSlider = screen.getByTestId('video-volume-slider')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
vi.spyOn(volumeSlider, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
width: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 10,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => { },
|
||||
} as DOMRect)
|
||||
mockBoundingRect(volumeSlider)
|
||||
|
||||
// Click
|
||||
fireEvent.click(volumeSlider, { clientX: 50 })
|
||||
@ -258,5 +265,156 @@ describe('VideoPlayer', () => {
|
||||
expect(screen.getByTestId('video-time-display')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle play() rejection error', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
window.HTMLVideoElement.prototype.play = vi.fn().mockRejectedValue(new Error('Play failed'))
|
||||
const user = userEvent.setup()
|
||||
|
||||
try {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const playPauseBtn = screen.getByTestId('video-play-pause-button')
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Error playing video:', expect.any(Error))
|
||||
})
|
||||
}
|
||||
finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('should reset volume to 1 when unmuting with volume at 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
const muteBtn = screen.getByTestId('video-mute-button')
|
||||
|
||||
// First click mutes — this sets volume to 0 and muted to true
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(true)
|
||||
expect(video.volume).toBe(0)
|
||||
|
||||
// Now explicitly ensure video.volume is 0 for unmute path
|
||||
video.volume = 0
|
||||
|
||||
// Second click unmutes — since volume is 0, the ternary
|
||||
// (video.volume > 0 ? video.volume : 1) should choose 1
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(false)
|
||||
expect(video.volume).toBe(1)
|
||||
})
|
||||
|
||||
it('should not clear hoverTime on mouseLeave while dragging', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
|
||||
mockBoundingRect(progressBar)
|
||||
|
||||
// Start dragging
|
||||
fireEvent.mouseDown(progressBar, { clientX: 50 })
|
||||
|
||||
// mouseLeave while dragging — hoverTime should remain visible
|
||||
fireEvent.mouseLeave(progressBar)
|
||||
expect(screen.getByTestId('video-hover-time')).toBeInTheDocument()
|
||||
|
||||
// End drag
|
||||
fireEvent.mouseUp(document)
|
||||
})
|
||||
|
||||
it('should not update time for out-of-bounds progress click', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(progressBar)
|
||||
|
||||
// Click far beyond the bar (clientX > rect.width) — pos > 1, newTime > duration
|
||||
fireEvent.click(progressBar, { clientX: 200 })
|
||||
// currentTime should remain unchanged since newTime (200) > duration (100)
|
||||
expect(video.currentTime).toBe(0)
|
||||
|
||||
// Click at negative position
|
||||
fireEvent.click(progressBar, { clientX: -50 })
|
||||
// currentTime should remain unchanged since newTime < 0
|
||||
expect(video.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it('should render without src or srcs', () => {
|
||||
render(<VideoPlayer />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
expect(video).toBeInTheDocument()
|
||||
expect(video.getAttribute('src')).toBeNull()
|
||||
expect(video.querySelectorAll('source')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should show controls on mouseEnter', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const container = screen.getByTestId('video-player-container')
|
||||
const controls = screen.getByTestId('video-controls')
|
||||
|
||||
// Initial state: visible
|
||||
expect(controls).toHaveAttribute('data-is-visible', 'true')
|
||||
|
||||
// Let controls hide
|
||||
fireEvent.mouseMove(container)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3001)
|
||||
})
|
||||
expect(controls).toHaveAttribute('data-is-visible', 'false')
|
||||
|
||||
// mouseEnter should show controls again
|
||||
fireEvent.mouseEnter(container)
|
||||
expect(controls).toHaveAttribute('data-is-visible', 'true')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle volume drag with inline mouseDown handler', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const volumeSlider = screen.getByTestId('video-volume-slider')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(volumeSlider)
|
||||
|
||||
// MouseDown starts the inline drag handler and sets initial volume
|
||||
fireEvent.mouseDown(volumeSlider, { clientX: 30 })
|
||||
expect(video.volume).toBe(0.3)
|
||||
|
||||
// Drag via document mousemove (registered in inline handler)
|
||||
fireEvent.mouseMove(document, { clientX: 60 })
|
||||
expect(video.volume).toBe(0.6)
|
||||
|
||||
// MouseUp cleans up the listeners
|
||||
fireEvent.mouseUp(document)
|
||||
|
||||
// After mouseUp, further moves should not affect volume
|
||||
fireEvent.mouseMove(document, { clientX: 10 })
|
||||
expect(video.volume).toBe(0.6)
|
||||
})
|
||||
|
||||
it('should clamp volume slider to max 1', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const volumeSlider = screen.getByTestId('video-volume-slider')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(volumeSlider)
|
||||
|
||||
// Click beyond slider range — should clamp to 1
|
||||
fireEvent.click(volumeSlider, { clientX: 200 })
|
||||
expect(video.volume).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle global mouse move when not dragging (no-op)', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
// Global mouse move without any drag — should not change anything
|
||||
fireEvent.mouseMove(document, { clientX: 50 })
|
||||
expect(video.currentTime).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { CrawlOptions } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Options from '../options'
|
||||
|
||||
// Test Data Factory
|
||||
@ -104,38 +103,28 @@ describe('Options', () => {
|
||||
describe('Props Display', () => {
|
||||
it('should display crawl_sub_pages checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// First checkbox should have check icon when checked
|
||||
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('check-icon-crawl-sub-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display crawl_sub_pages checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// First checkbox should not have check icon when unchecked
|
||||
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.queryByTestId('check-icon-crawl-sub-page')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// Second checkbox should have check icon when checked
|
||||
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.getByTestId('check-icon-only-main-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// Second checkbox should not have check icon when unchecked
|
||||
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.queryByTestId('check-icon-only-main-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display limit value in input', () => {
|
||||
|
||||
@ -32,9 +32,10 @@ const Options: FC<Props> = ({
|
||||
}
|
||||
}, [payload, onChange])
|
||||
return (
|
||||
<div className={cn(className, ' space-y-2')}>
|
||||
<div className={cn(className, 'space-y-2')}>
|
||||
<CheckboxWithLabel
|
||||
label={t(`${I18N_PREFIX}.crawlSubPage`, { ns: 'datasetCreation' })}
|
||||
testId="crawl-sub-page"
|
||||
isChecked={payload.crawl_sub_pages}
|
||||
onChange={handleChange('crawl_sub_pages')}
|
||||
labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary"
|
||||
@ -76,6 +77,7 @@ const Options: FC<Props> = ({
|
||||
</div>
|
||||
<CheckboxWithLabel
|
||||
label={t(`${I18N_PREFIX}.extractOnlyMainContent`, { ns: 'datasetCreation' })}
|
||||
testId="only-main-content"
|
||||
isChecked={payload.only_main_content}
|
||||
onChange={handleChange('only_main_content')}
|
||||
labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary"
|
||||
|
||||
@ -70,34 +70,26 @@ describe('Options (jina-reader)', () => {
|
||||
describe('Props Display', () => {
|
||||
it('should display crawl_sub_pages checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.getByTestId('check-icon-crawl-sub-pages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display crawl_sub_pages checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.queryByTestId('check-icon-crawl-sub-pages')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display use_sitemap checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ use_sitemap: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.getByTestId('check-icon-use-sitemap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display use_sitemap checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ use_sitemap: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.queryByTestId('check-icon-use-sitemap')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display limit value in input', () => {
|
||||
@ -111,10 +103,9 @@ describe('Options (jina-reader)', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[0])
|
||||
fireEvent.click(screen.getByTestId('checkbox-crawl-sub-pages'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
@ -124,10 +115,9 @@ describe('Options (jina-reader)', () => {
|
||||
|
||||
it('should call onChange with updated use_sitemap when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ use_sitemap: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[1])
|
||||
fireEvent.click(screen.getByTestId('checkbox-use-sitemap'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
|
||||
@ -87,10 +87,9 @@ describe('Options (watercrawl)', () => {
|
||||
describe('Props Display', () => {
|
||||
it('should display crawl_sub_pages checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('check-icon-crawl-sub-pages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display crawl_sub_pages checkbox without check icon when false', () => {
|
||||
@ -103,10 +102,8 @@ describe('Options (watercrawl)', () => {
|
||||
|
||||
it('should display only_main_content checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
expect(screen.getByTestId('check-icon-only-main-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox without check icon when false', () => {
|
||||
|
||||
@ -175,12 +175,11 @@ describe('DocumentList', () => {
|
||||
...defaultProps,
|
||||
selectedIds: ['doc-1', 'doc-2', 'doc-3'],
|
||||
}
|
||||
const { container } = render(<DocumentList {...props} />, { wrapper: createWrapper() })
|
||||
render(<DocumentList {...props} />, { wrapper: createWrapper() })
|
||||
|
||||
const checkboxes = findCheckboxes(container)
|
||||
// When checked, checkbox should have a check icon (svg) inside
|
||||
checkboxes.forEach((checkbox) => {
|
||||
const checkIcon = checkbox.querySelector('svg')
|
||||
props.selectedIds.forEach((id) => {
|
||||
const checkIcon = screen.getByTestId(`check-icon-doc-row-${id}`)
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -126,20 +126,16 @@ describe('DocumentTableRow', () => {
|
||||
describe('Selection', () => {
|
||||
it('should show check icon when isSelected is true', () => {
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} isSelected />, { wrapper: createWrapper() })
|
||||
// When selected, the checkbox should have a check icon (RiCheckLine svg)
|
||||
const checkbox = findCheckbox(container)
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
const checkIcon = checkbox?.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
expect(screen.getByTestId('check-icon-doc-row-doc-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show check icon when isSelected is false', () => {
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} isSelected={false} />, { wrapper: createWrapper() })
|
||||
const checkbox = findCheckbox(container)
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
// When not selected, there should be no check icon inside the checkbox
|
||||
const checkIcon = checkbox?.querySelector('svg')
|
||||
expect(checkIcon).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('check-icon-doc-row-doc-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelectOne when checkbox is clicked', () => {
|
||||
|
||||
@ -91,6 +91,7 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
className="mr-2 shrink-0"
|
||||
checked={isSelected}
|
||||
onCheck={() => onSelectOne(doc.id)}
|
||||
id={`doc-row-${doc.id}`}
|
||||
/>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
@ -42,7 +42,7 @@ const Item = ({
|
||||
}
|
||||
: {}
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
onSelect(file)
|
||||
}, [file, onSelect])
|
||||
@ -91,13 +91,13 @@ const Item = ({
|
||||
>
|
||||
<FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" />
|
||||
<span
|
||||
className="system-sm-medium grow truncate text-text-secondary"
|
||||
className="grow truncate text-text-secondary system-sm-medium"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{!isFolder && typeof size === 'number' && (
|
||||
<span className="system-xs-regular shrink-0 text-text-tertiary">{formatFileSize(size)}</span>
|
||||
<span className="shrink-0 text-text-tertiary system-xs-regular">{formatFileSize(size)}</span>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
|
||||
@ -84,7 +84,7 @@ vi.mock('../../metadata-dataset/select-metadata-modal', () => ({
|
||||
<div data-testid="select-modal">
|
||||
{trigger}
|
||||
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
|
||||
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
|
||||
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => { })}>Save</button>
|
||||
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
@ -202,7 +202,7 @@ describe('EditMetadataBatchModal', () => {
|
||||
if (checkboxContainer) {
|
||||
fireEvent.click(checkboxContainer)
|
||||
await waitFor(() => {
|
||||
const checkIcon = checkboxContainer.querySelector('svg')
|
||||
const checkIcon = screen.getByTestId('check-icon-apply-to-all')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
@ -118,7 +118,7 @@ const EditMetadataBatchModal: FC<Props> = ({
|
||||
onClose={onHide}
|
||||
className="!max-w-[640px]"
|
||||
>
|
||||
<div className="system-xs-medium mt-1 text-text-accent">{t(`${i18nPrefix}.editDocumentsNum`, { ns: 'dataset', num: documentNum })}</div>
|
||||
<div className="mt-1 text-text-accent system-xs-medium">{t(`${i18nPrefix}.editDocumentsNum`, { ns: 'dataset', num: documentNum })}</div>
|
||||
<div className="max-h-[305px] overflow-y-auto overflow-x-hidden">
|
||||
<div className="mt-4 space-y-2">
|
||||
{templeList.map(item => (
|
||||
@ -133,7 +133,7 @@ const EditMetadataBatchModal: FC<Props> = ({
|
||||
</div>
|
||||
<div className="mt-4 pl-[18px]">
|
||||
<div className="flex items-center">
|
||||
<div className="system-xs-medium-uppercase mr-2 shrink-0 text-text-tertiary">{t('metadata.createMetadata.title', { ns: 'dataset' })}</div>
|
||||
<div className="mr-2 shrink-0 text-text-tertiary system-xs-medium-uppercase">{t('metadata.createMetadata.title', { ns: 'dataset' })}</div>
|
||||
<Divider bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
@ -164,8 +164,8 @@ const EditMetadataBatchModal: FC<Props> = ({
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex select-none items-center">
|
||||
<Checkbox checked={isApplyToAllSelectDocument} onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} />
|
||||
<div className="system-xs-medium ml-2 mr-1 text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div>
|
||||
<Checkbox checked={isApplyToAllSelectDocument} onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)} id="apply-to-all" />
|
||||
<div className="ml-2 mr-1 text-text-secondary system-xs-medium">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div>
|
||||
<Tooltip popupContent={
|
||||
<div className="max-w-[240px]">{t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}</div>
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ const VariableLabel = ({
|
||||
)}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
{...(isExceptionVariable ? { 'data-testid': 'exception-variable' } : {})}
|
||||
>
|
||||
{isShowNodeLabel && (
|
||||
<VariableNodeLabel
|
||||
@ -47,7 +48,7 @@ const VariableLabel = ({
|
||||
notShowFullPath && (
|
||||
<>
|
||||
<RiMoreLine className="h-3 w-3 shrink-0 text-text-secondary" />
|
||||
<div className="system-xs-regular shrink-0 text-divider-deep">/</div>
|
||||
<div className="shrink-0 text-divider-deep system-xs-regular">/</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -62,7 +63,7 @@ const VariableLabel = ({
|
||||
/>
|
||||
{
|
||||
!!variableType && (
|
||||
<div className="system-xs-regular shrink-0 text-text-tertiary">
|
||||
<div className="shrink-0 text-text-tertiary system-xs-regular">
|
||||
{capitalize(variableType)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1923,15 +1923,9 @@
|
||||
"app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 5
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
@ -2089,9 +2083,6 @@
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
@ -2253,11 +2244,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/input-with-copy/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/input/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 2
|
||||
@ -3272,9 +3258,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/datasets/create/website/firecrawl/options.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3454,9 +3437,6 @@
|
||||
"app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/datasets/documents/create-from-pipeline/data-source/online-drive/header.tsx": {
|
||||
@ -4032,9 +4012,6 @@
|
||||
"app/components/datasets/metadata/edit-metadata-batch/modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": {
|
||||
@ -7134,9 +7111,6 @@
|
||||
"app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-name.tsx": {
|
||||
|
||||
Reference in New Issue
Block a user