@easyops-cn/a2ui-react 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/speckit.analyze.md +184 -0
- package/.claude/commands/speckit.checklist.md +294 -0
- package/.claude/commands/speckit.clarify.md +181 -0
- package/.claude/commands/speckit.constitution.md +82 -0
- package/.claude/commands/speckit.implement.md +135 -0
- package/.claude/commands/speckit.plan.md +89 -0
- package/.claude/commands/speckit.specify.md +256 -0
- package/.claude/commands/speckit.tasks.md +137 -0
- package/.claude/commands/speckit.taskstoissues.md +30 -0
- package/.github/workflows/deploy.yml +69 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +7 -0
- package/.specify/memory/constitution.md +73 -0
- package/.specify/scripts/bash/check-prerequisites.sh +166 -0
- package/.specify/scripts/bash/common.sh +156 -0
- package/.specify/scripts/bash/create-new-feature.sh +297 -0
- package/.specify/scripts/bash/setup-plan.sh +61 -0
- package/.specify/scripts/bash/update-agent-context.sh +799 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +40 -0
- package/.specify/templates/plan-template.md +105 -0
- package/.specify/templates/spec-template.md +115 -0
- package/.specify/templates/tasks-template.md +250 -0
- package/CLAUDE.md +105 -0
- package/CONTRIBUTING.md +97 -0
- package/README.md +126 -0
- package/components.json +21 -0
- package/eslint.config.js +25 -0
- package/netlify.toml +50 -0
- package/package.json +94 -0
- package/playground/README.md +75 -0
- package/playground/index.html +22 -0
- package/playground/package.json +32 -0
- package/playground/public/favicon.svg +8 -0
- package/playground/src/App.css +256 -0
- package/playground/src/App.tsx +115 -0
- package/playground/src/assets/react.svg +1 -0
- package/playground/src/components/ErrorDisplay.tsx +13 -0
- package/playground/src/components/ExampleSelector.tsx +64 -0
- package/playground/src/components/Header.tsx +47 -0
- package/playground/src/components/JsonEditor.tsx +32 -0
- package/playground/src/components/Preview.tsx +78 -0
- package/playground/src/components/ThemeToggle.tsx +19 -0
- package/playground/src/data/examples.ts +1571 -0
- package/playground/src/hooks/useTheme.ts +55 -0
- package/playground/src/index.css +220 -0
- package/playground/src/main.tsx +10 -0
- package/playground/tsconfig.app.json +34 -0
- package/playground/tsconfig.json +13 -0
- package/playground/tsconfig.node.json +26 -0
- package/playground/vite.config.ts +31 -0
- package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
- package/specs/001-a2ui-renderer/data-model.md +140 -0
- package/specs/001-a2ui-renderer/plan.md +123 -0
- package/specs/001-a2ui-renderer/quickstart.md +141 -0
- package/specs/001-a2ui-renderer/research.md +140 -0
- package/specs/001-a2ui-renderer/spec.md +165 -0
- package/specs/001-a2ui-renderer/tasks.md +310 -0
- package/specs/002-playground/checklists/requirements.md +37 -0
- package/specs/002-playground/contracts/components.md +120 -0
- package/specs/002-playground/data-model.md +149 -0
- package/specs/002-playground/plan.md +73 -0
- package/specs/002-playground/quickstart.md +158 -0
- package/specs/002-playground/research.md +117 -0
- package/specs/002-playground/spec.md +109 -0
- package/specs/002-playground/tasks.md +224 -0
- package/src/0.8/A2UIRender.test.tsx +793 -0
- package/src/0.8/A2UIRender.tsx +142 -0
- package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
- package/src/0.8/components/ComponentRenderer.tsx +163 -0
- package/src/0.8/components/UnknownComponent.tsx +49 -0
- package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
- package/src/0.8/components/display/DividerComponent.tsx +23 -0
- package/src/0.8/components/display/IconComponent.tsx +137 -0
- package/src/0.8/components/display/ImageComponent.tsx +57 -0
- package/src/0.8/components/display/TextComponent.tsx +56 -0
- package/src/0.8/components/display/VideoComponent.tsx +31 -0
- package/src/0.8/components/display/display.test.tsx +660 -0
- package/src/0.8/components/display/index.ts +10 -0
- package/src/0.8/components/index.ts +14 -0
- package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
- package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
- package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
- package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
- package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
- package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
- package/src/0.8/components/interactive/index.ts +10 -0
- package/src/0.8/components/interactive/interactive.test.tsx +618 -0
- package/src/0.8/components/layout/CardComponent.tsx +30 -0
- package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
- package/src/0.8/components/layout/ListComponent.tsx +81 -0
- package/src/0.8/components/layout/ModalComponent.tsx +41 -0
- package/src/0.8/components/layout/RowComponent.tsx +94 -0
- package/src/0.8/components/layout/TabsComponent.tsx +59 -0
- package/src/0.8/components/layout/index.ts +10 -0
- package/src/0.8/components/layout/layout.test.tsx +558 -0
- package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
- package/src/0.8/contexts/A2UIProvider.tsx +54 -0
- package/src/0.8/contexts/ActionContext.test.tsx +242 -0
- package/src/0.8/contexts/ActionContext.tsx +105 -0
- package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
- package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
- package/src/0.8/contexts/DataModelContext.tsx +184 -0
- package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
- package/src/0.8/contexts/SurfaceContext.tsx +197 -0
- package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
- package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
- package/src/0.8/hooks/useComponent.test.tsx +148 -0
- package/src/0.8/hooks/useComponent.ts +39 -0
- package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
- package/src/0.8/hooks/useDataBinding.ts +99 -0
- package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
- package/src/0.8/hooks/useDispatchAction.ts +35 -0
- package/src/0.8/hooks/useSurface.test.tsx +114 -0
- package/src/0.8/hooks/useSurface.ts +34 -0
- package/src/0.8/index.ts +38 -0
- package/src/0.8/schemas/client_to_server.json +50 -0
- package/src/0.8/schemas/server_to_client.json +148 -0
- package/src/0.8/schemas/standard_catalog_definition.json +661 -0
- package/src/0.8/types/index.ts +448 -0
- package/src/0.8/utils/dataBinding.test.ts +443 -0
- package/src/0.8/utils/dataBinding.ts +212 -0
- package/src/0.8/utils/pathUtils.test.ts +353 -0
- package/src/0.8/utils/pathUtils.ts +200 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/dialog.tsx +141 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/native-select.tsx +53 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +188 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/slider.tsx +61 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/index.ts +1 -0
- package/src/lib/utils.ts +6 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +29 -0
- package/vitest.config.ts +22 -0
- package/vitest.setup.ts +8 -0
- package/website/README.md +4 -0
- package/website/assets/favicon.svg +8 -0
- package/website/content/.gitkeep +0 -0
- package/website/content/index.md +122 -0
- package/website/global.d.ts +9 -0
- package/website/package.json +17 -0
- package/website/plain.config.js +28 -0
- package/website/serve.json +6 -0
- package/website/src/client/color-mode-switch.css +47 -0
- package/website/src/client/index.js +61 -0
- package/website/src/client/moon.svg +1 -0
- package/website/src/client/sun.svg +1 -0
- package/website/src/components/Footer.jsx +9 -0
- package/website/src/components/Header.jsx +44 -0
- package/website/src/components/Page.jsx +28 -0
- package/website/src/global.css +423 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2UIRender - Main entry component for rendering A2UI messages.
|
|
3
|
+
*
|
|
4
|
+
* This component processes A2UI messages and renders the resulting UI.
|
|
5
|
+
* It supports custom component overrides via the components prop.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { A2UIRender, A2UIMessage, A2UIAction } from '@easyops-cn/a2ui-react/0.8'
|
|
10
|
+
*
|
|
11
|
+
* function App() {
|
|
12
|
+
* const messages: A2UIMessage[] = [...]
|
|
13
|
+
* const handleAction = (action: A2UIAction) => {
|
|
14
|
+
* console.log('Action:', action)
|
|
15
|
+
* }
|
|
16
|
+
* return <A2UIRender messages={messages} onAction={handleAction} />
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, type ComponentType } from 'react'
|
|
22
|
+
import type { A2UIMessage, ActionPayload, BaseComponentProps } from './types'
|
|
23
|
+
import { A2UIProvider } from './contexts/A2UIProvider'
|
|
24
|
+
import { ComponentsMapProvider } from './contexts/ComponentsMapContext'
|
|
25
|
+
import { useSurfaceContext } from './contexts/SurfaceContext'
|
|
26
|
+
import { useA2UIMessageHandler } from './hooks/useA2UIMessageHandler'
|
|
27
|
+
import {
|
|
28
|
+
ComponentRenderer,
|
|
29
|
+
componentRegistry,
|
|
30
|
+
} from './components/ComponentRenderer'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Type for custom component map.
|
|
34
|
+
*/
|
|
35
|
+
export type ComponentsMap = Map<
|
|
36
|
+
string,
|
|
37
|
+
ComponentType<BaseComponentProps & Record<string, unknown>>
|
|
38
|
+
>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Props for A2UIRender component.
|
|
42
|
+
*/
|
|
43
|
+
export interface A2UIRenderProps {
|
|
44
|
+
/** Array of A2UI messages to render */
|
|
45
|
+
messages: A2UIMessage[]
|
|
46
|
+
/** Callback when an action is dispatched */
|
|
47
|
+
onAction?: (action: ActionPayload) => void
|
|
48
|
+
/** Custom component overrides */
|
|
49
|
+
components?: ComponentsMap
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Internal component that handles message processing and surface rendering.
|
|
54
|
+
*/
|
|
55
|
+
function A2UIRenderInner({ messages }: { messages: A2UIMessage[] }) {
|
|
56
|
+
const { processMessages, clear } = useA2UIMessageHandler()
|
|
57
|
+
const { surfaces } = useSurfaceContext()
|
|
58
|
+
|
|
59
|
+
// Process messages when they change
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
// Clear existing state and process new messages
|
|
62
|
+
clear()
|
|
63
|
+
if (messages && messages.length > 0) {
|
|
64
|
+
processMessages(messages)
|
|
65
|
+
}
|
|
66
|
+
}, [messages, processMessages, clear])
|
|
67
|
+
|
|
68
|
+
// Render all surfaces
|
|
69
|
+
const surfaceEntries = Array.from(surfaces.entries())
|
|
70
|
+
|
|
71
|
+
if (surfaceEntries.length === 0) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
{surfaceEntries.map(([surfaceId, surface]) => {
|
|
78
|
+
// Only render surfaces that have a root component
|
|
79
|
+
if (!surface.root) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
<ComponentRenderer
|
|
84
|
+
key={surfaceId}
|
|
85
|
+
surfaceId={surfaceId}
|
|
86
|
+
componentId={surface.root}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
})}
|
|
90
|
+
</>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Main entry component for rendering A2UI messages.
|
|
96
|
+
*
|
|
97
|
+
* Processes an array of A2UIMessage objects and renders the resulting UI.
|
|
98
|
+
* Supports custom component overrides via the components prop.
|
|
99
|
+
*
|
|
100
|
+
* @param props - Component props
|
|
101
|
+
* @param props.messages - Array of A2UI messages to render
|
|
102
|
+
* @param props.onAction - Optional callback when an action is dispatched
|
|
103
|
+
* @param props.components - Optional custom component overrides
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```tsx
|
|
107
|
+
* // Basic usage
|
|
108
|
+
* <A2UIRender messages={messages} onAction={handleAction} />
|
|
109
|
+
*
|
|
110
|
+
* // With custom components
|
|
111
|
+
* const customComponents = new Map([
|
|
112
|
+
* ['Button', CustomButton],
|
|
113
|
+
* ['Switch', CustomSwitch],
|
|
114
|
+
* ])
|
|
115
|
+
* <A2UIRender
|
|
116
|
+
* messages={messages}
|
|
117
|
+
* onAction={handleAction}
|
|
118
|
+
* components={customComponents}
|
|
119
|
+
* />
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function A2UIRender({
|
|
123
|
+
messages,
|
|
124
|
+
onAction,
|
|
125
|
+
components,
|
|
126
|
+
}: A2UIRenderProps) {
|
|
127
|
+
// Handle null/undefined messages gracefully
|
|
128
|
+
const safeMessages = messages ?? []
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<A2UIProvider onAction={onAction}>
|
|
132
|
+
<ComponentsMapProvider
|
|
133
|
+
components={components}
|
|
134
|
+
defaultComponents={componentRegistry}
|
|
135
|
+
>
|
|
136
|
+
<A2UIRenderInner messages={safeMessages} />
|
|
137
|
+
</ComponentsMapProvider>
|
|
138
|
+
</A2UIProvider>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
A2UIRender.displayName = 'A2UI.Render'
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComponentRenderer Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the ComponentRenderer component that routes rendering based on type.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
8
|
+
import { render, screen } from '@testing-library/react'
|
|
9
|
+
import { ComponentRenderer, registerComponent } from './ComponentRenderer'
|
|
10
|
+
import { SurfaceProvider, useSurfaceContext } from '../contexts/SurfaceContext'
|
|
11
|
+
import { DataModelProvider } from '../contexts/DataModelContext'
|
|
12
|
+
import { ActionProvider } from '../contexts/ActionContext'
|
|
13
|
+
import type { ReactNode } from 'react'
|
|
14
|
+
import type { BaseComponentProps, ComponentDefinition } from '../types'
|
|
15
|
+
import React from 'react'
|
|
16
|
+
|
|
17
|
+
// Wrapper with all providers
|
|
18
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
19
|
+
<SurfaceProvider>
|
|
20
|
+
<DataModelProvider>
|
|
21
|
+
<ActionProvider>{children}</ActionProvider>
|
|
22
|
+
</DataModelProvider>
|
|
23
|
+
</SurfaceProvider>
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// Helper component to set up surface with components
|
|
27
|
+
function SurfaceSetup({
|
|
28
|
+
children,
|
|
29
|
+
components,
|
|
30
|
+
}: {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
components: ComponentDefinition[]
|
|
33
|
+
}) {
|
|
34
|
+
const { initSurface, updateSurface } = useSurfaceContext()
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
initSurface('test-surface', 'root')
|
|
37
|
+
updateSurface('test-surface', components)
|
|
38
|
+
}, [initSurface, updateSurface, components])
|
|
39
|
+
return <>{children}</>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Custom wrapper that sets up surface
|
|
43
|
+
const createSurfaceWrapper = (components: ComponentDefinition[]) => {
|
|
44
|
+
return ({ children }: { children: ReactNode }) => (
|
|
45
|
+
<SurfaceProvider>
|
|
46
|
+
<DataModelProvider>
|
|
47
|
+
<ActionProvider>
|
|
48
|
+
<SurfaceSetup components={components}>{children}</SurfaceSetup>
|
|
49
|
+
</ActionProvider>
|
|
50
|
+
</DataModelProvider>
|
|
51
|
+
</SurfaceProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('ComponentRenderer', () => {
|
|
56
|
+
let consoleWarn: ReturnType<typeof vi.spyOn>
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
consoleWarn.mockRestore()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('component not found', () => {
|
|
67
|
+
it('should return null and warn when component not found', () => {
|
|
68
|
+
render(
|
|
69
|
+
<ComponentRenderer
|
|
70
|
+
surfaceId="test-surface"
|
|
71
|
+
componentId="nonexistent"
|
|
72
|
+
/>,
|
|
73
|
+
{ wrapper }
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
77
|
+
'A2UI: Component not found: nonexistent on surface test-surface'
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('component with no type definition', () => {
|
|
83
|
+
it('should return null and warn when component has no type', () => {
|
|
84
|
+
const components: ComponentDefinition[] = [
|
|
85
|
+
{ id: 'empty-comp', component: {} },
|
|
86
|
+
]
|
|
87
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
88
|
+
|
|
89
|
+
render(
|
|
90
|
+
<ComponentRenderer surfaceId="test-surface" componentId="empty-comp" />,
|
|
91
|
+
{ wrapper: surfaceWrapper }
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
95
|
+
'A2UI: Component empty-comp has no type definition'
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('unknown component type', () => {
|
|
101
|
+
it('should return null and warn for unknown component type', () => {
|
|
102
|
+
const components: ComponentDefinition[] = [
|
|
103
|
+
{ id: 'unknown-comp', component: { UnknownType: {} } },
|
|
104
|
+
]
|
|
105
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<ComponentRenderer
|
|
109
|
+
surfaceId="test-surface"
|
|
110
|
+
componentId="unknown-comp"
|
|
111
|
+
/>,
|
|
112
|
+
{ wrapper: surfaceWrapper }
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
116
|
+
'A2UI: Unknown component type: UnknownType'
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('rendering display components', () => {
|
|
122
|
+
it('should render Text component', () => {
|
|
123
|
+
const components: ComponentDefinition[] = [
|
|
124
|
+
{
|
|
125
|
+
id: 'text-1',
|
|
126
|
+
component: { Text: { text: { literalString: 'Hello World' } } },
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<ComponentRenderer surfaceId="test-surface" componentId="text-1" />,
|
|
133
|
+
{ wrapper: surfaceWrapper }
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(screen.getByText('Hello World')).toBeInTheDocument()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should render Image component', () => {
|
|
140
|
+
const components: ComponentDefinition[] = [
|
|
141
|
+
{
|
|
142
|
+
id: 'image-1',
|
|
143
|
+
component: {
|
|
144
|
+
Image: { url: { literalString: 'https://example.com/image.jpg' } },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
149
|
+
|
|
150
|
+
render(
|
|
151
|
+
<ComponentRenderer surfaceId="test-surface" componentId="image-1" />,
|
|
152
|
+
{ wrapper: surfaceWrapper }
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const img = screen.getByRole('presentation')
|
|
156
|
+
expect(img).toHaveAttribute('src', 'https://example.com/image.jpg')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should render Divider component', () => {
|
|
160
|
+
const components: ComponentDefinition[] = [
|
|
161
|
+
{ id: 'divider-1', component: { Divider: { axis: 'horizontal' } } },
|
|
162
|
+
]
|
|
163
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
164
|
+
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<ComponentRenderer surfaceId="test-surface" componentId="divider-1" />,
|
|
167
|
+
{ wrapper: surfaceWrapper }
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expect(
|
|
171
|
+
container.querySelector('[data-orientation="horizontal"]')
|
|
172
|
+
).toBeInTheDocument()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('rendering layout components', () => {
|
|
177
|
+
it('should render Row component', () => {
|
|
178
|
+
const components: ComponentDefinition[] = [
|
|
179
|
+
{ id: 'row-1', component: { Row: { distribution: 'center' } } },
|
|
180
|
+
]
|
|
181
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
182
|
+
|
|
183
|
+
const { container } = render(
|
|
184
|
+
<ComponentRenderer surfaceId="test-surface" componentId="row-1" />,
|
|
185
|
+
{ wrapper: surfaceWrapper }
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(container.querySelector('.flex-row')).toBeInTheDocument()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('should render Column component', () => {
|
|
192
|
+
const components: ComponentDefinition[] = [
|
|
193
|
+
{ id: 'column-1', component: { Column: { alignment: 'center' } } },
|
|
194
|
+
]
|
|
195
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
196
|
+
|
|
197
|
+
const { container } = render(
|
|
198
|
+
<ComponentRenderer surfaceId="test-surface" componentId="column-1" />,
|
|
199
|
+
{ wrapper: surfaceWrapper }
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
expect(container.querySelector('.flex-col')).toBeInTheDocument()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should render Card component', () => {
|
|
206
|
+
const components: ComponentDefinition[] = [
|
|
207
|
+
{ id: 'card-1', component: { Card: {} } },
|
|
208
|
+
]
|
|
209
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
210
|
+
|
|
211
|
+
const { container } = render(
|
|
212
|
+
<ComponentRenderer surfaceId="test-surface" componentId="card-1" />,
|
|
213
|
+
{ wrapper: surfaceWrapper }
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('rendering interactive components', () => {
|
|
221
|
+
it('should render Button component', () => {
|
|
222
|
+
const components: ComponentDefinition[] = [
|
|
223
|
+
{ id: 'button-1', component: { Button: { primary: true } } },
|
|
224
|
+
]
|
|
225
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
226
|
+
|
|
227
|
+
render(
|
|
228
|
+
<ComponentRenderer surfaceId="test-surface" componentId="button-1" />,
|
|
229
|
+
{ wrapper: surfaceWrapper }
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
expect(screen.getByRole('button')).toBeInTheDocument()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should render CheckBox component', () => {
|
|
236
|
+
const components: ComponentDefinition[] = [
|
|
237
|
+
{
|
|
238
|
+
id: 'checkbox-1',
|
|
239
|
+
component: { CheckBox: { label: { literalString: 'Accept' } } },
|
|
240
|
+
},
|
|
241
|
+
]
|
|
242
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
243
|
+
|
|
244
|
+
render(
|
|
245
|
+
<ComponentRenderer surfaceId="test-surface" componentId="checkbox-1" />,
|
|
246
|
+
{ wrapper: surfaceWrapper }
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
expect(screen.getByRole('checkbox')).toBeInTheDocument()
|
|
250
|
+
expect(screen.getByText('Accept')).toBeInTheDocument()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should render TextField component', () => {
|
|
254
|
+
const components: ComponentDefinition[] = [
|
|
255
|
+
{
|
|
256
|
+
id: 'field-1',
|
|
257
|
+
component: { TextField: { label: { literalString: 'Name' } } },
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
261
|
+
|
|
262
|
+
render(
|
|
263
|
+
<ComponentRenderer surfaceId="test-surface" componentId="field-1" />,
|
|
264
|
+
{ wrapper: surfaceWrapper }
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
|
268
|
+
expect(screen.getByText('Name')).toBeInTheDocument()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('should render Slider component', () => {
|
|
272
|
+
const components: ComponentDefinition[] = [
|
|
273
|
+
{
|
|
274
|
+
id: 'slider-1',
|
|
275
|
+
component: { Slider: { minValue: 0, maxValue: 100 } },
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
279
|
+
|
|
280
|
+
render(
|
|
281
|
+
<ComponentRenderer surfaceId="test-surface" componentId="slider-1" />,
|
|
282
|
+
{ wrapper: surfaceWrapper }
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
expect(screen.getByRole('slider')).toBeInTheDocument()
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('passing props', () => {
|
|
290
|
+
it('should pass weight prop to component', () => {
|
|
291
|
+
const components: ComponentDefinition[] = [
|
|
292
|
+
{
|
|
293
|
+
id: 'text-1',
|
|
294
|
+
weight: 2,
|
|
295
|
+
component: { Text: { text: { literalString: 'Weighted' } } },
|
|
296
|
+
},
|
|
297
|
+
]
|
|
298
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
299
|
+
|
|
300
|
+
render(
|
|
301
|
+
<ComponentRenderer surfaceId="test-surface" componentId="text-1" />,
|
|
302
|
+
{ wrapper: surfaceWrapper }
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// Component should render (weight is passed but may not affect visual)
|
|
306
|
+
expect(screen.getByText('Weighted')).toBeInTheDocument()
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('should pass all component props', () => {
|
|
310
|
+
const components: ComponentDefinition[] = [
|
|
311
|
+
{
|
|
312
|
+
id: 'text-1',
|
|
313
|
+
component: {
|
|
314
|
+
Text: {
|
|
315
|
+
text: { literalString: 'Heading' },
|
|
316
|
+
usageHint: 'h1',
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
]
|
|
321
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
322
|
+
|
|
323
|
+
const { container } = render(
|
|
324
|
+
<ComponentRenderer surfaceId="test-surface" componentId="text-1" />,
|
|
325
|
+
{ wrapper: surfaceWrapper }
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
expect(container.querySelector('h1')).toBeInTheDocument()
|
|
329
|
+
expect(container.querySelector('h1')).toHaveTextContent('Heading')
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
describe('registerComponent', () => {
|
|
334
|
+
it('should register and render custom component', () => {
|
|
335
|
+
// Register a custom component
|
|
336
|
+
const CustomComponent = ({
|
|
337
|
+
surfaceId,
|
|
338
|
+
componentId,
|
|
339
|
+
customProp,
|
|
340
|
+
}: BaseComponentProps & { customProp?: string }) => (
|
|
341
|
+
<div data-testid="custom-component">
|
|
342
|
+
Custom: {customProp} ({surfaceId}/{componentId})
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
registerComponent('CustomWidget', CustomComponent)
|
|
347
|
+
|
|
348
|
+
const components: ComponentDefinition[] = [
|
|
349
|
+
{
|
|
350
|
+
id: 'custom-1',
|
|
351
|
+
component: { CustomWidget: { customProp: 'test-value' } },
|
|
352
|
+
},
|
|
353
|
+
]
|
|
354
|
+
const surfaceWrapper = createSurfaceWrapper(components)
|
|
355
|
+
|
|
356
|
+
render(
|
|
357
|
+
<ComponentRenderer surfaceId="test-surface" componentId="custom-1" />,
|
|
358
|
+
{ wrapper: surfaceWrapper }
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
expect(screen.getByTestId('custom-component')).toBeInTheDocument()
|
|
362
|
+
expect(screen.getByTestId('custom-component')).toHaveTextContent(
|
|
363
|
+
'Custom: test-value'
|
|
364
|
+
)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe('displayName', () => {
|
|
369
|
+
it('should have correct displayName', () => {
|
|
370
|
+
expect(ComponentRenderer.displayName).toBe('A2UI.ComponentRenderer')
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComponentRenderer - Routes component rendering based on type.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { memo, useContext, type ComponentType } from 'react'
|
|
6
|
+
import type { BaseComponentProps } from '../types'
|
|
7
|
+
import { useComponent } from '../hooks/useComponent'
|
|
8
|
+
import { ComponentsMapContext } from '../contexts/ComponentsMapContext'
|
|
9
|
+
|
|
10
|
+
// Display components
|
|
11
|
+
import {
|
|
12
|
+
TextComponent,
|
|
13
|
+
ImageComponent,
|
|
14
|
+
IconComponent,
|
|
15
|
+
VideoComponent,
|
|
16
|
+
AudioPlayerComponent,
|
|
17
|
+
DividerComponent,
|
|
18
|
+
} from './display'
|
|
19
|
+
|
|
20
|
+
// Layout components (will be imported after creation)
|
|
21
|
+
import {
|
|
22
|
+
RowComponent,
|
|
23
|
+
ColumnComponent,
|
|
24
|
+
ListComponent,
|
|
25
|
+
CardComponent,
|
|
26
|
+
TabsComponent,
|
|
27
|
+
ModalComponent,
|
|
28
|
+
} from './layout'
|
|
29
|
+
|
|
30
|
+
// Interactive components (will be imported after creation)
|
|
31
|
+
import {
|
|
32
|
+
ButtonComponent,
|
|
33
|
+
CheckBoxComponent,
|
|
34
|
+
TextFieldComponent,
|
|
35
|
+
DateTimeInputComponent,
|
|
36
|
+
MultipleChoiceComponent,
|
|
37
|
+
SliderComponent,
|
|
38
|
+
} from './interactive'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Component registry mapping component type names to React components.
|
|
42
|
+
*/
|
|
43
|
+
export const componentRegistry: Record<
|
|
44
|
+
string,
|
|
45
|
+
ComponentType<BaseComponentProps & Record<string, unknown>>
|
|
46
|
+
> = {
|
|
47
|
+
// Display components
|
|
48
|
+
Text: TextComponent,
|
|
49
|
+
Image: ImageComponent,
|
|
50
|
+
Icon: IconComponent,
|
|
51
|
+
Video: VideoComponent,
|
|
52
|
+
AudioPlayer: AudioPlayerComponent,
|
|
53
|
+
Divider: DividerComponent,
|
|
54
|
+
|
|
55
|
+
// Layout components
|
|
56
|
+
Row: RowComponent,
|
|
57
|
+
Column: ColumnComponent,
|
|
58
|
+
List: ListComponent,
|
|
59
|
+
Card: CardComponent,
|
|
60
|
+
Tabs: TabsComponent,
|
|
61
|
+
Modal: ModalComponent,
|
|
62
|
+
|
|
63
|
+
// Interactive components
|
|
64
|
+
Button: ButtonComponent,
|
|
65
|
+
CheckBox: CheckBoxComponent,
|
|
66
|
+
TextField: TextFieldComponent,
|
|
67
|
+
DateTimeInput: DateTimeInputComponent,
|
|
68
|
+
MultipleChoice: MultipleChoiceComponent,
|
|
69
|
+
Slider: SliderComponent,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Props for ComponentRenderer.
|
|
74
|
+
*/
|
|
75
|
+
export interface ComponentRendererProps {
|
|
76
|
+
surfaceId: string
|
|
77
|
+
componentId: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Renders a component based on its type from the component registry.
|
|
82
|
+
* Supports custom component overrides via ComponentsMapContext.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```tsx
|
|
86
|
+
* // Render a component by ID
|
|
87
|
+
* <ComponentRenderer surfaceId="surface-1" componentId="text-1" />
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export const ComponentRenderer = memo(function ComponentRenderer({
|
|
91
|
+
surfaceId,
|
|
92
|
+
componentId,
|
|
93
|
+
}: ComponentRendererProps) {
|
|
94
|
+
const component = useComponent(surfaceId, componentId)
|
|
95
|
+
const componentsMapContext = useContext(ComponentsMapContext)
|
|
96
|
+
|
|
97
|
+
if (!component) {
|
|
98
|
+
console.warn(
|
|
99
|
+
`A2UI: Component not found: ${componentId} on surface ${surfaceId}`
|
|
100
|
+
)
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract the component type and props from the definition
|
|
105
|
+
// component.component is { [componentType]: props }
|
|
106
|
+
const entries = Object.entries(component.component)
|
|
107
|
+
if (entries.length === 0) {
|
|
108
|
+
console.warn(`A2UI: Component ${componentId} has no type definition`)
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const [componentType, props] = entries[0]
|
|
113
|
+
|
|
114
|
+
// Try to get component from context first (custom components), then fall back to registry
|
|
115
|
+
let Component:
|
|
116
|
+
| ComponentType<BaseComponentProps & Record<string, unknown>>
|
|
117
|
+
| undefined
|
|
118
|
+
if (componentsMapContext) {
|
|
119
|
+
Component = componentsMapContext.getComponent(componentType)
|
|
120
|
+
} else {
|
|
121
|
+
Component = componentRegistry[componentType]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!Component) {
|
|
125
|
+
// In development mode, render a placeholder for unknown components
|
|
126
|
+
// In production, skip unknown components silently
|
|
127
|
+
console.warn(`A2UI: Unknown component type: ${componentType}`)
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
// eslint-disable-next-line react-hooks/static-components
|
|
133
|
+
<Component
|
|
134
|
+
surfaceId={surfaceId}
|
|
135
|
+
componentId={componentId}
|
|
136
|
+
weight={component.weight}
|
|
137
|
+
{...(props as Record<string, unknown>)}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
ComponentRenderer.displayName = 'A2UI.ComponentRenderer'
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Registers a custom component type.
|
|
146
|
+
*
|
|
147
|
+
* @param type - The component type name
|
|
148
|
+
* @param component - The React component to register
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```tsx
|
|
152
|
+
* registerComponent('CustomChart', ({ surfaceId, data }) => {
|
|
153
|
+
* const chartData = useDataBinding(surfaceId, data, []);
|
|
154
|
+
* return <Chart data={chartData} />;
|
|
155
|
+
* });
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export function registerComponent(
|
|
159
|
+
type: string,
|
|
160
|
+
component: ComponentType<BaseComponentProps & Record<string, unknown>>
|
|
161
|
+
): void {
|
|
162
|
+
componentRegistry[type] = component
|
|
163
|
+
}
|