| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791 |
- import Link from "next/link";
- import { Code } from "@/components/code";
- export const metadata = {
- title: "Adaptive Cards Integration | json-render",
- };
- export default function AdaptiveCardsPage() {
- return (
- <article>
- <h1 className="text-3xl font-bold mb-4">Adaptive Cards Integration</h1>
- <p className="text-muted-foreground mb-8">
- Use json-render to render{" "}
- <a
- href="https://adaptivecards.io"
- target="_blank"
- rel="noopener noreferrer"
- className="text-foreground hover:underline"
- >
- Microsoft Adaptive Cards
- </a>{" "}
- natively.
- </p>
- <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
- <p className="text-sm text-amber-700 dark:text-amber-300">
- <strong>Concept:</strong> This page demonstrates how json-render can
- support Adaptive Cards. The examples are illustrative and may require
- adaptation for production use.
- </p>
- </div>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Adaptive Cards Overview
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- Adaptive Cards is a JSON-based format for platform-agnostic UI snippets.
- Cards have a <code className="text-foreground">body</code> array of
- elements and an optional{" "}
- <code className="text-foreground">actions</code> array for interactive
- buttons.
- </p>
- <h3 className="text-lg font-medium mt-8 mb-3">Example Adaptive Card</h3>
- <Code lang="json">{`{
- "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
- "type": "AdaptiveCard",
- "version": "1.5",
- "body": [
- {
- "type": "TextBlock",
- "text": "Hello, Adaptive Cards!",
- "size": "large",
- "weight": "bolder"
- },
- {
- "type": "Image",
- "url": "https://example.com/image.png",
- "altText": "Example image"
- },
- {
- "type": "Container",
- "items": [
- {
- "type": "TextBlock",
- "text": "This is inside a container",
- "wrap": true
- }
- ]
- },
- {
- "type": "ColumnSet",
- "columns": [
- {
- "type": "Column",
- "width": "auto",
- "items": [
- { "type": "TextBlock", "text": "Column 1" }
- ]
- },
- {
- "type": "Column",
- "width": "stretch",
- "items": [
- { "type": "TextBlock", "text": "Column 2" }
- ]
- }
- ]
- },
- {
- "type": "Input.Text",
- "id": "userInput",
- "placeholder": "Enter your name",
- "label": "Name"
- }
- ],
- "actions": [
- {
- "type": "Action.Submit",
- "title": "Submit"
- },
- {
- "type": "Action.OpenUrl",
- "title": "Learn More",
- "url": "https://adaptivecards.io"
- }
- ]
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Creating an Adaptive Cards Catalog
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- Define a catalog matching the Adaptive Cards element types:
- </p>
- <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
- import { z } from 'zod';
- // Common Adaptive Cards properties
- const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
- const HorizontalAlignment = z.enum(['left', 'center', 'right']);
- const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
- const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
- const FontWeight = z.enum(['lighter', 'default', 'bolder']);
- const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
- const ImageStyle = z.enum(['default', 'person']);
- // Base element properties shared by most elements
- const BaseElement = {
- id: z.string().optional(),
- isVisible: z.boolean().optional(),
- separator: z.boolean().optional(),
- spacing: Spacing.optional(),
- };
- export const adaptiveCardsCatalog = createCatalog({
- components: {
- // Root card
- AdaptiveCard: {
- description: 'Root Adaptive Card container',
- props: z.object({
- version: z.string(),
- body: z.array(z.unknown()).optional(),
- actions: z.array(z.unknown()).optional(),
- fallbackText: z.string().optional(),
- minHeight: z.string().optional(),
- rtl: z.boolean().optional(),
- verticalContentAlignment: VerticalAlignment.optional(),
- }),
- },
- // Elements
- TextBlock: {
- description: 'Displays text with formatting options',
- props: z.object({
- ...BaseElement,
- text: z.string(),
- color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
- fontType: z.enum(['default', 'monospace']).optional(),
- horizontalAlignment: HorizontalAlignment.optional(),
- isSubtle: z.boolean().optional(),
- maxLines: z.number().optional(),
- size: FontSize.optional(),
- weight: FontWeight.optional(),
- wrap: z.boolean().optional(),
- }),
- },
- Image: {
- description: 'Displays an image',
- props: z.object({
- ...BaseElement,
- url: z.string(),
- altText: z.string().optional(),
- backgroundColor: z.string().optional(),
- height: z.string().optional(),
- width: z.string().optional(),
- horizontalAlignment: HorizontalAlignment.optional(),
- size: ImageSize.optional(),
- style: ImageStyle.optional(),
- }),
- },
- Container: {
- description: 'Groups elements together',
- props: z.object({
- ...BaseElement,
- items: z.array(z.unknown()),
- style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
- verticalContentAlignment: VerticalAlignment.optional(),
- bleed: z.boolean().optional(),
- minHeight: z.string().optional(),
- }),
- },
- ColumnSet: {
- description: 'Arranges columns horizontally',
- props: z.object({
- ...BaseElement,
- columns: z.array(z.unknown()),
- horizontalAlignment: HorizontalAlignment.optional(),
- minHeight: z.string().optional(),
- }),
- },
- Column: {
- description: 'A column within a ColumnSet',
- props: z.object({
- ...BaseElement,
- items: z.array(z.unknown()).optional(),
- width: z.union([z.string(), z.number()]).optional(),
- style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
- verticalContentAlignment: VerticalAlignment.optional(),
- }),
- },
- FactSet: {
- description: 'Displays a series of facts as key/value pairs',
- props: z.object({
- ...BaseElement,
- facts: z.array(z.object({
- title: z.string(),
- value: z.string(),
- })),
- }),
- },
- ImageSet: {
- description: 'Displays a collection of images',
- props: z.object({
- ...BaseElement,
- images: z.array(z.object({
- type: z.literal('Image'),
- url: z.string(),
- altText: z.string().optional(),
- })),
- imageSize: ImageSize.optional(),
- }),
- },
- ActionSet: {
- description: 'Displays a set of actions',
- props: z.object({
- ...BaseElement,
- actions: z.array(z.unknown()),
- }),
- },
- RichTextBlock: {
- description: 'Rich text with inline formatting',
- props: z.object({
- ...BaseElement,
- inlines: z.array(z.unknown()),
- horizontalAlignment: HorizontalAlignment.optional(),
- }),
- },
- // Inputs
- 'Input.Text': {
- description: 'Text input field',
- props: z.object({
- ...BaseElement,
- id: z.string(),
- isMultiline: z.boolean().optional(),
- maxLength: z.number().optional(),
- placeholder: z.string().optional(),
- label: z.string().optional(),
- value: z.string().optional(),
- style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
- isRequired: z.boolean().optional(),
- errorMessage: z.string().optional(),
- }),
- },
- 'Input.Number': {
- description: 'Number input field',
- props: z.object({
- ...BaseElement,
- id: z.string(),
- max: z.number().optional(),
- min: z.number().optional(),
- placeholder: z.string().optional(),
- label: z.string().optional(),
- value: z.number().optional(),
- isRequired: z.boolean().optional(),
- errorMessage: z.string().optional(),
- }),
- },
- 'Input.Date': {
- description: 'Date picker input',
- props: z.object({
- ...BaseElement,
- id: z.string(),
- max: z.string().optional(),
- min: z.string().optional(),
- placeholder: z.string().optional(),
- label: z.string().optional(),
- value: z.string().optional(),
- isRequired: z.boolean().optional(),
- }),
- },
- 'Input.Time': {
- description: 'Time picker input',
- props: z.object({
- ...BaseElement,
- id: z.string(),
- max: z.string().optional(),
- min: z.string().optional(),
- placeholder: z.string().optional(),
- label: z.string().optional(),
- value: z.string().optional(),
- isRequired: z.boolean().optional(),
- }),
- },
- 'Input.Toggle': {
- description: 'Toggle/checkbox input',
- props: z.object({
- ...BaseElement,
- id: z.string(),
- title: z.string(),
- label: z.string().optional(),
- value: z.string().optional(),
- valueOff: z.string().optional(),
- valueOn: z.string().optional(),
- isRequired: z.boolean().optional(),
- }),
- },
- 'Input.ChoiceSet': {
- description: 'Dropdown or radio/checkbox group',
- props: z.object({
- ...BaseElement,
- id: z.string(),
- choices: z.array(z.object({
- title: z.string(),
- value: z.string(),
- })),
- isMultiSelect: z.boolean().optional(),
- style: z.enum(['compact', 'expanded']).optional(),
- label: z.string().optional(),
- value: z.string().optional(),
- placeholder: z.string().optional(),
- isRequired: z.boolean().optional(),
- }),
- },
- // Actions
- 'Action.OpenUrl': {
- description: 'Opens a URL',
- props: z.object({
- title: z.string().optional(),
- url: z.string(),
- iconUrl: z.string().optional(),
- }),
- },
- 'Action.Submit': {
- description: 'Submits input data',
- props: z.object({
- title: z.string().optional(),
- data: z.unknown().optional(),
- iconUrl: z.string().optional(),
- }),
- },
- 'Action.ShowCard': {
- description: 'Shows a card inline',
- props: z.object({
- title: z.string().optional(),
- card: z.unknown(),
- iconUrl: z.string().optional(),
- }),
- },
- 'Action.ToggleVisibility': {
- description: 'Toggles visibility of elements',
- props: z.object({
- title: z.string().optional(),
- targetElements: z.array(z.union([
- z.string(),
- z.object({ elementId: z.string(), isVisible: z.boolean().optional() }),
- ])),
- iconUrl: z.string().optional(),
- }),
- },
- 'Action.Execute': {
- description: 'Universal action for bots',
- props: z.object({
- title: z.string().optional(),
- verb: z.string().optional(),
- data: z.unknown().optional(),
- iconUrl: z.string().optional(),
- }),
- },
- },
- });`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Building an Adaptive Cards Renderer
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- Create a renderer that processes Adaptive Cards JSON:
- </p>
- <Code lang="tsx">{`'use client';
- import React from 'react';
- interface AdaptiveCardElement {
- type: string;
- [key: string]: unknown;
- }
- interface AdaptiveCard {
- type: 'AdaptiveCard';
- version: string;
- body?: AdaptiveCardElement[];
- actions?: AdaptiveCardElement[];
- }
- interface RenderContext {
- onAction: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
- inputs: Record<string, unknown>;
- setInput: (id: string, value: unknown) => void;
- }
- // Widget registry for Adaptive Cards elements
- const widgets: Record<string, React.FC<any>> = {
- TextBlock: ({ text, size, weight, color, isSubtle, wrap, horizontalAlignment }) => {
- const sizeClass = {
- small: 'text-xs',
- default: 'text-sm',
- medium: 'text-base',
- large: 'text-lg',
- extraLarge: 'text-2xl',
- }[size || 'default'];
- const weightClass = {
- lighter: 'font-light',
- default: 'font-normal',
- bolder: 'font-bold',
- }[weight || 'default'];
- const alignClass = {
- left: 'text-left',
- center: 'text-center',
- right: 'text-right',
- }[horizontalAlignment || 'left'];
- return (
- <p className={\`\${sizeClass} \${weightClass} \${alignClass} \${isSubtle ? 'text-muted-foreground' : ''} \${wrap !== false ? '' : 'truncate'}\`}>
- {text}
- </p>
- );
- },
- Image: ({ url, altText, size, style, horizontalAlignment }) => {
- const sizeClass = {
- auto: '',
- stretch: 'w-full',
- small: 'w-16',
- medium: 'w-32',
- large: 'w-48',
- }[size || 'auto'];
- return (
- <div className={\`flex \${horizontalAlignment === 'center' ? 'justify-center' : horizontalAlignment === 'right' ? 'justify-end' : ''}\`}>
- <img
- src={url}
- alt={altText || ''}
- className={\`\${sizeClass} \${style === 'person' ? 'rounded-full' : ''}\`}
- />
- </div>
- );
- },
- Container: ({ items, style, children, ctx }) => {
- const styleClass = {
- default: '',
- emphasis: 'bg-muted p-2 rounded',
- good: 'bg-green-50 p-2 rounded',
- attention: 'bg-red-50 p-2 rounded',
- warning: 'bg-yellow-50 p-2 rounded',
- accent: 'bg-blue-50 p-2 rounded',
- }[style || 'default'];
- return (
- <div className={\`\${styleClass} space-y-2\`}>
- {children || items?.map((item: any, i: number) => (
- <AdaptiveElement key={i} element={item} ctx={ctx} />
- ))}
- </div>
- );
- },
- ColumnSet: ({ columns, ctx }) => (
- <div className="flex gap-2">
- {columns?.map((col: any, i: number) => (
- <AdaptiveElement key={i} element={{ ...col, type: 'Column' }} ctx={ctx} />
- ))}
- </div>
- ),
- Column: ({ items, width, style, ctx }) => {
- const widthClass = width === 'auto' ? 'flex-none' :
- width === 'stretch' ? 'flex-1' :
- typeof width === 'number' ? \`flex-[\${width}]\` : 'flex-1';
- return (
- <div className={\`\${widthClass} space-y-2\`}>
- {items?.map((item: any, i: number) => (
- <AdaptiveElement key={i} element={item} ctx={ctx} />
- ))}
- </div>
- );
- },
- FactSet: ({ facts }) => (
- <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
- {facts?.map((fact: any, i: number) => (
- <React.Fragment key={i}>
- <span className="font-medium">{fact.title}</span>
- <span>{fact.value}</span>
- </React.Fragment>
- ))}
- </div>
- ),
- ActionSet: ({ actions, ctx }) => (
- <div className="flex gap-2 pt-2">
- {actions?.map((action: any, i: number) => (
- <AdaptiveElement key={i} element={action} ctx={ctx} />
- ))}
- </div>
- ),
- 'Input.Text': ({ id, placeholder, label, isMultiline, value, ctx }) => (
- <div className="space-y-1">
- {label && <label className="text-sm font-medium">{label}</label>}
- {isMultiline ? (
- <textarea
- className="w-full px-3 py-2 border rounded text-sm"
- placeholder={placeholder}
- defaultValue={value}
- onChange={(e) => ctx.setInput(id, e.target.value)}
- />
- ) : (
- <input
- type="text"
- className="w-full px-3 py-2 border rounded text-sm"
- placeholder={placeholder}
- defaultValue={value}
- onChange={(e) => ctx.setInput(id, e.target.value)}
- />
- )}
- </div>
- ),
- 'Input.Number': ({ id, placeholder, label, min, max, value, ctx }) => (
- <div className="space-y-1">
- {label && <label className="text-sm font-medium">{label}</label>}
- <input
- type="number"
- className="w-full px-3 py-2 border rounded text-sm"
- placeholder={placeholder}
- min={min}
- max={max}
- defaultValue={value}
- onChange={(e) => ctx.setInput(id, parseFloat(e.target.value))}
- />
- </div>
- ),
- 'Input.Toggle': ({ id, title, label, valueOn = 'true', valueOff = 'false', value, ctx }) => (
- <div className="flex items-center gap-2">
- <input
- type="checkbox"
- id={id}
- defaultChecked={value === valueOn}
- onChange={(e) => ctx.setInput(id, e.target.checked ? valueOn : valueOff)}
- />
- <label htmlFor={id} className="text-sm">{title || label}</label>
- </div>
- ),
- 'Input.ChoiceSet': ({ id, choices, isMultiSelect, style, label, placeholder, ctx }) => (
- <div className="space-y-1">
- {label && <label className="text-sm font-medium">{label}</label>}
- {style === 'expanded' ? (
- <div className="space-y-1">
- {choices?.map((choice: any, i: number) => (
- <label key={i} className="flex items-center gap-2 text-sm">
- <input
- type={isMultiSelect ? 'checkbox' : 'radio'}
- name={id}
- value={choice.value}
- onChange={(e) => ctx.setInput(id, e.target.value)}
- />
- {choice.title}
- </label>
- ))}
- </div>
- ) : (
- <select
- className="w-full px-3 py-2 border rounded text-sm"
- onChange={(e) => ctx.setInput(id, e.target.value)}
- >
- {placeholder && <option value="">{placeholder}</option>}
- {choices?.map((choice: any, i: number) => (
- <option key={i} value={choice.value}>{choice.title}</option>
- ))}
- </select>
- )}
- </div>
- ),
- 'Action.Submit': ({ title, data, ctx }) => (
- <button
- className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
- onClick={() => ctx.onAction({ type: 'Action.Submit', data }, ctx.inputs)}
- >
- {title || 'Submit'}
- </button>
- ),
- 'Action.OpenUrl': ({ title, url }) => (
- <a
- href={url}
- target="_blank"
- rel="noopener noreferrer"
- className="px-4 py-2 border rounded text-sm hover:bg-muted"
- >
- {title || 'Open'}
- </a>
- ),
- 'Action.Execute': ({ title, verb, data, ctx }) => (
- <button
- className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
- onClick={() => ctx.onAction({ type: 'Action.Execute', verb, data }, ctx.inputs)}
- >
- {title || 'Execute'}
- </button>
- ),
- };
- function AdaptiveElement({ element, ctx }: { element: AdaptiveCardElement; ctx: RenderContext }) {
- const Widget = widgets[element.type];
- if (!Widget) {
- console.warn(\`Unknown Adaptive Card element: \${element.type}\`);
- return null;
- }
- return <Widget {...element} ctx={ctx} />;
- }
- export function AdaptiveCardRenderer({
- card,
- onAction,
- }: {
- card: AdaptiveCard;
- onAction?: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
- }) {
- const [inputs, setInputs] = React.useState<Record<string, unknown>>({});
- const ctx: RenderContext = {
- onAction: onAction || (() => {}),
- inputs,
- setInput: (id, value) => setInputs((prev) => ({ ...prev, [id]: value })),
- };
- return (
- <div className="rounded-lg border p-4 space-y-3 max-w-md">
- {card.body?.map((element, i) => (
- <AdaptiveElement key={i} element={element} ctx={ctx} />
- ))}
- {card.actions && card.actions.length > 0 && (
- <div className="flex gap-2 pt-2 border-t">
- {card.actions.map((action, i) => (
- <AdaptiveElement key={i} element={action} ctx={ctx} />
- ))}
- </div>
- )}
- </div>
- );
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
- <p className="text-sm text-muted-foreground mb-4">
- Render an Adaptive Card and handle actions:
- </p>
- <Code lang="tsx">{`'use client';
- import { AdaptiveCardRenderer } from './adaptive-card-renderer';
- const card = {
- type: 'AdaptiveCard' as const,
- version: '1.5',
- body: [
- {
- type: 'TextBlock',
- text: 'Contact Form',
- size: 'large',
- weight: 'bolder',
- },
- {
- type: 'Input.Text',
- id: 'name',
- label: 'Your Name',
- placeholder: 'Enter your name',
- },
- {
- type: 'Input.Text',
- id: 'message',
- label: 'Message',
- placeholder: 'Enter your message',
- isMultiline: true,
- },
- ],
- actions: [
- {
- type: 'Action.Submit',
- title: 'Send',
- data: { action: 'submitForm' },
- },
- ],
- };
- export function ContactCard() {
- const handleAction = (action: any, inputData: Record<string, unknown>) => {
- console.log('Action:', action);
- console.log('Input data:', inputData);
-
- // Send to your backend
- fetch('/api/submit', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action, data: inputData }),
- });
- };
- return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Handling Action.Execute for Bots
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- For bot scenarios, handle{" "}
- <code className="text-foreground">Action.Execute</code> with the verb
- and data:
- </p>
- <Code lang="typescript">{`interface ActionExecutePayload {
- action: {
- type: 'Action.Execute';
- verb: string;
- data?: unknown;
- };
- inputs: Record<string, unknown>;
- }
- async function handleBotAction(payload: ActionExecutePayload) {
- const response = await fetch('/api/bot/action', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- verb: payload.action.verb,
- data: payload.action.data,
- inputs: payload.inputs,
- }),
- });
-
- // Bot may return a new card to render
- const result = await response.json();
- if (result.card) {
- return result.card; // New AdaptiveCard to render
- }
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
- <p className="text-sm text-muted-foreground">
- Learn about{" "}
- <Link href="/docs/a2ui" className="text-foreground hover:underline">
- A2UI integration
- </Link>{" "}
- for another agent-driven UI protocol.
- </p>
- </article>
- );
- }
|