feat(frontend): add QueryInput and KeywordsDisplay components with tests

QueryInput: textarea with submit button, loading state, Enter-to-submit, clears on submit.

KeywordsDisplay: keyword chips with loading skeletons, animated entrance.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Woody 2026-04-23 11:23:08 +08:00
parent 3923e20d8a
commit fa94b7c9a3
4 changed files with 288 additions and 0 deletions

View File

@ -0,0 +1,46 @@
import React from 'react'
export interface KeywordsDisplayProps {
keywords?: string[]
isLoading: boolean
}
export const KeywordsDisplay: React.FC<KeywordsDisplayProps> = ({ keywords, isLoading }) => {
if (!isLoading && (!keywords || keywords.length === 0)) {
return null
}
if (isLoading) {
return (
<div className="space-y-2">
<span className="text-xs text-gray-500 uppercase tracking-wide">Extracted Keywords:</span>
<div className="flex flex-wrap gap-2">
{[1, 2, 3].map((i) => (
<div
key={i}
data-testid="keyword-skeleton"
className="rounded-full px-3 py-1 bg-gray-200 animate-pulse w-16 h-6"
/>
))}
</div>
</div>
)
}
return (
<div className="space-y-2">
<span className="text-xs text-gray-500 uppercase tracking-wide">Extracted Keywords:</span>
<div data-testid="keywords-container" className="flex flex-wrap">
{keywords?.map((keyword, index) => (
<span
key={index}
role="listitem"
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-blue-100 text-blue-800 mr-2 mb-2"
>
{keyword}
</span>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,49 @@
import React, { useState, type FormEvent, type KeyboardEvent } from 'react'
export interface QueryInputProps {
onSubmit: (question: string) => void
isLoading: boolean
}
export const QueryInput: React.FC<QueryInputProps> = ({ onSubmit, isLoading }) => {
const [question, setQuestion] = useState<string>('')
const handleSubmit = (e: FormEvent): void => {
e.preventDefault()
const trimmed = question.trim()
if (trimmed && !isLoading) {
onSubmit(trimmed)
setQuestion('')
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
const isDisabled = isLoading || question.trim() === ''
return (
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question about your documents..."
disabled={isLoading}
rows={3}
className="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={isDisabled}
className="px-4 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
>
{isLoading ? 'Loading...' : 'Submit'}
</button>
</form>
)
}

View File

@ -0,0 +1,79 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { KeywordsDisplay } from '../../components/KeywordsDisplay'
describe('KeywordsDisplay', () => {
it('returns null when keywords empty and not loading', () => {
const { container } = render(<KeywordsDisplay keywords={[]} isLoading={false} />)
expect(container).toBeEmptyDOMElement()
})
it('returns null when keywords is undefined and not loading', () => {
const { container } = render(<KeywordsDisplay keywords={undefined} isLoading={false} />)
expect(container).toBeEmptyDOMElement()
})
it('shows loading skeletons when isLoading is true', () => {
render(<KeywordsDisplay keywords={[]} isLoading={true} />)
// Check for 3 animated placeholder pills
const skeletons = screen.getAllByTestId('keyword-skeleton')
expect(skeletons).toHaveLength(3)
// Check that they have the animate-pulse class
skeletons.forEach((skeleton) => {
expect(skeleton).toHaveClass('animate-pulse')
})
})
it('renders keyword chips when keywords provided', () => {
const keywords = ['RAG', 'retrieval', 'question answering']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
// Check that all keywords are rendered
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.getByText('retrieval')).toBeInTheDocument()
expect(screen.getByText('question answering')).toBeInTheDocument()
// Check that chips have the correct styling
const chips = screen.getAllByRole('listitem')
chips.forEach((chip) => {
expect(chip).toHaveClass('inline-block', 'rounded-full', 'px-3', 'py-1', 'text-sm', 'font-medium', 'bg-blue-100', 'text-blue-800')
})
})
it('shows "Extracted Keywords" label', () => {
const keywords = ['test']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
const label = screen.getByText(/extracted keywords:/i)
expect(label).toBeInTheDocument()
expect(label).toHaveClass('text-xs', 'text-gray-500', 'uppercase', 'tracking-wide')
})
it('renders chips in a flex container with flex-wrap', () => {
const keywords = ['keyword1', 'keyword2', 'keyword3']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
const container = screen.getByTestId('keywords-container')
expect(container).toHaveClass('flex', 'flex-wrap')
})
it('renders each keyword chip with mr-2 mb-2 spacing', () => {
const keywords = ['keyword1', 'keyword2']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
const chips = screen.getAllByRole('listitem')
chips.forEach((chip) => {
expect(chip).toHaveClass('mr-2', 'mb-2')
})
})
it('does not render loading state when keywords are provided', () => {
const keywords = ['test']
render(<KeywordsDisplay keywords={keywords} isLoading={false} />)
const skeletons = screen.queryAllByTestId('keyword-skeleton')
expect(skeletons).toHaveLength(0)
})
})

View File

@ -0,0 +1,114 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { QueryInput } from '../../components/QueryInput'
describe('QueryInput', () => {
const mockOnSubmit = vi.fn()
beforeEach(() => {
mockOnSubmit.mockClear()
})
it('renders textarea with placeholder text', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
expect(textarea).toBeInTheDocument()
})
it('renders submit button', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const button = screen.getByRole('button', { name: /submit/i })
expect(button).toBeInTheDocument()
})
it('button is disabled when textarea is empty', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const button = screen.getByRole('button', { name: /submit/i })
expect(button).toBeDisabled()
})
it('button is disabled when textarea contains only whitespace', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
const button = screen.getByRole('button', { name: /submit/i })
fireEvent.change(textarea, { target: { value: ' ' } })
expect(button).toBeDisabled()
})
it('button is disabled when isLoading is true', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />)
const button = screen.getByRole('button', { name: /loading/i })
expect(button).toBeDisabled()
})
it('textarea is disabled when isLoading is true', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
expect(textarea).toBeDisabled()
})
it('button is disabled when isLoading is true even with text', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={true} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
const button = screen.getByRole('button', { name: /loading/i })
fireEvent.change(textarea, { target: { value: 'Some question' } })
expect(button).toBeDisabled()
})
it('calls onSubmit with trimmed text when button is clicked', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
const button = screen.getByRole('button', { name: /submit/i })
fireEvent.change(textarea, { target: { value: ' Test question ' } })
fireEvent.click(button)
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
expect(mockOnSubmit).toHaveBeenCalledWith('Test question')
})
it('clears textarea after submit', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
const button = screen.getByRole('button', { name: /submit/i })
fireEvent.change(textarea, { target: { value: 'Test question' } })
fireEvent.click(button)
expect(textarea).toHaveValue('')
})
it('calls onSubmit when pressing Enter without Shift', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
fireEvent.change(textarea, { target: { value: 'Test question' } })
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false })
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
expect(mockOnSubmit).toHaveBeenCalledWith('Test question')
})
it('does not call onSubmit when pressing Enter with Shift', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
fireEvent.change(textarea, { target: { value: 'Test question' } })
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it('does not submit when textarea is empty and Enter is pressed', () => {
render(<QueryInput onSubmit={mockOnSubmit} isLoading={false} />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false })
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})