feat: A2UI renderer for filters, tables, cards, forms
This commit is contained in:
@@ -0,0 +1,252 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { IconFilter, IconTable, IconCards, IconForms } from '@tabler/icons-react'
|
||||||
|
import type { A2UIBlock } from '../lib/types'
|
||||||
|
|
||||||
|
interface A2UIRendererProps {
|
||||||
|
block: A2UIBlock
|
||||||
|
onAction?: (action: string, data: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filters ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FiltersRenderer({ block, onAction }: A2UIRendererProps & { block: Extract<A2UIBlock, { type: 'filters' }> }) {
|
||||||
|
const [activeLabels, setActiveLabels] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
function handleToggle(label: string) {
|
||||||
|
const isActive = activeLabels.has(label)
|
||||||
|
setActiveLabels(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (isActive) next.delete(label)
|
||||||
|
else next.add(label)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
onAction?.('filter_toggle', { label, active: !isActive })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='tool-call-card'
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<IconFilter size={16} color='#7aa2f7' style={{ marginRight: 4 }} />
|
||||||
|
{block.filters.map(option => {
|
||||||
|
const active = activeLabels.has(option.label)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleToggle(option.label)}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: active ? '#5a7aba' : '#3a3a5a',
|
||||||
|
background: active ? '#2a3a6a' : 'transparent',
|
||||||
|
color: active ? '#ffffff' : '#8888aa',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TableRenderer({ block }: { block: Extract<A2UIBlock, { type: 'table' }> }) {
|
||||||
|
return (
|
||||||
|
<div className='tool-call-card' style={{ overflowX: 'auto' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
|
||||||
|
<IconTable size={16} color='#7aa2f7' />
|
||||||
|
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Data Table</span>
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{block.headers.map((header, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '0.3rem 0.5rem',
|
||||||
|
borderBottom: '1px solid #2a2a4a',
|
||||||
|
color: '#aaaacc',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
{row.map((cell, ci) => (
|
||||||
|
<td
|
||||||
|
key={ci}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.5rem',
|
||||||
|
borderBottom: '1px solid #1a1a2e',
|
||||||
|
color: '#c0c0d0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cards ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CardsRenderer({ block }: { block: Extract<A2UIBlock, { type: 'cards' }> }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gap: '0.5rem',
|
||||||
|
margin: '0.5rem 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className='tool-call-card'
|
||||||
|
style={{ cursor: item.url ? 'pointer' : undefined }}
|
||||||
|
onClick={item.url ? () => window.open(item.url, '_blank') : undefined}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: '#e0e0e0' }}>{item.title}</div>
|
||||||
|
{item.price != null && (
|
||||||
|
<div style={{ color: '#7af7a2', fontWeight: 600, fontSize: '0.9rem' }}>{item.price}</div>
|
||||||
|
)}
|
||||||
|
{item.description && (
|
||||||
|
<div style={{ color: '#8888aa', fontSize: '0.75rem', marginTop: 4 }}>{item.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FormRenderer({
|
||||||
|
block,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
block: Extract<A2UIBlock, { type: 'form' }>
|
||||||
|
onAction?: (action: string, data: Record<string, unknown>) => void
|
||||||
|
}) {
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
data[key] = value
|
||||||
|
})
|
||||||
|
onAction?.('form_submit', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.3rem',
|
||||||
|
background: '#1e2a4a',
|
||||||
|
border: '1px solid #3a3a5a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='tool-call-card'>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
|
||||||
|
<IconForms size={16} color='#7aa2f7' />
|
||||||
|
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Input Form</span>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
|
{block.fields.map(field => (
|
||||||
|
<div key={field.name}>
|
||||||
|
<label style={{ fontSize: '0.75rem', color: '#8888aa', display: 'block' }}>
|
||||||
|
{field.label || field.name}
|
||||||
|
</label>
|
||||||
|
{field.type === 'select' ? (
|
||||||
|
<select name={field.name} style={inputStyle}>
|
||||||
|
{field.options?.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input name={field.name} type={field.type} style={inputStyle} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.8rem',
|
||||||
|
background: '#3a5aba',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
marginTop: '0.3rem',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.submitLabel || 'Submit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Renderer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function A2UIRenderer({ block, onAction }: A2UIRendererProps) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'filters':
|
||||||
|
return <FiltersRenderer block={block} onAction={onAction} />
|
||||||
|
case 'table':
|
||||||
|
return <TableRenderer block={block} />
|
||||||
|
case 'cards':
|
||||||
|
return <CardsRenderer block={block} />
|
||||||
|
case 'form':
|
||||||
|
return <FormRenderer block={block} onAction={onAction} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── parseA2UIBlocks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function parseA2UIBlocks(content: string): A2UIBlock[] {
|
||||||
|
const blocks: A2UIBlock[] = []
|
||||||
|
const regex = /```a2ui\s*\n([\s\S]*?)```/g
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[1])
|
||||||
|
if (parsed && parsed.type) {
|
||||||
|
blocks.push(parsed as A2UIBlock)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { A2UIRenderer, parseA2UIBlocks } from '../components/A2UIRenderer'
|
||||||
|
import type { A2UIBlock } from '../lib/types'
|
||||||
|
|
||||||
|
// Mock @tabler/icons-react so icon SVGs don't cause jsdom issues
|
||||||
|
vi.mock('@tabler/icons-react', () => ({
|
||||||
|
IconFilter: ({ size, color }: { size?: number; color?: string }) => (
|
||||||
|
<span data-testid='icon-filter' data-size={size} data-color={color} />
|
||||||
|
),
|
||||||
|
IconTable: ({ size, color }: { size?: number; color?: string }) => (
|
||||||
|
<span data-testid='icon-table' data-size={size} data-color={color} />
|
||||||
|
),
|
||||||
|
IconCards: ({ size, color }: { size?: number; color?: string }) => (
|
||||||
|
<span data-testid='icon-cards' data-size={size} data-color={color} />
|
||||||
|
),
|
||||||
|
IconForms: ({ size, color }: { size?: number; color?: string }) => (
|
||||||
|
<span data-testid='icon-forms' data-size={size} data-color={color} />
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ─── parseA2UIBlocks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseA2UIBlocks', () => {
|
||||||
|
it('extracts a single a2ui block from content', () => {
|
||||||
|
const content = '```a2ui\n{"type":"filters","filters":[]}\n```'
|
||||||
|
const blocks = parseA2UIBlocks(content)
|
||||||
|
expect(blocks).toHaveLength(1)
|
||||||
|
expect(blocks[0].type).toBe('filters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts multiple a2ui blocks from content', () => {
|
||||||
|
const content = [
|
||||||
|
'```a2ui\n{"type":"filters","filters":[]}\n```',
|
||||||
|
' some text ',
|
||||||
|
'```a2ui\n{"type":"table","headers":[],"rows":[]}\n```',
|
||||||
|
].join('')
|
||||||
|
const blocks = parseA2UIBlocks(content)
|
||||||
|
expect(blocks).toHaveLength(2)
|
||||||
|
expect(blocks[0].type).toBe('filters')
|
||||||
|
expect(blocks[1].type).toBe('table')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently skips invalid JSON', () => {
|
||||||
|
const content = '```a2ui\nnot valid json\n```'
|
||||||
|
const blocks = parseA2UIBlocks(content)
|
||||||
|
expect(blocks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips blocks without a type field', () => {
|
||||||
|
const content = '```a2ui\n{"data":"no type"}\n```'
|
||||||
|
const blocks = parseA2UIBlocks(content)
|
||||||
|
expect(blocks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no a2ui blocks present', () => {
|
||||||
|
const content = 'plain text with ```js\nsome code\n```'
|
||||||
|
const blocks = parseA2UIBlocks(content)
|
||||||
|
expect(blocks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the parsed block as A2UIBlock with all fields', () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
type: 'table',
|
||||||
|
headers: ['Name', 'Price'],
|
||||||
|
rows: [['Apple', '$1']],
|
||||||
|
})
|
||||||
|
const content = `\`\`\`a2ui\n${json}\n\`\`\``
|
||||||
|
const blocks = parseA2UIBlocks(content)
|
||||||
|
const block = blocks[0] as import('../lib/types').A2UITableBlock
|
||||||
|
expect(block.headers).toEqual(['Name', 'Price'])
|
||||||
|
expect(block.rows).toEqual([['Apple', '$1']])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── A2UIRenderer — filters ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('A2UIRenderer — filters', () => {
|
||||||
|
const filtersBlock: A2UIBlock = {
|
||||||
|
type: 'filters',
|
||||||
|
filters: [
|
||||||
|
{ label: 'In Stock', value: 'in_stock' },
|
||||||
|
{ label: 'On Sale', value: 'on_sale' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders inside a tool-call-card container', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={filtersBlock} />)
|
||||||
|
expect(container.querySelector('.tool-call-card')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the IconFilter icon', () => {
|
||||||
|
render(<A2UIRenderer block={filtersBlock} />)
|
||||||
|
expect(screen.getByTestId('icon-filter')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a button for each filter option', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={filtersBlock} />)
|
||||||
|
const buttons = container.querySelectorAll('button')
|
||||||
|
expect(buttons).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders filter label text in buttons', () => {
|
||||||
|
render(<A2UIRenderer block={filtersBlock} />)
|
||||||
|
expect(screen.getByText('In Stock')).toBeTruthy()
|
||||||
|
expect(screen.getByText('On Sale')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAction with filter_toggle and label when button clicked', () => {
|
||||||
|
const onAction = vi.fn()
|
||||||
|
render(<A2UIRenderer block={filtersBlock} onAction={onAction} />)
|
||||||
|
fireEvent.click(screen.getByText('In Stock'))
|
||||||
|
expect(onAction).toHaveBeenCalledWith('filter_toggle', {
|
||||||
|
label: 'In Stock',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles active state on second click (active: false)', () => {
|
||||||
|
const onAction = vi.fn()
|
||||||
|
render(<A2UIRenderer block={filtersBlock} onAction={onAction} />)
|
||||||
|
fireEvent.click(screen.getByText('In Stock'))
|
||||||
|
fireEvent.click(screen.getByText('In Stock'))
|
||||||
|
expect(onAction).toHaveBeenLastCalledWith('filter_toggle', {
|
||||||
|
label: 'In Stock',
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw when onAction is not provided', () => {
|
||||||
|
render(<A2UIRenderer block={filtersBlock} />)
|
||||||
|
expect(() => fireEvent.click(screen.getByText('In Stock'))).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── A2UIRenderer — table ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('A2UIRenderer — table', () => {
|
||||||
|
const tableBlock: A2UIBlock = {
|
||||||
|
type: 'table',
|
||||||
|
headers: ['Name', 'Price', 'Stock'],
|
||||||
|
rows: [
|
||||||
|
['Apple', '$1.00', '50'],
|
||||||
|
['Banana', '$0.50', '100'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders inside a tool-call-card container', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(container.querySelector('.tool-call-card')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the IconTable icon', () => {
|
||||||
|
render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(screen.getByTestId('icon-table')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "Data Table" header label', () => {
|
||||||
|
render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(screen.getByText('Data Table')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a <table> element', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(container.querySelector('table')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all column headers', () => {
|
||||||
|
render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(screen.getByText('Name')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Price')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Stock')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all row data', () => {
|
||||||
|
render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(screen.getByText('Apple')).toBeTruthy()
|
||||||
|
expect(screen.getByText('$1.00')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Banana')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders correct number of th elements', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
expect(container.querySelectorAll('th')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders correct number of td elements', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={tableBlock} />)
|
||||||
|
// 2 rows × 3 cols = 6 td
|
||||||
|
expect(container.querySelectorAll('td')).toHaveLength(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── A2UIRenderer — cards ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('A2UIRenderer — cards', () => {
|
||||||
|
const cardsBlock: A2UIBlock = {
|
||||||
|
type: 'cards',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Widget Pro',
|
||||||
|
description: 'Best widget ever',
|
||||||
|
price: '$29.99',
|
||||||
|
url: 'https://example.com/widget',
|
||||||
|
},
|
||||||
|
{ title: 'Gadget Basic', description: 'Simple gadget' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders a grid container with multiple tool-call-card elements', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={cardsBlock} />)
|
||||||
|
const cards = container.querySelectorAll('.tool-call-card')
|
||||||
|
expect(cards.length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders each card title', () => {
|
||||||
|
render(<A2UIRenderer block={cardsBlock} />)
|
||||||
|
expect(screen.getByText('Widget Pro')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Gadget Basic')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders price when present', () => {
|
||||||
|
render(<A2UIRenderer block={cardsBlock} />)
|
||||||
|
expect(screen.getByText('$29.99')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders description when present', () => {
|
||||||
|
render(<A2UIRenderer block={cardsBlock} />)
|
||||||
|
expect(screen.getByText('Best widget ever')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens url in new tab on click when url is present', () => {
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
render(<A2UIRenderer block={cardsBlock} />)
|
||||||
|
fireEvent.click(screen.getByText('Widget Pro'))
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('https://example.com/widget', '_blank')
|
||||||
|
openSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not set cursor pointer on cards without url', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={cardsBlock} />)
|
||||||
|
// Find the card for "Gadget Basic" — the second tool-call-card
|
||||||
|
const cards = container.querySelectorAll('.tool-call-card')
|
||||||
|
const gadgetCard = Array.from(cards).find(
|
||||||
|
card => card.textContent?.includes('Gadget Basic')
|
||||||
|
)
|
||||||
|
expect(gadgetCard).toBeTruthy()
|
||||||
|
expect((gadgetCard as HTMLElement).style.cursor).not.toBe('pointer')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── A2UIRenderer — form ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('A2UIRenderer — form', () => {
|
||||||
|
const formBlock: A2UIBlock = {
|
||||||
|
type: 'form',
|
||||||
|
submitLabel: 'Search',
|
||||||
|
fields: [
|
||||||
|
{ name: 'query', type: 'text', label: 'Search Query' },
|
||||||
|
{ name: 'max_price', type: 'number', label: 'Max Price' },
|
||||||
|
{
|
||||||
|
name: 'category',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Category',
|
||||||
|
options: [
|
||||||
|
{ label: 'Electronics', value: 'electronics' },
|
||||||
|
{ label: 'Books', value: 'books' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders inside a tool-call-card container', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(container.querySelector('.tool-call-card')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the IconForms icon', () => {
|
||||||
|
render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(screen.getByTestId('icon-forms')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "Input Form" header label', () => {
|
||||||
|
render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(screen.getByText('Input Form')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a <form> element', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(container.querySelector('form')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders labels for each field', () => {
|
||||||
|
render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(screen.getByText('Search Query')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Max Price')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Category')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders text input for text field type', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={formBlock} />)
|
||||||
|
const textInput = container.querySelector('input[type="text"]')
|
||||||
|
expect(textInput).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders number input for number field type', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={formBlock} />)
|
||||||
|
const numInput = container.querySelector('input[type="number"]')
|
||||||
|
expect(numInput).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a <select> for select field type', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(container.querySelector('select')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders select options', () => {
|
||||||
|
render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(screen.getByText('Electronics')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Books')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders submit button with submitLabel text', () => {
|
||||||
|
render(<A2UIRenderer block={formBlock} />)
|
||||||
|
expect(screen.getByText('Search')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses "Submit" as default submit label when submitLabel absent', () => {
|
||||||
|
const blockWithoutLabel: A2UIBlock = {
|
||||||
|
type: 'form',
|
||||||
|
fields: [{ name: 'q', type: 'text' }],
|
||||||
|
submitLabel: '',
|
||||||
|
}
|
||||||
|
render(<A2UIRenderer block={blockWithoutLabel} />)
|
||||||
|
expect(screen.getByText('Submit')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses field.name as label when field.label is absent', () => {
|
||||||
|
const blockNoLabel: A2UIBlock = {
|
||||||
|
type: 'form',
|
||||||
|
fields: [{ name: 'my_field', type: 'text' }],
|
||||||
|
submitLabel: 'Go',
|
||||||
|
}
|
||||||
|
render(<A2UIRenderer block={blockNoLabel} />)
|
||||||
|
expect(screen.getByText('my_field')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAction with form_submit and form data on submit', () => {
|
||||||
|
const onAction = vi.fn()
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIRenderer block={formBlock} onAction={onAction} />
|
||||||
|
)
|
||||||
|
const form = container.querySelector('form')!
|
||||||
|
fireEvent.submit(form)
|
||||||
|
expect(onAction).toHaveBeenCalledWith(
|
||||||
|
'form_submit',
|
||||||
|
expect.any(Object)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents default form submission', () => {
|
||||||
|
const { container } = render(<A2UIRenderer block={formBlock} />)
|
||||||
|
const form = container.querySelector('form')!
|
||||||
|
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
|
||||||
|
form.dispatchEvent(submitEvent)
|
||||||
|
expect(submitEvent.defaultPrevented).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── A2UIRenderer — default / unknown type ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('A2UIRenderer — default', () => {
|
||||||
|
it('returns null for unknown block type', () => {
|
||||||
|
// Cast to bypass TypeScript — simulates runtime unknown type
|
||||||
|
const unknown = { type: 'unknown' } as unknown as A2UIBlock
|
||||||
|
const { container } = render(<A2UIRenderer block={unknown} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user