diff --git a/frontend/src/components/A2UIRenderer.tsx b/frontend/src/components/A2UIRenderer.tsx new file mode 100644 index 0000000..34d5b5e --- /dev/null +++ b/frontend/src/components/A2UIRenderer.tsx @@ -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) => void +} + +// ─── Filters ───────────────────────────────────────────────────────────────── + +function FiltersRenderer({ block, onAction }: A2UIRendererProps & { block: Extract }) { + const [activeLabels, setActiveLabels] = useState>(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 ( +
+ + {block.filters.map(option => { + const active = activeLabels.has(option.label) + return ( + + ) + })} +
+ ) +} + +// ─── Table ─────────────────────────────────────────────────────────────────── + +function TableRenderer({ block }: { block: Extract }) { + return ( +
+
+ + Data Table +
+ + + + {block.headers.map((header, i) => ( + + ))} + + + + {block.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+ ) +} + +// ─── Cards ─────────────────────────────────────────────────────────────────── + +function CardsRenderer({ block }: { block: Extract }) { + return ( +
+ {block.items.map((item, i) => ( +
window.open(item.url, '_blank') : undefined} + > +
{item.title}
+ {item.price != null && ( +
{item.price}
+ )} + {item.description && ( +
{item.description}
+ )} +
+ ))} +
+ ) +} + +// ─── Form ──────────────────────────────────────────────────────────────────── + +function FormRenderer({ + block, + onAction, +}: { + block: Extract + onAction?: (action: string, data: Record) => void +}) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const formData = new FormData(e.currentTarget) + const data: Record = {} + 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 ( +
+
+ + Input Form +
+
+ {block.fields.map(field => ( +
+ + {field.type === 'select' ? ( + + ) : ( + + )} +
+ ))} + +
+
+ ) +} + +// ─── Main Renderer ─────────────────────────────────────────────────────────── + +export function A2UIRenderer({ block, onAction }: A2UIRendererProps) { + switch (block.type) { + case 'filters': + return + case 'table': + return + case 'cards': + return + case 'form': + return + 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 +} diff --git a/frontend/src/test/A2UIRenderer.test.tsx b/frontend/src/test/A2UIRenderer.test.tsx new file mode 100644 index 0000000..146103f --- /dev/null +++ b/frontend/src/test/A2UIRenderer.test.tsx @@ -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 }) => ( + + ), + IconTable: ({ size, color }: { size?: number; color?: string }) => ( + + ), + IconCards: ({ size, color }: { size?: number; color?: string }) => ( + + ), + IconForms: ({ size, color }: { size?: number; color?: string }) => ( + + ), +})) + +// ─── 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() + expect(container.querySelector('.tool-call-card')).toBeTruthy() + }) + + it('renders the IconFilter icon', () => { + render() + expect(screen.getByTestId('icon-filter')).toBeTruthy() + }) + + it('renders a button for each filter option', () => { + const { container } = render() + const buttons = container.querySelectorAll('button') + expect(buttons).toHaveLength(2) + }) + + it('renders filter label text in buttons', () => { + render() + 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() + 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() + 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() + 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() + expect(container.querySelector('.tool-call-card')).toBeTruthy() + }) + + it('renders the IconTable icon', () => { + render() + expect(screen.getByTestId('icon-table')).toBeTruthy() + }) + + it('renders "Data Table" header label', () => { + render() + expect(screen.getByText('Data Table')).toBeTruthy() + }) + + it('renders a element', () => { + const { container } = render() + expect(container.querySelector('table')).toBeTruthy() + }) + + it('renders all column headers', () => { + render() + expect(screen.getByText('Name')).toBeTruthy() + expect(screen.getByText('Price')).toBeTruthy() + expect(screen.getByText('Stock')).toBeTruthy() + }) + + it('renders all row data', () => { + render() + 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() + expect(container.querySelectorAll('th')).toHaveLength(3) + }) + + it('renders correct number of td elements', () => { + const { container } = render() + // 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() + const cards = container.querySelectorAll('.tool-call-card') + expect(cards.length).toBeGreaterThanOrEqual(2) + }) + + it('renders each card title', () => { + render() + expect(screen.getByText('Widget Pro')).toBeTruthy() + expect(screen.getByText('Gadget Basic')).toBeTruthy() + }) + + it('renders price when present', () => { + render() + expect(screen.getByText('$29.99')).toBeTruthy() + }) + + it('renders description when present', () => { + render() + 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() + 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() + // 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() + expect(container.querySelector('.tool-call-card')).toBeTruthy() + }) + + it('renders the IconForms icon', () => { + render() + expect(screen.getByTestId('icon-forms')).toBeTruthy() + }) + + it('renders "Input Form" header label', () => { + render() + expect(screen.getByText('Input Form')).toBeTruthy() + }) + + it('renders a
element', () => { + const { container } = render() + expect(container.querySelector('form')).toBeTruthy() + }) + + it('renders labels for each field', () => { + render() + 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() + const textInput = container.querySelector('input[type="text"]') + expect(textInput).toBeTruthy() + }) + + it('renders number input for number field type', () => { + const { container } = render() + const numInput = container.querySelector('input[type="number"]') + expect(numInput).toBeTruthy() + }) + + it('renders a