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

1196 lines
62 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,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
hasSqlDataSourceUrlParams,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
isSqlDataSourceNode,
normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER,
SQL_DATA_SOURCE_SLOTS,
type DesignerDataSource,
type DesignerDocument,
type DesignerNode,
} from './types'
/**
* Record scope opened by a SqlDataSource. The record state is named
* `data_<nodeId>`, which is exactly what `bindingExpression` emits for a binding
* whose `sourceId` is the container's node id — so children bind to columns
* through the regular binding machinery and write back through `setterName`.
*/
interface FormScope {
sourceId: string
setterName: string
}
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,
}
}
/** Prop of a node that the enclosing SqlDataSource writes the record back from. */
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
if (!formScope) return null
const propertyName = (['value', 'checked'] as const).find(
(name) => node.bindings?.[name]?.sourceId === formScope.sourceId,
)
if (!propertyName) return null
return {
propertyName,
eventName: node.type === 'AutoComplete' ? 'onInputChange' : 'onChange',
path: node.bindings[propertyName].path,
setterName: formScope.setterName,
}
}
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[] = [],
formScope?: FormScope,
) => {
const omitted = new Set(omitProperties)
const runtimeState = getRuntimeStateSpec(node)
const formField = getFormScopeField(node, formScope)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
// Inside a SqlDataSource the bound value is editable: the change is written to
// the record, so the input must not be forced read-only.
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField)
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') &&
// A field the SqlDataSource writes back must stay editable, whatever the
// node happens to carry from the toolbox defaults.
!(formField && (key === 'readOnly' || key === 'disabled')) &&
!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)}, ${isDesignerDateOnlyComponent(node.type)})}`
: `${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 || '')})`
: // Pickers need a Date instance; an endpoint column is an ISO string.
isDesignerDateProperty(node.type, propertyName)
? `toDesignerDate(${expression}, ${isDesignerDateOnlyComponent(node.type)})`
: // Select is controlled by the option object, not by the raw column value.
node.type === 'Select' && propertyName === 'value'
? `toSelectValue(${optionCollectionExpression(node, itemVariable)}, ${expression}, ${Boolean(node.props.isMulti)})`
: // An empty record (New mode) would otherwise hand React `undefined`,
// which flips the field to uncontrolled and makes it unusable.
formField?.propertyName === propertyName
? `(${expression}) ?? ${propertyName === 'checked' ? 'false' : '""'}`
: 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)
if (formField) handlerNames.add(formField.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 sqlIdentifiers = (node: DesignerNode) => {
const identifier = safeIdentifier(node.id)
return {
identifier,
record: `data_${identifier}`,
setRecord: `setData_${identifier}`,
rows: `sqlRows_${identifier}`,
busy: `sqlBusy_${identifier}`,
setBusy: `setSqlBusy_${identifier}`,
error: `sqlError_${identifier}`,
setError: `setSqlError_${identifier}`,
key: `sqlKey_${identifier}`,
keyParam: `sqlKeyParam_${identifier}`,
hasKey: `sqlHasKey_${identifier}`,
original: `sqlOriginal_${identifier}`,
setOriginal: `setSqlOriginal_${identifier}`,
mode: `sqlMode_${identifier}`,
setMode: `setSqlMode_${identifier}`,
index: `sqlIndex_${identifier}`,
setIndex: `setSqlIndex_${identifier}`,
selectUrl: `sqlSelectUrl_${identifier}`,
urlKey: `sqlUrlKey_${identifier}`,
setField: `sqlSetField_${identifier}`,
reload: `sqlReload_${identifier}`,
refresh: `sqlRefresh_${identifier}`,
create: `sqlNew_${identifier}`,
save: `sqlSave_${identifier}`,
remove: `sqlDelete_${identifier}`,
previous: `sqlPrev_${identifier}`,
next: `sqlNext_${identifier}`,
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
}
}
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
const sqlSlotLiteral = (
node: DesignerNode,
property: string,
method: string,
dataSources: DesignerDataSource[],
) => {
const source = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, property),
)
if (!source?.url.trim()) return 'null'
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
}
const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[]) => {
const names = sqlIdentifiers(node)
const keyField = getSqlDataSourceKeyField(node)
const selectSource = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
)
// The Select endpoint already owns a fetch hook, so the container reuses that
// state instead of issuing a second request for the same URL.
const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null'
// Must mirror the setter emitted by the data hooks, or the reload would call an
// identifier that was never declared.
const setSelectData =
selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/')
? `setData_${safeIdentifier(selectSource.id)}`
: ''
const keySource = getSqlDataSourceKeySource(node)
const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})`
const slots = SQL_DATA_SOURCE_SLOTS.map(
(slot) =>
` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`,
).join('\n')
return `${slots}
const ${names.key} = ${JSON.stringify(keyField)}
const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))}
const [${names.record}, ${names.setRecord}] = React.useState({})
const [${names.original}, ${names.setOriginal}] = React.useState({})
const [${names.mode}, ${names.setMode}] = React.useState("edit")
const [${names.index}, ${names.setIndex}] = React.useState(0)
const [${names.busy}, ${names.setBusy}] = React.useState(false)
const [${names.error}, ${names.setError}] = React.useState("")
// The Select key can come from the page URL, which is how a detail page reads
// /api/app/orders/{id} or /api/app/orders?id=… for a single record.
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
const ${names.selectUrl} = React.useMemo(() => {
if (!${names.slot('selectEndpoint')}) return ""
if (!${names.urlKey}) return ${names.slot('selectEndpoint')}.url
const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam})
return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey})
}, [${names.urlKey}])
// A key in the page URL always narrows the result: a list endpoint ignores the
// parameter server side, so the requested record is picked out here. Without a
// key every row is kept and the navigation below takes over.
const ${names.rows} = React.useMemo(
() => filterSqlRowsByKey(${rowsExpression}, ${names.key}, ${names.urlKey}),
[${selectData}, ${names.urlKey}],
)
// A fresh result set resets the position; New mode is left untouched so an
// unsaved draft is not overwritten by a re-render.
React.useEffect(() => {
${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0))
}, [${names.rows}])
React.useEffect(() => {
const row = ${names.rows}[${names.index}]
if (!row) return
${names.setRecord}(row)
${names.setOriginal}(row)
${names.setMode}("edit")
}, [${names.rows}, ${names.index}])
const ${names.hasKey} = (() => {
const value = readSqlField(${names.record}, ${names.key})
return value !== undefined && value !== null && value !== ""
})()
const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), [])
const ${names.reload} = React.useCallback(async () => {${
setSelectData
? `
if (!${names.selectUrl}) return
// Calling a URL that still holds a placeholder is a guaranteed 400, so the
// missing key is reported in the component instead.
if (hasSqlUrlParams(${names.selectUrl})) {
throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.")
}
const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" })
${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))`
: ''
}
}, [${setSelectData ? names.selectUrl : ''}])
// Reload wrapped with the busy/error handling the toolbar and mount effect need,
// so a failing Select is reported in the component instead of the console.
const ${names.refresh} = React.useCallback(async () => {
${names.setBusy}(true)
${names.setError}("")
try {
await ${names.reload}()
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
} finally {
${names.setBusy}(false)
}
}, [${names.reload}])
const ${names.create} = React.useCallback(() => {
${names.setError}("")
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
}, [])
const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), [])
const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}])
const ${names.save} = async () => {
const isNew = ${names.mode} === "new"
const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')}
if (!target) {
${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.")
return
}
// Update carries only the edited columns plus the key; Insert sends the record.
const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key})
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
${names.setError}("Kaydedilecek bir değişiklik yok.")
return
}
${names.setBusy}(true)
${names.setError}("")
try {
const response = await callSqlEndpoint(target, ${names.record}, ${names.key}, payload)
// With a Select endpoint the reload is the source of truth and its effect
// repopulates the form; without one, the response row is all there is.
if (${names.slot('selectEndpoint')}) {
await ${names.reload}()
} else {
const saved = toSqlRows(response?.data, "")[0]
if (saved) {
${names.setRecord}(saved)
${names.setOriginal}(saved)
}
}
${names.setMode}("edit")
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
} finally {
${names.setBusy}(false)
}
}
const ${names.remove} = async () => {
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return
// Deleting cannot be undone from the form, so it always asks first.
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
${names.setBusy}(true)
${names.setError}("")
try {
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
await ${names.reload}()
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
} finally {
${names.setBusy}(false)
}
}${
node.props.autoLoad === false
? ''
: `
React.useEffect(() => { void ${names.refresh}() }, [${names.refresh}])`
}`
}
const SQL_TOOLBAR_BUTTON_CLASS =
'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40'
const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
const names = sqlIdentifiers(node)
const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
.join('\n')
const className = JSON.stringify(String(node.props.className || ''))
const style = `{ display: "flex", flexDirection: "column", gap: ${Number(node.props.gap) || 0} }`
const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
// Navigation appears on its own whenever there is more than one record to walk.
const navigation = `
${indent(`{${names.rows}.length > 1 ? (`, level + 2)}
${indent('<>', level + 3)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 4)}
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 4)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 4)}
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 4)}
${indent('</>', level + 3)}
${indent(') : null}', level + 2)}`
const toolbar =
node.props.showToolbar === false
? ''
: `
${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 1)}${navigation}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 2)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 2)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 2)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 2)}
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 2)}
${indent('</div>', level + 1)}`
const error = `
${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 1)}`
return `${indent(`<div className=${className} style={${style}}>`, level)}
${children}${toolbar}${error}
${indent('</div>', level)}`
}
const nodeToCode = (
node: DesignerNode,
level = 0,
itemVariable?: string,
formScope?: FormScope,
): string => {
if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable)
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, formScope))
.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, formScope))
.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, formScope))
.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 && !(${valueVariable} instanceof Date) ? JSON.stringify(${valueVariable}) : formatLocaleValue(${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, formScope))
.join('\n')
const repeatedChildren = node.children.length
? node.children
.map((child) => nodeToCode(child, level + 3, repeatedItemVariable, formScope))
.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, [], formScope)
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, [], formScope)
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, [], formScope)
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'], formScope)
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, formScope))
.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, [], formScope)
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, [], formScope)
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, formScope))
.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 sqlDataSourceNodes: DesignerNode[] = []
/** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */
const sqlManagedSelectSourceIds = new Set<string>()
const visit = (nodes: DesignerNode[], formScope?: FormScope) => {
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
if (isSqlDataSourceNode(node.type)) {
sqlDataSourceNodes.push(node)
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
if (selectId) sqlManagedSelectSourceIds.add(selectId)
}
const formField = getFormScopeField(node, formScope)
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)}, ${isDesignerDateOnlyComponent(node.type)})`
: 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)
if (formField) eventNames.add(formField.eventName)
eventNames.forEach((eventName) => {
const script = node.events[eventName]?.trim() || ''
// A form scoped field replaces the local state update: the edit belongs
// to the SqlDataSource record, which is what Save posts back.
const formUpdate =
formField?.eventName === eventName
? `${formField.setterName}(${JSON.stringify(formField.path)}, ${
node.type === 'Select'
? 'Array.isArray(valueOrEvent) ? valueOrEvent.map((option) => option?.value ?? option) : (valueOrEvent?.value ?? null)'
: formField.propertyName === 'checked'
? 'typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent)'
: isDesignerDateComponent(node.type)
? `fromDesignerDate(valueOrEvent ?? null, ${isDesignerDateOnlyComponent(node.type)})`
: node.type === 'AutoComplete'
? 'valueOrEvent ?? ""'
: 'valueOrEvent?.target?.value ?? valueOrEvent'
})`
: ''
const stateUpdate =
formUpdate ||
(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,
isSqlDataSourceNode(node.type)
? { sourceId: node.id, setterName: sqlIdentifiers(node).setField }
: formScope,
)
})
}
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 }
})
}`
: ''
// A bound Select receives a raw column value, but react-select is controlled by
// the option object itself.
const selectValueHelpers = hasSelectComponent
? ` const toSelectValue = (options, value, isMulti) => {
if (isMulti) return options.filter((option) => Array.isArray(value) && value.includes(option.value))
return options.find((option) => option.value === value) ?? null
}`
: ''
// 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 ?? "") : ""
}`
: ''
// Grid cells print raw endpoint columns, so dates and decimals are localised
// here — the culture is published on `<html lang>` because a runtime compiled
// component has neither hooks nor imports to reach the store.
const localeHelpers = hasDataTable
? ` const localeCulture = () => (typeof document !== "undefined" && document.documentElement.lang) || "en"
const localeDateOptions = { year: "numeric", month: "2-digit", day: "2-digit" }
const localeTimeOptions = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }
const localeIsoPattern = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/
const formatLocaleValue = (value) => {
if (value === null || value === undefined) return ""
const culture = localeCulture()
if (typeof value === "number") {
if (!Number.isFinite(value)) return String(value)
return new Intl.NumberFormat(culture, { useGrouping: false, maximumFractionDigits: 20 }).format(value)
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? "" : new Intl.DateTimeFormat(culture, localeTimeOptions).format(value)
}
if (typeof value !== "string") return String(value)
const parts = localeIsoPattern.exec(value.trim())
if (!parts) return value
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
if (parts[4] === undefined) {
const day = new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
return new Intl.DateTimeFormat(culture, localeDateOptions).format(day)
}
return new Intl.DateTimeFormat(culture, localeTimeOptions).format(parsed)
}`
: ''
// Pickers check `instanceof Date`, but the document only stores ISO strings.
// Both directions stay on the local wall clock: a date column carries no
// timezone, so converting through UTC would move 29/07 to 28/07.
const dateHelpers = hasDatePicker
? ` const sqlPad = (value) => String(value).padStart(2, "0")
const isoDateTime = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2}))?)?/
const toDesignerDate = (value, dateOnly) => {
if (Array.isArray(value)) return value.map((entry) => toDesignerDate(entry, dateOnly))
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value !== "string" || !value.trim()) return null
const parts = isoDateTime.exec(value.trim())
const midnight = parts && dateOnly && !Number(parts[4]) && !Number(parts[5]) && !Number(parts[6])
if (parts && (parts[4] === undefined || midnight)) {
return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const fromDesignerDate = (value, dateOnly) => {
if (Array.isArray(value)) return value.map((entry) => fromDesignerDate(entry, dateOnly))
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return null
const day = value.getFullYear() + "-" + sqlPad(value.getMonth() + 1) + "-" + sqlPad(value.getDate())
if (dateOnly) return day
return day + "T" + sqlPad(value.getHours()) + ":" + sqlPad(value.getMinutes()) + ":" + sqlPad(value.getSeconds())
}
return typeof value === "string" && value.trim() ? value : null
}`
: ''
// 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 }) },
}`
: ''
// Shared SqlDataSource runtime: row extraction, record writes, URL parameter
// binding and the actual Insert/Update/Delete calls.
const sqlHelpers = sqlDataSourceNodes.length
? ` const readSqlField = (record, field) => {
if (!record || typeof record !== "object" || Array.isArray(record)) return undefined
if (field in record) return record[field]
const matched = Object.keys(record).find((key) => key.toLowerCase() === String(field).toLowerCase())
return matched === undefined ? undefined : record[matched]
}
const toSqlRows = (value, collectionPath) => {
const source = collectionPath ? getByPath(value, collectionPath) : value
const rows = Array.isArray(source)
? source
: source && typeof source === "object"
? (Array.isArray(source.items) ? source.items : [source])
: []
return rows.filter((item) => item && typeof item === "object" && !Array.isArray(item))
}
const setSqlField = (record, path, value) => {
const keys = String(path || "").split(".").filter(Boolean)
if (!keys.length) return record
const next = { ...(record || {}) }
let target = next
for (let index = 0; index < keys.length - 1; index += 1) {
const child = target[keys[index]]
target[keys[index]] = child && typeof child === "object" && !Array.isArray(child) ? { ...child } : {}
target = target[keys[index]]
}
target[keys[keys.length - 1]] = value
return next
}
const bindSqlUrl = (url, record, keyField) => {
let keyBound = false
const boundUrl = String(url).replace(/\\{([^}]+)\\}|(?<=\\/):([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, colon) => {
const name = (braced || colon || "").trim()
if (!name) return match
const value = readSqlField(record, name) ?? readSqlField(record, keyField)
if (value === undefined || value === null || value === "") return match
if (name.toLowerCase() === String(keyField).toLowerCase()) keyBound = true
return encodeURIComponent(String(value))
})
return { url: boundUrl, keyBound }
}
const appendQueryParam = (url, name, value) =>
url + (url.includes("?") ? "&" : "?") + encodeURIComponent(name) + "=" + encodeURIComponent(value)
const hasSqlUrlParams = (url) => /\\{[^}]+\\}|(?<=\\/):[A-Za-z_][A-Za-z0-9_]*/.test(String(url))
const readUrlKey = (source, name) => {
if (typeof window === "undefined") return ""
if (source === "query") return new URLSearchParams(window.location.search).get(name) ?? ""
const segments = window.location.pathname.split("/").filter(Boolean)
return segments.length ? decodeURIComponent(segments[segments.length - 1]) : ""
}
const filterSqlRowsByKey = (rows, keyField, keyValue) => {
if (!keyValue) return rows
const matched = rows.filter((row) => String(readSqlField(row, keyField) ?? "") === String(keyValue))
// No match means the endpoint already filtered server side, or the key column
// is named differently — keeping the rows beats showing an empty form.
return matched.length ? matched : rows
}
// Update payload: only the columns the user actually edited, plus the key.
const toSqlChanges = (original, current, keyField) => {
const changes = {}
const isSame = (left, right) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
Object.keys(current || {}).forEach((column) => {
if (!isSame(original ? original[column] : undefined, current[column])) changes[column] = current[column]
})
const keyColumn = Object.keys(current || {}).find((column) => column.toLowerCase() === String(keyField).toLowerCase())
if (keyColumn !== undefined && changes[keyColumn] === undefined) changes[keyColumn] = current[keyColumn]
return changes
}
const callSqlEndpoint = (target, record, keyField, payload) => {
// The URL is always bound from the full record: a placeholder may reference a
// column that the update payload does not carry.
const bound = bindSqlUrl(target.url, record, keyField)
const request = { url: bound.url, method: target.method }
if (target.method === "POST" || target.method === "PUT") request.data = payload === undefined ? record : payload
const keyValue = readSqlField(record, keyField)
const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ""
// The key still has to reach the endpoint when the URL carries no placeholder.
if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue }
return apiService.fetchData(request)
}
const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."`
: ''
const sqlHooks = sqlDataSourceNodes
.map((node) => sqlDataSourceHooks(node, dataSources))
.join('\n\n')
const dataHelpers =
dataSources.length || hasDataTable || sqlDataSourceNodes.length
? ` 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)
// POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command;
// they hold no readable state and must never be written to.
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) {
return ` const [data_${identifier}] = React.useState(null)`
}
// A GetById style URL is not fetched on mount — requesting a literal `{id}`
// is a guaranteed 400 — and neither is a source the owning SqlDataSource
// loads itself. Both still need the setter that container writes through.
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
return ` const [data_${identifier}, setData_${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, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, dataHooks, sqlHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
}