sozsoft-platform/ui/src/components/visualDesigner/codeGenerator.ts
2026-08-07 00:19:34 +03:00

694 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
getDesignerCollectionProperty,
getDesignerTabSlotValue,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER,
type DesignerDocument,
type DesignerNode,
} from './types'
const safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}`
}
const staticValueExpression = (value: unknown): string => {
if (typeof value === 'string') {
return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
}
if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]`
if (value && typeof value === 'object') {
return `{ ${Object.entries(value)
.map(([key, item]) => `${JSON.stringify(key)}: ${staticValueExpression(item)}`)
.join(', ')} }`
}
if (value === undefined) return 'undefined'
return JSON.stringify(value)
}
const serializeValue = (value: unknown) => `{${staticValueExpression(value)}}`
const bindingExpression = (node: DesignerNode, propertyName: string, itemVariable?: string) => {
const binding = node.bindings?.[propertyName]
if (!binding?.sourceId) return ''
const path = binding.path.trim()
if (itemVariable && (path === '$item' || path.startsWith('$item.'))) {
return `getByPath(${itemVariable}, ${JSON.stringify(path === '$item' ? '' : path.slice(6))})`
}
return `getByPath(data_${safeIdentifier(binding.sourceId)}, ${JSON.stringify(binding.path || '')})`
}
interface RuntimeStateSpec {
propertyName: string
eventName: string
stateName: string
setterName: string
initialValue: unknown
}
const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
const configured: Record<string, { propertyName: string; eventName: string }> = {
AutoComplete: { propertyName: 'value', eventName: 'onInputChange' },
Dropdown: { propertyName: 'activeKey', eventName: 'onSelect' },
Input: { propertyName: 'value', eventName: 'onChange' },
ImageViewer: { propertyName: 'activeIndex', eventName: 'onIndexChange' },
Menu: { propertyName: 'defaultActiveKeys', eventName: 'onSelect' },
Pagination: { propertyName: 'currentPage', eventName: 'onChange' },
'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
Select: { propertyName: 'value', eventName: 'onChange' },
Steps: { propertyName: 'current', eventName: 'onChange' },
Tabs: { propertyName: 'value', eventName: 'onChange' },
Upload: { propertyName: 'fileList', eventName: 'onChange' },
}
const fallback =
'checked' in node.props
? { propertyName: 'checked', eventName: 'onChange' }
: 'value' in node.props
? { propertyName: 'value', eventName: 'onChange' }
: null
const state = configured[node.type] || fallback
if (!state || node.bindings?.[state.propertyName]?.sourceId) return null
let initialValue = node.props[state.propertyName]
if (node.type === 'Select') {
const options = Array.isArray(node.props.options) ? node.props.options : []
initialValue = node.props.isMulti
? options.filter(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
Array.isArray(node.props.value) &&
node.props.value.includes((option as Record<string, unknown>).value),
)
: options.find(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
(option as Record<string, unknown>).value === node.props.value,
) || null
} else if (node.type === 'Menu') {
initialValue = normalizeDesignerKeyList(initialValue)
} else if (node.type === 'Tabs') {
initialValue = initialValue === undefined || initialValue === null ? '' : String(initialValue)
} else if (node.type === 'Pagination') {
initialValue = Number(initialValue) || 1
} else if (initialValue === undefined) {
initialValue = state.propertyName === 'checked' ? false : ''
}
const identifier = safeIdentifier(node.id)
return {
...state,
stateName: `runtime_${identifier}_${safeIdentifier(state.propertyName)}`,
setterName: `setRuntime_${identifier}_${safeIdentifier(state.propertyName)}`,
initialValue,
}
}
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type)
/**
* Options expression for a data component: the endpoint binding mapped through
* `toSelectOptions` (honouring the configured label/value columns), otherwise the
* static collection maintained in the designer.
*/
const optionCollectionExpression = (node: DesignerNode, itemVariable?: string) => {
const collectionProperty = getDesignerCollectionProperty(node.type)
const binding = node.bindings?.[collectionProperty]
const boundExpression = binding?.sourceId
? bindingExpression(node, collectionProperty, itemVariable)
: ''
if (boundExpression) {
return `toSelectOptions(${boundExpression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
}
const staticOptions = node.props[collectionProperty]
return staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
}
/** Node stripped of its collection prop/binding; those are rendered as children. */
const withoutCollection = (node: DesignerNode) => {
const collectionProperty = getDesignerCollectionProperty(node.type)
const props = { ...node.props }
const bindings = { ...(node.bindings || {}) }
delete props[collectionProperty]
delete bindings[collectionProperty]
return { node: { ...node, props, bindings }, props, bindings }
}
const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: string[] = []) => {
const omitted = new Set(omitProperties)
const runtimeState = getRuntimeStateSpec(node)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim())
const props = Object.entries(node.props)
.filter(
([key, value]) =>
key !== 'children' &&
key !== runtimeState?.propertyName &&
!omitted.has(key) &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
!node.bindings?.[key]?.sourceId &&
value !== '' &&
value !== undefined,
)
.map(([key, value]) =>
// Dates live in the document as ISO strings; pickers need Date instances.
isDesignerDateProperty(node.type, key)
? `${key}={toDesignerDate(${staticValueExpression(value)})}`
: `${key}=${serializeValue(value)}`,
)
Object.keys(node.bindings || {}).forEach((propertyName) => {
if (propertyName === 'children') return
const expression = bindingExpression(node, propertyName, itemVariable)
if (expression) {
// Label/value columns come from the bound property itself; the collection
// is named `items` on some data components and `options` on others.
const binding = node.bindings?.[propertyName]
const propertyExpression = isOptionCollectionProperty(node, propertyName)
? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
: expression
props.push(`${propertyName}={${propertyExpression}}`)
}
})
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
if (runtimeState && !omitted.has(runtimeState.propertyName)) {
props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
}
const handlerNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName),
)
if (runtimeState) handlerNames.add(runtimeState.eventName)
handlerNames.forEach((eventName) =>
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`),
)
// Spread first so anything configured on the node overrides the defaults.
const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : []
const allProps = [...leading, ...props]
return allProps.length ? ` ${allProps.join(' ')}` : ''
}
const indent = (text: string, level: number) =>
text
.split('\n')
.map((line) => `${' '.repeat(level)}${line}`)
.join('\n')
const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): string => {
if (node.type === 'Spacer') {
return indent(
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
level,
)
}
if (node.type === 'FlexRow') {
const style = `{ display: "flex", gap: ${Number(node.props.gap) || 0}, flexWrap: "${node.props.wrap ? 'wrap' : 'nowrap'}", alignItems: "${String(node.props.align || 'stretch')}" }`
const className = node.props.className
? ` className=${JSON.stringify(node.props.className)}`
: ''
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable))
.join('\n')
return `${indent(`<div${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
}
if (
node.type === 'PageContainer' ||
node.type === 'TwoColumns' ||
node.type === 'SidebarContent' ||
node.type === 'HeaderContent'
) {
const gap = Number(node.props.gap) || 0
const className = JSON.stringify(String(node.props.className || ''))
const style =
node.type === 'PageContainer'
? `{ display: "flex", flexDirection: "column", gap: ${gap}, margin: "0 auto", maxWidth: ${JSON.stringify(String(node.props.maxWidth || '1280px'))}, padding: ${Number(node.props.padding) || 0}, width: "100%" }`
: node.type === 'HeaderContent'
? `{ display: "flex", flexDirection: "column", gap: ${gap} }`
: `{ display: "grid", gap: ${gap}, gridTemplateColumns: ${JSON.stringify(node.type === 'SidebarContent' ? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)` : 'repeat(2, minmax(0, 1fr))')} }`
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable))
.join('\n')
return `${indent(`<div className=${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
}
if (node.type === 'Table') {
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
const columnCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.columnCount) || 3)))
const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}`
const cellChildren = node.children.filter(
(child, childIndex) =>
(child.slot ||
`table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`) === slot,
)
const content = cellChildren.length
? cellChildren.map((child) => nodeToCode(child, level + 3, itemVariable)).join('\n')
: indent('<span />', level + 3)
return `${indent('<td>', level + 2)}\n${content}\n${indent('</td>', level + 2)}`
}).join('\n')
return `${indent('<tr>', level + 1)}\n${cells}\n${indent('</tr>', level + 1)}`
}).join('\n')
const className = JSON.stringify(String(node.props.className || ''))
return `${indent(`<UiKit.Table className={${className} + " [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5"} borderlessRow compact={${Boolean(node.props.compact)}} hoverable={${node.props.hoverable !== false}} overflow={${node.props.overflow !== false}} style={{ minWidth: "100%", tableLayout: "fixed", width: "100%" }}>`, level)}
${indent('<tbody>', level + 1)}
${rows}
${indent('</tbody>', level + 1)}
${indent('</UiKit.Table>', level)}`
}
if (node.type === 'Grid') {
const columns = Number(node.props.cols) || 3
const gap = (Number(node.props.gap) || 0) * 4
const itemsExpression =
bindingExpression(node, 'items', itemVariable) ||
(Array.isArray(node.props.items) ? JSON.stringify(node.props.items) : '')
const itemsBinding = node.bindings?.items
const sourceRootExpression =
!itemVariable && itemsBinding?.sourceId ? `data_${safeIdentifier(itemsBinding.sourceId)}` : ''
const collectionExpression =
sourceRootExpression && sourceRootExpression !== itemsExpression
? `(Array.isArray(${itemsExpression}) ? ${itemsExpression} : Array.isArray(${sourceRootExpression}) ? ${sourceRootExpression} : null)`
: itemsExpression
const gridClassName = JSON.stringify(String(node.props.className || ''))
const gridStart = `<div className=${gridClassName} style={{ display: "grid", gridTemplateColumns: "repeat(${columns}, minmax(0, 1fr))", gap: ${gap} }}>`
if (collectionExpression && node.children.length === 0) {
const identifier = safeIdentifier(node.id)
const itemsVariable = `gridItems_${identifier}`
const columnsVariable = `gridColumns_${identifier}`
const itemVariable = `gridItem_${identifier}`
const valueVariable = `cellValue_${identifier}`
const configuredColumns = Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
: null
const columnsExpression = configuredColumns
? JSON.stringify(configuredColumns)
: `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]`
const compact = Boolean(node.props.compact)
const borderlessRow = Boolean(node.props.borderlessRow)
const hoverable = node.props.hoverable !== false
const overflow = node.props.overflow !== false
const tableTextClass = compact ? 'text-[11px]' : 'text-xs'
const cellSpacingClass = compact ? 'px-2 py-1' : 'px-3 py-2'
const rowClass = [
borderlessRow ? '' : 'border-b border-slate-100 last:border-b-0 dark:border-slate-800',
hoverable ? 'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70' : '',
]
.filter(Boolean)
.join(' ')
const overflowClass = overflow ? 'overflow-auto' : 'overflow-visible'
const table = `${indent('{(() => {', level + 1)}
${indent(`const ${itemsVariable} = ${collectionExpression}`, level + 2)}
${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)}
${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)}
${indent('return (', level + 2)}
${indent(`<div className="min-w-0 ${overflowClass} rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900" style={{ gridColumn: "1 / -1" }}>`, level + 3)}
${indent(`<table className="w-full border-collapse text-left ${tableTextClass}">`, level + 4)}
${indent('<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200"><tr>', level + 5)}
${indent(`{${columnsVariable}.map((column) => <th key={column} className="whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${cellSpacingClass}">{column === "value" ? "Value" : column}</th>)}`, level + 6)}
${indent('</tr></thead>', level + 5)}
${indent('<tbody>', level + 5)}
${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, level + 7)}
${indent(`{${columnsVariable}.map((column) => {`, level + 8)}
${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)}
${indent(`const text = typeof ${valueVariable} === "object" && ${valueVariable} !== null ? JSON.stringify(${valueVariable}) : String(${valueVariable} ?? "") || "—"`, level + 9)}
${indent(`return <td key={column} className="max-w-64 truncate text-slate-800 dark:text-slate-100 ${cellSpacingClass}" title={text}>{text}</td>`, level + 9)}
${indent('})}', level + 8)}
${indent('</tr>', level + 7)}
${indent('))}', level + 6)}
${indent('</tbody>', level + 5)}
${indent('</table>', level + 4)}
${indent('</div>', level + 3)}
${indent(')', level + 2)}
${indent('})()}', level + 1)}`
return `${indent(gridStart, level)}\n${table}\n${indent('</div>', level)}`
}
const repeatedItemVariable = `item_${safeIdentifier(node.id)}`
const staticChildren = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable))
.join('\n')
const repeatedChildren = node.children.length
? node.children.map((child) => nodeToCode(child, level + 3, repeatedItemVariable)).join('\n')
: indent(
`<pre className="min-w-0 overflow-auto rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}</pre>`,
level + 3,
)
const children = collectionExpression
? `${indent(`{Array.isArray(${collectionExpression}) ? ${collectionExpression}.map((${repeatedItemVariable}, index) => (`, level + 1)}
${indent(`<React.Fragment key={${repeatedItemVariable}?.id ?? index}>`, level + 2)}
${repeatedChildren}
${indent('</React.Fragment>', level + 2)}
${indent(')) : (', level + 1)}
${indent('<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>', level + 2)}
${indent(')}', level + 1)}`
: staticChildren
return `${indent(gridStart, level)}\n${children}\n${indent('</div>', level)}`
}
if (node.kind === 'platform') {
const viewNames: Record<string, string> = {
ListView: 'List',
DataGridView: 'Grid',
TreeView: 'Tree',
GanttView: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
PivotView: 'Pivot',
ChartView: 'Chart',
}
return indent(
`<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))} />`,
level,
)
}
if (node.type === 'Menu') {
const { node: menuNode, props: menuProps } = withoutCollection(node)
delete menuProps.variant
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const menuPropsCode = propsToCode(menuNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
}
if (node.type === 'Dropdown') {
const { node: dropdownNode, props: dropdownProps, bindings } = withoutCollection(node)
// Rendered from the active option below, so `title` must not also be emitted
// by propsToCode — whether it is static or bound to an endpoint field.
const boundTitle = bindings.title?.sourceId
? bindingExpression(node, 'title', itemVariable)
: ''
const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '')
delete dropdownProps.title
delete bindings.title
const dropdownPropsCode = propsToCode(dropdownNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
const activeKeyExpression =
getRuntimeStateSpec(node)?.stateName ||
bindingExpression(node, 'activeKey', itemVariable) ||
staticValueExpression(node.props.activeKey ?? '')
const titleExpression = `toDropdownTitle(${optionsExpression}, ${activeKeyExpression}, ${fallbackTitleExpression})`
return `${indent(`<UiKit.Dropdown${dropdownPropsCode} title={${titleExpression}}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
}
if (node.type === 'Pagination') {
const { node: paginationNode } = withoutCollection(node)
const paginationPropsCode = propsToCode(paginationNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
return indent(
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
level,
)
}
if (node.type === 'Tabs') {
const { node: tabsNode } = withoutCollection(node)
// `value` is emitted below, resolved against the (possibly async) tab list.
const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'])
const identifier = safeIdentifier(node.id)
const optionsVariable = `tabItems_${identifier}`
const activeVariable = `tabValue_${identifier}`
const optionVariable = `tabItem_${identifier}`
const activeStateExpression =
getRuntimeStateSpec(node)?.stateName ||
bindingExpression(node, 'value', itemVariable) ||
staticValueExpression(node.props.value ?? '')
const childrenVariable = `tabChildren_${identifier}`
const fallbackVariable = `tabFallback_${identifier}`
// Children are assigned to a tab through a `tab:<value>` slot. Building a
// lookup keyed by tab value keeps this working when the tab list itself is
// only known at runtime (endpoint driven tabs).
const slotGroups = new Map<string, DesignerNode[]>()
node.children.forEach((child) => {
const key = getDesignerTabSlotValue(child.slot)
const group = slotGroups.get(key)
if (group) group.push(child)
else slotGroups.set(key, [child])
})
const fragmentFor = (children: DesignerNode[], fragmentLevel: number) =>
`${indent('<>', fragmentLevel)}\n${children
.map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable))
.join('\n')}\n${indent('</>', fragmentLevel)}`
const slottedEntries = [...slotGroups]
.filter(([key]) => key !== '')
.map(
([key, children]) =>
`${indent(`${JSON.stringify(key)}: (`, level + 2)}\n${fragmentFor(children, level + 3)}\n${indent('),', level + 2)}`,
)
.join('\n')
// Children saved before per-tab slots existed belong to the first tab.
const fallbackChildren = slotGroups.get('') || []
return `${indent('{(() => {', level)}
${indent(`const ${optionsVariable} = ${optionCollectionExpression(node, itemVariable)}`, level + 1)}
${indent(`const ${activeVariable} = toTabValue(${optionsVariable}, ${activeStateExpression})`, level + 1)}
${indent(`const ${childrenVariable} = {`, level + 1)}
${slottedEntries}
${indent('}', level + 1)}
${indent(`const ${fallbackVariable} = (`, level + 1)}
${fallbackChildren.length ? fragmentFor(fallbackChildren, level + 2) : indent('null', level + 2)}
${indent(')', level + 1)}
${indent('return (', level + 1)}
${indent(`<UiKit.Tabs${tabsPropsCode} value={${activeVariable}}>`, level + 2)}
${indent('<UiKit.Tabs.TabList>', level + 3)}
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => <UiKit.Tabs.TabNav key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)} disabled={Boolean(${optionVariable}.disabled)}>{String(${optionVariable}.label ?? ${optionVariable}.value ?? \`Sekme \${tabIndex + 1}\`)}</UiKit.Tabs.TabNav>)}`, level + 4)}
${indent('</UiKit.Tabs.TabList>', level + 3)}
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => (`, level + 3)}
${indent(`<UiKit.Tabs.TabContent key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)}>`, level + 4)}
${indent(`{${optionVariable}.content ? <div>{String(${optionVariable}.content)}</div> : null}`, level + 5)}
${indent(`{${childrenVariable}[String(${optionVariable}.value ?? tabIndex)] ?? null}`, level + 5)}
${indent(`{tabIndex === 0 ? ${fallbackVariable} : null}`, level + 5)}
${indent('</UiKit.Tabs.TabContent>', level + 4)}
${indent('))}', level + 3)}
${indent('</UiKit.Tabs>', level + 2)}
${indent(')', level + 1)}
${indent('})()}', level)}`
}
if (node.type === 'Radio.Group') {
const { node: groupNode, props: groupProps } = withoutCollection(node)
delete groupProps.checked
delete groupProps.defaultChecked
delete groupProps.readOnly
const groupPropsCode = propsToCode(groupNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
}
const tag =
node.type === 'checkbox'
? 'input'
: node.kind === 'ui'
? `UiKit.${node.type}`
: node.kind === 'custom'
? safeIdentifier(node.type)
: node.type
const props = { ...node.props }
if (node.type === 'checkbox') props.type = 'checkbox'
const normalizedNode = { ...node, props }
const propCode = propsToCode(normalizedNode, itemVariable)
const childrenText = String(node.props.children ?? '')
const childrenBinding = bindingExpression(node, 'children', itemVariable)
const hasChildren = node.children.length > 0 || childrenText.length > 0 || !!childrenBinding
const voidElement = ['input', 'img'].includes(tag)
if (voidElement) return indent(`<${tag}${propCode} />`, level)
if (!hasChildren) return indent(`<${tag}${propCode} />`, level)
const nested = node.children.map((child) => nodeToCode(child, level + 1, itemVariable)).join('\n')
const text = childrenBinding
? indent(`{${childrenBinding}}`, level + 1)
: childrenText
? indent(`{${staticValueExpression(childrenText)}}`, level + 1)
: ''
const body = [text, nested].filter(Boolean).join('\n')
return `${indent(`<${tag}${propCode}>`, level)}\n${body}\n${indent(`</${tag}>`, level)}`
}
export const generateDesignerCode = (name: string, document: DesignerDocument) => {
const componentName = safeIdentifier(name || 'VisualComponent')
const handlers: string[] = []
const runtimeStateHooks: string[] = []
let hasSelect = false
let hasDataTable = false
let hasDropdown = false
let hasSelectComponent = false
let hasDatePicker = false
let hasTabs = false
const visit = (nodes: DesignerNode[]) => {
nodes.forEach((node) => {
if (isDesignerOptionComponent(node.type)) hasSelect = true
if (node.type === 'Select') hasSelectComponent = true
if (node.type === 'Dropdown') hasDropdown = true
if (node.type === 'Grid') hasDataTable = true
if (node.type === 'Tabs') hasTabs = true
if (isDesignerDateComponent(node.type)) hasDatePicker = true
const runtimeState = getRuntimeStateSpec(node)
if (runtimeState) {
// Date pickers hold a Date in state but an ISO string in the document.
const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName)
? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)})`
: JSON.stringify(runtimeState.initialValue)
runtimeStateHooks.push(
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
)
}
const eventNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName),
)
if (runtimeState) eventNames.add(runtimeState.eventName)
eventNames.forEach((eventName) => {
const script = node.events[eventName]?.trim() || ''
const stateUpdate =
runtimeState?.eventName === eventName
? node.type === 'Select'
? `${runtimeState.setterName}(valueOrEvent)`
: node.type === 'AutoComplete'
? `${runtimeState.setterName}(valueOrEvent ?? "")`
: node.type === 'Menu'
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
: node.type === 'Tabs'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Dropdown'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Pagination'
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
: node.type === 'Steps' || node.type === 'ImageViewer'
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
: node.type === 'Upload'
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
: runtimeState.propertyName === 'checked'
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
: node.type === 'Input' || node.kind === 'html'
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
: `${runtimeState.setterName}(valueOrEvent)`
: ''
const eventDeclaration =
node.type === 'Checkbox' && eventName === 'onChange'
? ' const event = { checked: Boolean(valueOrEvent), originalEvent, target: originalEvent?.target }'
: ' const event = valueOrEvent'
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = (valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`,
)
})
visit(node.children)
})
}
visit(document.nodes)
const dataSources = document.dataSources || []
const selectHelpers = hasSelect
? ` const toSelectOptions = (value, labelPath = "", valuePath = "") => {
if (!Array.isArray(value)) return []
return value.map((item, index) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) return { label: String(item ?? ""), value: item ?? index }
const primitiveKeys = Object.keys(item).filter((key) => item[key] === null || ["string", "number", "boolean"].includes(typeof item[key]))
const labelKey = ["label", "children", "name", "title", "text", "description"].find((key) => item[key] !== undefined)
const valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined)
const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]]
const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? primitiveKeys[0]]
return { ...item, label: String(optionLabel ?? \`Seçenek \${index + 1}\`), value: optionValue ?? index }
})
}`
: ''
// Dropdown paints its toggle from `title`; `activeKey` only marks the item
// inside the closed menu, so the label of the active option is resolved here.
const dropdownHelpers = hasDropdown
? ` const toDropdownTitle = (options, activeKey, fallbackTitle) => {
const key = activeKey === undefined || activeKey === null ? "" : String(activeKey)
const selected = key ? options.find((option) => String(option?.value ?? "") === key) : undefined
if (selected) return String(selected.label ?? selected.value ?? key)
return String(fallbackTitle ?? "") || ${JSON.stringify(DESIGNER_DROPDOWN_PLACEHOLDER)}
}`
: ''
// Falls back to the first tab while an endpoint driven tab list is still empty
// or when the stored value no longer exists.
const tabHelpers = hasTabs
? ` const toTabValue = (options, value) => {
const key = value === undefined || value === null ? "" : String(value)
if (key && options.some((option) => String(option?.value ?? "") === key)) return key
return options.length ? String(options[0]?.value ?? "") : ""
}`
: ''
// Pickers check `instanceof Date`, but the document only stores ISO strings.
const dateHelpers = hasDatePicker
? ` const toDesignerDate = (value) => {
if (Array.isArray(value)) return value.map(toDesignerDate)
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value !== "string" || !value.trim()) return null
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}`
: ''
// Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table,
// Card) would cut it off. A body portal keeps the menu fully visible.
const selectMenuHelpers = hasSelectComponent
? ` const selectMenuProps = {
menuPosition: "fixed",
menuPortalTarget: typeof window === "undefined" ? null : window.document.body,
styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) },
}`
: ''
const dataHelpers =
dataSources.length || hasDataTable
? ` const getByPath = (value, path) => {
if (!path) return value
const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target)
const directValue = readPath(value, path)
if (directValue !== undefined) return directValue
if (path.startsWith('result.')) return readPath(value, path.slice('result.'.length))
return value?.result === undefined ? undefined : readPath(value.result, path)
}`
: ''
const dataHooks = dataSources
.map((source) => {
const identifier = safeIdentifier(source.id)
if (!source.url.trim().startsWith('/api/')) {
return ` const [data_${identifier}] = React.useState(null)`
}
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)
React.useEffect(() => {
let active = true
apiService.fetchData({ url: ${JSON.stringify(source.url)}, method: 'GET' }).then((response) => {
if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')}))
}).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error))
return () => { active = false }
}, [])`
})
.join('\n\n')
const mount = document.lifecycle.onMount.trim()
? ` React.useEffect(() => {\n${indent(document.lifecycle.onMount, 2)}\n }, [])`
: ''
const body = document.nodes.map((node) => nodeToCode(node, 3)).join('\n')
const designerBackup = encodeURIComponent(JSON.stringify(document))
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dropdownHelpers, selectMenuHelpers, dateHelpers, tabHelpers, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
}