Add JSON page data/function prop mapping

This commit is contained in:
2026-01-18 17:29:18 +00:00
parent a718aca6f5
commit 174f03edd2
5 changed files with 126 additions and 66 deletions

View File

@@ -4,9 +4,11 @@ import { useSchemaLoader } from '@/hooks/use-schema-loader'
interface JSONSchemaPageLoaderProps {
schemaPath: string
data?: Record<string, any>
functions?: Record<string, any>
}
export function JSONSchemaPageLoader({ schemaPath }: JSONSchemaPageLoaderProps) {
export function JSONSchemaPageLoader({ schemaPath, data, functions }: JSONSchemaPageLoaderProps) {
const { schema, loading, error } = useSchemaLoader(schemaPath)
if (loading) {
@@ -21,5 +23,5 @@ export function JSONSchemaPageLoader({ schemaPath }: JSONSchemaPageLoaderProps)
)
}
return <PageRenderer schema={schema} />
return <PageRenderer schema={schema} data={data} functions={functions} />
}

View File

@@ -3,8 +3,22 @@ import { PageSchema } from '@/types/json-ui'
import { FeatureToggles } from '@/types/project'
export interface PropConfig {
/**
* Component page prop bindings (map to stateContext).
*/
state?: string[]
/**
* Component page action bindings (map to actionContext).
*/
actions?: string[]
/**
* JSON page data bindings (map to stateContext).
*/
data?: string[]
/**
* JSON page function bindings (map to actionContext).
*/
functions?: string[]
}
export interface ResizableConfig {
@@ -107,44 +121,39 @@ export function resolveProps(propConfig: PropConfig | undefined, stateContext: R
const resolvedProps: Record<string, any> = {}
const resolveEntries = (
entries: string[] | undefined,
context: Record<string, any>,
label: string
) => {
if (!entries?.length) {
return
}
console.log('[CONFIG] 📦 Resolving', entries.length, label)
for (const entry of entries) {
try {
const [propName, contextKey] = entry.includes(':')
? entry.split(':')
: [entry, entry]
if (context[contextKey] !== undefined) {
resolvedProps[propName] = context[contextKey]
console.log('[CONFIG] ✅ Resolved', label, 'prop:', propName)
} else {
console.log('[CONFIG] ⚠️', label, 'prop not found:', contextKey)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve', label, 'prop:', entry, err)
}
}
}
try {
if (propConfig.state) {
console.log('[CONFIG] 📦 Resolving', propConfig.state.length, 'state props')
for (const stateKey of propConfig.state) {
try {
const [propName, contextKey] = stateKey.includes(':')
? stateKey.split(':')
: [stateKey, stateKey]
if (stateContext[contextKey] !== undefined) {
resolvedProps[propName] = stateContext[contextKey]
console.log('[CONFIG] ✅ Resolved state prop:', propName)
} else {
console.log('[CONFIG] ⚠️ State prop not found:', contextKey)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve state prop:', stateKey, err)
}
}
}
if (propConfig.actions) {
console.log('[CONFIG] 🎬 Resolving', propConfig.actions.length, 'action props')
for (const actionKey of propConfig.actions) {
try {
const [propName, contextKey] = actionKey.split(':')
if (actionContext[contextKey]) {
resolvedProps[propName] = actionContext[contextKey]
console.log('[CONFIG] ✅ Resolved action prop:', propName)
} else {
console.log('[CONFIG] ⚠️ Action prop not found:', contextKey)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve action prop:', actionKey, err)
}
}
}
resolveEntries(propConfig.state, stateContext, 'state')
resolveEntries(propConfig.data, stateContext, 'data')
resolveEntries(propConfig.actions, actionContext, 'action')
resolveEntries(propConfig.functions, actionContext, 'function')
} catch (err) {
console.error('[CONFIG] ❌ Failed to resolve props:', err)
}

View File

@@ -153,44 +153,53 @@ export function validatePageConfig(): ValidationError[] {
}
if (page.props) {
if (page.props.state) {
page.props.state.forEach(stateKey => {
const [, contextKey] = stateKey.includes(':')
? stateKey.split(':')
const validateStateKeys = (keys: string[] | undefined, field: string) => {
if (!keys) return
keys.forEach(stateKey => {
const [, contextKey] = stateKey.includes(':')
? stateKey.split(':')
: [stateKey, stateKey]
if (!validStateKeys.includes(contextKey)) {
errors.push({
page: page.id || 'Unknown',
field: 'props.state',
field,
message: `Unknown state key: ${contextKey}. Valid keys: ${validStateKeys.join(', ')}`,
severity: 'error',
})
}
})
}
if (page.props.actions) {
page.props.actions.forEach(actionKey => {
const [, contextKey] = actionKey.split(':')
const validateActionKeys = (keys: string[] | undefined, field: string) => {
if (!keys) return
keys.forEach(actionKey => {
const [, contextKey] = actionKey.includes(':')
? actionKey.split(':')
: [actionKey, actionKey]
if (!contextKey) {
errors.push({
page: page.id || 'Unknown',
field: 'props.actions',
field,
message: `Action key must use format "propName:functionName". Got: ${actionKey}`,
severity: 'error',
})
} else if (!validActionKeys.includes(contextKey)) {
errors.push({
page: page.id || 'Unknown',
field: 'props.actions',
field,
message: `Unknown action key: ${contextKey}. Valid keys: ${validActionKeys.join(', ')}`,
severity: 'error',
})
}
})
}
validateStateKeys(page.props.state, 'props.state')
validateActionKeys(page.props.actions, 'props.actions')
validateStateKeys(page.props.data, 'props.data')
validateActionKeys(page.props.functions, 'props.functions')
}
if (page.requiresResizable) {

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react'
import { PageSchema } from '@/types/json-ui'
import { useCallback, useMemo } from 'react'
import { Action, PageSchema } from '@/types/json-ui'
import { useDataSources } from '@/hooks/data/use-data-sources'
import { useActionExecutor } from '@/hooks/ui/use-action-executor'
import { useAppSelector } from '@/store'
@@ -8,20 +8,34 @@ import { ComponentRenderer } from './component-renderer'
interface PageRendererProps {
schema: PageSchema
onCustomAction?: (action: any, event?: any) => Promise<void>
data?: Record<string, any>
functions?: Record<string, any>
}
export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
const { data, updateData, updatePath } = useDataSources(schema.dataSources)
export function PageRenderer({ schema, onCustomAction, data: externalData, functions }: PageRendererProps) {
const { data: sourceData, updateData, updatePath } = useDataSources(schema.dataSources)
const state = useAppSelector((rootState) => rootState)
const mergedData = useMemo(() => ({ ...sourceData, ...externalData }), [externalData, sourceData])
const executeCustomAction = useCallback(async (action: Action, event?: any) => {
if (onCustomAction) {
await onCustomAction(action, event)
return
}
const handler = functions?.[action.id]
if (typeof handler === 'function') {
await handler(action, event)
}
}, [functions, onCustomAction])
const context = {
data,
const actionContext = {
data: mergedData,
updateData,
updatePath,
executeAction: onCustomAction || (async () => {}),
executeAction: executeCustomAction,
}
const { executeActions } = useActionExecutor(context)
const { executeActions } = useActionExecutor(actionContext)
const handleEvent = useCallback((_componentId: string, handler: { actions: any[] }, eventData: any) => {
if (!handler?.actions?.length) return
@@ -34,7 +48,8 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
<ComponentRenderer
key={component.id || index}
component={component}
data={data}
data={mergedData}
context={functions}
state={state}
onEvent={handleEvent}
/>

View File

@@ -89,15 +89,22 @@ export function createRoutes(
const rootPage = enabledPages.find(p => p.isRoot)
console.log('[ROUTES] 🏠 Root page search result:', rootPage ? `Found: ${rootPage.id} (${rootPage.component})` : 'NOT FOUND - will redirect to /dashboard')
const renderJsonPage = (page: typeof enabledPages[number]) => {
// JSON page prop contract: page.props.data maps to stateContext -> data bindings,
// page.props.functions maps to actionContext -> custom action handlers.
// The mapping syntax matches props.state/props.actions (propName[:contextKey]).
const renderJsonPage = (
page: typeof enabledPages[number],
data?: Record<string, any>,
functions?: Record<string, any>
) => {
if (page.schema) {
console.log('[ROUTES] 🧾 Rendering preloaded JSON schema for page:', page.id)
return <PageRenderer schema={page.schema} />
return <PageRenderer schema={page.schema} data={data} functions={functions} />
}
if (page.schemaPath) {
console.log('[ROUTES] 🧾 Rendering JSON schema loader for page:', page.id)
return <JSONSchemaPageLoader schemaPath={page.schemaPath} />
return <JSONSchemaPageLoader schemaPath={page.schemaPath} data={data} functions={functions} />
}
console.error('[ROUTES] ❌ JSON page missing schemaPath:', page.id)
@@ -114,9 +121,18 @@ export function createRoutes(
: {}
if (page.type === 'json' || page.schemaPath) {
const jsonDataConfig = page.props?.data ?? page.props?.state
const jsonFunctionsConfig = page.props?.functions ?? page.props?.actions
const jsonData = jsonDataConfig
? resolveProps({ state: jsonDataConfig }, stateContext, actionContext)
: {}
const jsonFunctions = jsonFunctionsConfig
? resolveProps({ actions: jsonFunctionsConfig }, stateContext, actionContext)
: {}
return {
path: `/${page.id}`,
element: renderJsonPage(page)
element: renderJsonPage(page, jsonData, jsonFunctions)
}
}
@@ -168,9 +184,18 @@ export function createRoutes(
: {}
if (rootPage.type === 'json' || rootPage.schemaPath) {
const jsonDataConfig = rootPage.props?.data ?? rootPage.props?.state
const jsonFunctionsConfig = rootPage.props?.functions ?? rootPage.props?.actions
const jsonData = jsonDataConfig
? resolveProps({ state: jsonDataConfig }, stateContext, actionContext)
: {}
const jsonFunctions = jsonFunctionsConfig
? resolveProps({ actions: jsonFunctionsConfig }, stateContext, actionContext)
: {}
routes.push({
path: '/',
element: renderJsonPage(rootPage)
element: renderJsonPage(rootPage, jsonData, jsonFunctions)
})
} else if (!rootPage.component) {
console.error('[ROUTES] ❌ Root page missing component:', rootPage.id)