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:
parent
3923e20d8a
commit
fa94b7c9a3
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue