sozsoft-platform/ui/src/components/visualDesigner/codeGenerator.ts

416 lines
22 KiB
TypeScript
Raw Normal View History

2026-08-05 20:51:43 +00:00
import { normalizeDesignerKeyList, type DesignerDocument, type DesignerNode } from './types'
const EVENT_NAMES: Record<string, string> = {
onClick: 'event',
onChange: 'event',
onFocus: 'event',
onBlur: 'event',
onMouseEnter: 'event',
onMouseLeave: 'event',
}
const safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}`
}
const serializeValue = (value: unknown) => {
if (typeof value === 'string') return `{${JSON.stringify(value)}}`
if (typeof value === 'number' || typeof value === 'boolean') return `{${String(value)}}`
if (value === null) return '{null}'
if (Array.isArray(value) || typeof value === 'object') return `{${JSON.stringify(value)}}`
return JSON.stringify(String(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 || '')})`
}
const propsToCode = (node: DesignerNode, itemVariable?: string) => {
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' &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
!node.bindings?.[key]?.sourceId &&
value !== '' &&
value !== undefined,
)
.map(([key, value]) => {
if (node.type === 'Checkbox' && key === 'checked') {
return `defaultChecked=${serializeValue(value)}`
}
if (node.type === 'Input' && key === 'value') {
return `defaultValue=${serializeValue(value)}`
}
if (node.type === 'Select' && key === 'value') {
const staticOptions = Array.isArray(node.props.options) ? node.props.options : []
const selectedOption = staticOptions.find(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
(option as Record<string, unknown>).value === value,
)
return `defaultValue=${serializeValue(selectedOption ?? null)}`
}
return `${key}=${serializeValue(value)}`
})
Object.keys(node.bindings || {}).forEach((propertyName) => {
if (propertyName === 'children') return
const expression = bindingExpression(node, propertyName, itemVariable)
if (expression) {
const propertyExpression =
['Select', 'AutoComplete'].includes(node.type) && propertyName === 'options'
? `toSelectOptions(${expression}, ${JSON.stringify(node.bindings.options?.labelPath || '')}, ${JSON.stringify(node.bindings.options?.valuePath || '')})`
: expression
props.push(`${propertyName}={${propertyExpression}}`)
}
})
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
Object.entries(node.events)
.filter(([, script]) => script.trim())
.forEach(([eventName]) => {
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`)
})
return props.length ? ` ${props.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" 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 legacyData = Array.isArray(node.props.data) ? node.props.data : []
const staticItems =
Array.isArray(node.props.items) && (node.props.items.length > 0 || !legacyData.length)
? node.props.items
: legacyData
const itemsExpression =
bindingExpression(node, 'items', itemVariable) ||
bindingExpression(node, 'data', itemVariable) ||
JSON.stringify(staticItems)
const identifier = safeIdentifier(node.id)
const itemsVariable = `tableItems_${identifier}`
const columnsVariable = `tableColumns_${identifier}`
const rowVariable = `tableRow_${identifier}`
const configuredColumns = Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
: Array.isArray(node.props.columns)
? node.props.columns
.map((column) => {
if (typeof column === 'string') return column
if (!column || typeof column !== 'object' || Array.isArray(column)) return ''
const record = column as Record<string, unknown>
return String(record.dataIndex ?? record.key ?? '')
})
.filter(Boolean)
: null
const columnsExpression = configuredColumns
? JSON.stringify(configuredColumns)
: `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]`
return `${indent('{(() => {', level)}
${indent(`const ${itemsVariable} = ${itemsExpression}`, level + 1)}
${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">Table items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 1)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 1)}
${indent('return (', level + 1)}
${indent(`<UiKit.Table borderlessRow={${Boolean(node.props.borderlessRow)}} compact={${Boolean(node.props.compact)}} hoverable={${node.props.hoverable !== false}} overflow={${node.props.overflow !== false}}>`, level + 2)}
${indent(`<thead><tr>{${columnsVariable}.map((column) => <th key={column}>{column === "value" ? "Value" : column}</th>)}</tr></thead>`, level + 3)}
${indent(`<tbody>{${itemsVariable}.map((${rowVariable}, rowIndex) => <tr key={rowIndex}>{${columnsVariable}.map((column) => { const value = column === "value" ? ${rowVariable} : getByPath(${rowVariable}, column); return <td key={column}>{typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? "")}</td> })}</tr>)}</tbody>`, level + 3)}
${indent('</UiKit.Table>', level + 2)}
${indent(')', level + 1)}
${indent('})()}', 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 { items: staticOptions = [], variant: _variant, ...menuProps } = node.props
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const { items: optionsBinding, ...menuBindings } = node.bindings || {}
const menuNode = { ...node, props: menuProps, bindings: menuBindings }
const menuPropsCode = propsToCode(menuNode, itemVariable)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: JSON.stringify(Array.isArray(staticOptions) ? staticOptions : [])
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)}`
}
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(`{${JSON.stringify(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[] = []
let hasSelect = false
let hasDataTable = false
const visit = (nodes: DesignerNode[]) => {
nodes.forEach((node) => {
if (node.type === 'Select' || node.type === 'AutoComplete' || node.type === 'Menu') {
hasSelect = true
}
if (node.type === 'Grid' || node.type === 'Table') hasDataTable = true
Object.entries(node.events).forEach(([eventName, script]) => {
if (!script.trim()) return
if (node.type === 'Checkbox' && eventName === 'onChange') {
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = (checked, originalEvent) => {\n const event = { checked, originalEvent, target: originalEvent?.target }\n${indent(script, 2)}\n }`,
)
return
}
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = (${EVENT_NAMES[eventName] || 'event'}) => {\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", "name", "title", "text", "description"].find((key) => item[key] !== undefined)
const valueKey = ["value", "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 }
})
}`
: ''
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, dataHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
}