Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/render/__tests__/llm-image-output.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe.skipIf(!RUN)('LLM inline image output (E2E)', () => {
// src MUST be one of the fixture image URLs — no fabrication.
expect(fixtureSrcs.has(src)).toBe(true)
}
if (el.type === 'QuestionButton') {
if (el.type === 'Button') {
hasRelatedQuestions = true
}
}
Expand Down
148 changes: 148 additions & 0 deletions lib/render/__tests__/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { Spec } from '@json-render/core'
import { describe, expect, test } from 'vitest'

import { migrateSpec, type MigrationMap } from '../migrations'

// Synthetic fixture so the tests exercise the migration infrastructure
// without committing to any real catalog entries.
const fixtureMigrations: MigrationMap = {
LegacyHeading: { to: 'Heading' },
LegacyButton: {
to: 'Button',
defaultProps: { variant: 'link', icon: 'arrow-right' }
},
// Chained rename: IntermediateHeading was itself renamed to LegacyHeading
// at some point, and LegacyHeading is now Heading.
IntermediateHeading: { to: 'LegacyHeading' }
}

function makeLegacySpec(): Spec {
return {
root: 'main',
elements: {
main: {
type: 'Stack',
props: { direction: 'vertical' },
children: ['header', 'questions']
},
header: {
type: 'LegacyHeading',
props: { title: 'Related' },
children: []
},
questions: {
type: 'Stack',
props: { direction: 'vertical' },
children: ['q1']
},
q1: {
type: 'LegacyButton',
props: { text: 'First question' },
on: {
press: { action: 'submitQuery', params: { query: 'First question' } }
},
children: []
}
}
} as unknown as Spec
}

describe('migrateSpec', () => {
test('renames legacy type names to their current catalog names', () => {
const spec = makeLegacySpec()
const result = migrateSpec(spec, fixtureMigrations)

const elements = result.elements as Record<string, { type: string }>
expect(elements.header.type).toBe('Heading')
expect(elements.q1.type).toBe('Button')
// Untouched types stay the same.
expect(elements.main.type).toBe('Stack')
expect(elements.questions.type).toBe('Stack')
})

test('merges defaultProps without overriding existing props', () => {
const spec = makeLegacySpec()
const result = migrateSpec(spec, fixtureMigrations)
const q1 = (
result.elements as Record<string, { props: Record<string, unknown> }>
).q1

// Existing prop preserved.
expect(q1.props.text).toBe('First question')
// Default props applied.
expect(q1.props.variant).toBe('link')
expect(q1.props.icon).toBe('arrow-right')
})

test('does not override an existing prop that happens to match a default', () => {
const spec: Spec = {
root: 'q1',
elements: {
q1: {
type: 'LegacyButton',
props: { text: 'X', variant: 'outline' },
children: []
}
}
} as unknown as Spec

const result = migrateSpec(spec, fixtureMigrations)
const q1 = (
result.elements as Record<string, { props: Record<string, unknown> }>
).q1
// LegacyButton's default variant is "link" but the original spec
// already declared "outline" — that must win.
expect(q1.props.variant).toBe('outline')
// icon default still fills in because it was not provided.
expect(q1.props.icon).toBe('arrow-right')
})

test('is idempotent — running the migration twice yields the same result', () => {
const spec = makeLegacySpec()
const once = migrateSpec(spec, fixtureMigrations)
const twice = migrateSpec(once, fixtureMigrations)
expect(twice).toEqual(once)
})

test('resolves chained renames through to the final target', () => {
const spec: Spec = {
root: 'header',
elements: {
header: {
type: 'IntermediateHeading',
props: { title: 'Chained' },
children: []
}
}
} as unknown as Spec

const result = migrateSpec(spec, fixtureMigrations)
const header = (result.elements as Record<string, { type: string }>).header
expect(header.type).toBe('Heading')
})

test('returns the same object reference when no changes are needed', () => {
const spec: Spec = {
root: 'main',
elements: {
main: { type: 'Heading', props: { title: 'Up to date' }, children: [] }
}
} as unknown as Spec

const result = migrateSpec(spec, fixtureMigrations)
expect(result).toBe(spec)
})

test('leaves unknown types untouched so the renderer can report them', () => {
const spec: Spec = {
root: 'x',
elements: {
x: { type: 'TotallyUnknown', props: {}, children: [] }
}
} as unknown as Spec

const result = migrateSpec(spec, fixtureMigrations)
const x = (result.elements as Record<string, { type: string }>).x
expect(x.type).toBe('TotallyUnknown')
})
})
10 changes: 7 additions & 3 deletions lib/render/__tests__/parse-spec-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function buildRelatedQuestionsSource(questions: string[]): string {
]
questions.forEach((q, i) => {
lines.push(
`{"op":"add","path":"/elements/q${i + 1}","value":{"type":"QuestionButton","props":{"text":"${q}"},"on":{"press":{"action":"submitQuery","params":{"query":"${q}"}}},"children":[]}}`
`{"op":"add","path":"/elements/q${i + 1}","value":{"type":"Button","props":{"text":"${q}","variant":"link","icon":"arrow-right"},"on":{"press":{"action":"submitQuery","params":{"query":"${q}"}}},"children":[]}}`
)
})
return lines.join('\n')
Expand Down Expand Up @@ -93,8 +93,12 @@ describe('parseSpecBlock', () => {
const spec = parseSpecBlock(source)
expect(spec.root).toBe('main')
expect(Object.keys(spec.elements)).toHaveLength(4) // main + q1 + q2 + q3
expect(spec.elements['q1'].type).toBe('QuestionButton')
expect(spec.elements['q1'].props).toEqual({ text: 'Question 1' })
expect(spec.elements['q1'].type).toBe('Button')
expect(spec.elements['q1'].props).toMatchObject({
text: 'Question 1',
variant: 'link',
icon: 'arrow-right'
})
})

test('throws for invalid component types', () => {
Expand Down
19 changes: 13 additions & 6 deletions lib/render/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { z } from 'zod'

import { schema } from './schema'

const iconName = z.enum(['related', 'arrow-right']).optional()

export const catalog = schema.createCatalog({
components: {
SectionHeader: {
Heading: {
props: z.object({
title: z.string(),
icon: z.enum(['related']).optional()
icon: iconName
}),
description: 'A section heading label with optional icon'
description: 'A heading label with an optional icon'
},
Stack: {
props: z
Expand All @@ -21,11 +23,16 @@ export const catalog = schema.createCatalog({
description:
'A layout container that stacks children vertically or horizontally'
},
QuestionButton: {
Button: {
props: z.object({
text: z.string()
text: z.string(),
icon: iconName,
variant: z
.enum(['default', 'outline', 'ghost', 'link', 'secondary'])
.optional()
}),
description: 'A related follow-up question the user can click to ask'
description:
'A clickable button that emits a press action. Use variant="link" with icon="arrow-right" for inline follow-up suggestions.'
},
Grid: {
props: z.object({
Expand Down
39 changes: 39 additions & 0 deletions lib/render/components/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import type { ComponentFn } from '@json-render/react'

import { cn } from '@/lib/utils'

import { Button as UIButton } from '@/components/ui/button'

import { type CatalogType, iconMap } from './shared'

/**
* Generic button spec component. Renders the project's shadcn Button and
* emits a `press` action on click. Use variant="link" with an arrow icon
* for inline follow-up suggestions (the former QuestionButton look).
*/
export const Button: ComponentFn<CatalogType, 'Button'> = ({ props, on }) => {
const { text, icon, variant = 'link' } = props
const Icon = icon ? iconMap[icon] : null
const handle = on('press')

// For the link variant, tweak layout/color so it reads as an inline
// follow-up suggestion: muted color, left-aligned, wrappable text and
// zero padding. The shadcn link variant's hover underline is preserved.
const linkOverride =
variant === 'link'
? 'h-auto w-fit justify-start whitespace-normal text-left px-0 py-0.5 font-semibold text-accent-foreground/50 hover:text-accent-foreground/70'
: ''

return (
<UIButton
variant={variant}
onClick={handle.emit}
className={cn('gap-2', linkOverride)}
>
{Icon && <Icon className="size-4 shrink-0" />}
{text}
</UIButton>
)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
'use client'

import type { ComponentFn } from '@json-render/react'
import { Repeat2 } from 'lucide-react'

import type { CatalogType } from './shared'
import { type CatalogType, iconMap } from './shared'

const iconMap = {
related: Repeat2
} as const

export const SectionHeader: ComponentFn<CatalogType, 'SectionHeader'> = ({
props
}) => {
export const Heading: ComponentFn<CatalogType, 'Heading'> = ({ props }) => {
const Icon = props.icon ? iconMap[props.icon] : null
return (
<div className="flex items-center gap-2 text-lg font-bold text-foreground">
Expand Down
25 changes: 0 additions & 25 deletions lib/render/components/question-button.tsx

This file was deleted.

13 changes: 13 additions & 0 deletions lib/render/components/shared.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ArrowRight, Repeat2 } from 'lucide-react'

import { catalog } from '../catalog'

export type CatalogType = typeof catalog
Expand All @@ -8,3 +10,14 @@ export const stackGap = {
md: 'gap-3',
lg: 'gap-4'
} as const

/**
* Icons available to any spec component that accepts an `icon` prop
* (Heading, Button, etc). Keep this small and curated.
*/
export const iconMap = {
related: Repeat2,
'arrow-right': ArrowRight
} as const

export type IconName = keyof typeof iconMap
Loading
Loading