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

1900 lines
96 KiB
TypeScript
Raw Normal View History

import {
collectSqlDefaultFields,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerTabSlotValue,
getDesignerDataSourceFilters,
getDesignerNodeFilters,
getSqlDataSourceColumnCount,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
getSqlDataSourceScriptedEvents,
getSqlFormValueProperty,
hasSqlDataSourceUrlParams,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
isSqlDataSourceNode,
isValuelessDesignerFilter,
DESIGNER_FILTER_DX_OPERATORS,
toDesignerFilterParamName,
normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER,
SQL_DATA_SOURCE_SLOTS,
SQL_DEFAULT_VALUE_PROP,
type DesignerDataSource,
type DesignerDataSourceFilter,
type DesignerDocument,
type DesignerNode,
} from './types'
2026-08-05 20:51:43 +00:00
/**
* 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
}
2026-08-05 20:51:43 +00:00
const safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}`
}
/**
* Whether the emitted component carries the `refs` runtime. It is switched on by
* `generateDesignerCode` for the whole pass when a script actually addresses
* another component, so a page that uses no refs keeps its previous output byte
* for byte instead of paying for a state hook and a guard around every node.
*/
let refRuntimeEnabled = false
/** Prop that turns a component off; react-select spells it differently. */
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')
/** A script with `await` in it has to be wrapped in an async function. */
export const usesAwait = (script: string) => /\bawait\b/.test(script || '')
const refPropsExpression = (ref: string) => `designerRefProps(${JSON.stringify(ref)})`
const isRefControlled = (node: DesignerNode) => refRuntimeEnabled && Boolean(node.ref)
2026-08-06 13:17:59 +00:00
const staticValueExpression = (value: unknown): string => {
if (typeof value === 'string') {
return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
2026-08-06 13:17:59 +00:00
}
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)
2026-08-05 20:51:43 +00:00
}
2026-08-06 13:17:59 +00:00
const serializeValue = (value: unknown) => `{${staticValueExpression(value)}}`
2026-08-05 20:51:43 +00:00
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' },
2026-08-06 13:17:59 +00:00
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' },
2026-08-06 13:17:59 +00:00
Steps: { propertyName: 'current', eventName: 'onChange' },
Tabs: { propertyName: 'value', eventName: 'onChange' },
2026-08-06 13:17:59 +00:00
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 = getSqlFormValueProperty(node, formScope.sourceId)
if (!propertyName) return null
return {
propertyName,
eventName: node.type === 'AutoComplete' ? 'onInputChange' : 'onChange',
path: node.bindings[propertyName].path,
setterName: formScope.setterName,
sourceId: formScope.sourceId,
}
}
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)
2026-08-05 20:51:43 +00:00
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)
2026-08-05 20:51:43 +00:00
const props = Object.entries(node.props)
.filter(
([key, value]) =>
key !== 'children' &&
key !== runtimeState?.propertyName &&
// Seeds a new SqlDataSource record; it is not a prop of the component.
key !== SQL_DEFAULT_VALUE_PROP &&
!omitted.has(key) &&
2026-08-05 20:51:43 +00:00
!(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')) &&
2026-08-05 20:51:43 +00:00
!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)}`,
)
2026-08-05 20:51:43 +00:00
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
2026-08-05 20:51:43 +00:00
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}}`),
)
2026-08-05 20:51:43 +00:00
// Spread first so anything configured on the node overrides the defaults.
const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : []
// …and the ref overrides spread last, because a script that disables or
// refills a component has to win over both the defaults and the design values.
const trailing = isRefControlled(node) ? [`{...${refPropsExpression(node.ref as string)}}`] : []
const allProps = [...leading, ...props, ...trailing]
return allProps.length ? ` ${allProps.join(' ')}` : ''
2026-08-05 20:51:43 +00:00
}
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}`,
filters: `sqlFilters_${identifier}`,
filterKey: `sqlFilterKey_${identifier}`,
urlKey: `sqlUrlKey_${identifier}`,
setField: `sqlSetField_${identifier}`,
reload: `sqlReload_${identifier}`,
refresh: `sqlRefresh_${identifier}`,
create: `sqlNew_${identifier}`,
draft: `sqlDraft_${identifier}`,
host: `sqlHost_${identifier}`,
save: `sqlSave_${identifier}`,
remove: `sqlDelete_${identifier}`,
previous: `sqlPrev_${identifier}`,
next: `sqlNext_${identifier}`,
// Mirrors of the record/mode state, so an event handler declared further down
// the component body reads the current value instead of a closed over one.
recordRef: `sqlRecordRef_${identifier}`,
modeRef: `sqlModeRef_${identifier}`,
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
}
}
/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */
const sqlEventHandler = (node: DesignerNode, eventName: string) =>
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : ''
const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0
/**
* `await`ed call of an event script, emitted only when the event carries one.
* `payload` is the object literal the script sees as `event`.
*/
const sqlEventCall = (node: DesignerNode, eventName: string, payload: string) => {
const handler = sqlEventHandler(node, eventName)
return handler ? `await ${handler}(${payload})` : ''
}
/**
* Cancelable variant: the command stops when the script returns exactly `false`,
* so a script that returns nothing (the common case) never blocks anything.
*/
const sqlCancelableEventCall = (node: DesignerNode, eventName: string, payload: string) => {
const handler = sqlEventHandler(node, eventName)
return handler ? `if ((await ${handler}(${payload})) === false) return` : ''
}
/** Same, but inside a `try` that already owns the busy flag — needs a reset. */
const sqlErrorEventCall = (node: DesignerNode, action: string) => {
const handler = sqlEventHandler(node, 'onError')
const names = sqlIdentifiers(node)
return handler
? `\n await ${handler}({ message: toSqlErrorMessage(error), action: ${JSON.stringify(action)}, error, record: ${names.recordRef}.current })`
: ''
}
/**
* Runtime expression a filter reads its value from. A `record` filter addresses
* another SqlDataSource by its ref, which is what turns a master container and a
* detail Grid into a filtered pair without a line of script.
*/
const filterValueExpression = (
filter: DesignerDataSourceFilter,
sqlRecordByRef: Map<string, string>,
) => {
if (filter.source === 'static') return JSON.stringify(filter.value)
if (filter.source === 'query' || filter.source === 'route') {
return `readDesignerUrlValue(${JSON.stringify(filter.source)}, ${JSON.stringify(filter.value)})`
}
const [ref, ...rest] = String(filter.value).split('.')
const record = sqlRecordByRef.get(ref)
const column = rest.join('.')
// A ref that no longer exists resolves to nothing; a required filter then
// cancels the request instead of loading the whole collection.
if (!record || !column) return '""'
return `readSqlField(${record}, ${JSON.stringify(column)})`
}
/**
* Filter descriptor array `buildPlatformFilterParams` consumes. A platform view
* is filtered through a DevExtreme expression rather than through query
* parameters, so the operator is emitted in its DevExtreme form.
*/
const platformFilterEntriesExpression = (
node: DesignerNode,
sqlRecordByRef: Map<string, string>,
) => {
const filters = getDesignerNodeFilters(node)
if (!filters.length) return ''
return `[${filters
.map(
(filter) =>
`{ field: ${JSON.stringify(filter.field)}, operator: ${JSON.stringify(
DESIGNER_FILTER_DX_OPERATORS[filter.operator],
)}, kind: ${JSON.stringify(filter.operator)}, value: ${
isValuelessDesignerFilter(filter) ? '""' : filterValueExpression(filter, sqlRecordByRef)
}, required: ${Boolean(filter.required)} }`,
)
.join(', ')}]`
}
/** Filter descriptor array `buildFilteredUrl` consumes, or `''` when there is none. */
const filterEntriesExpression = (
source: DesignerDataSource | undefined,
sqlRecordByRef: Map<string, string>,
) => {
const filters = getDesignerDataSourceFilters(source)
if (!filters.length) return ''
return `[${filters
.map(
(filter) =>
`{ param: ${JSON.stringify(toDesignerFilterParamName(filter))}, value: ${
isValuelessDesignerFilter(filter) ? '"true"' : filterValueExpression(filter, sqlRecordByRef)
}, required: ${Boolean(filter.required)} }`,
)
.join(', ')}]`
}
/** `{ 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[],
sqlRecordByRef: Map<string, string>,
) => {
const names = sqlIdentifiers(node)
const keyField = getSqlDataSourceKeyField(node)
const selectSource = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
)
const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef)
// 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("")
// Container element, so New can hand the cursor to the first field of the form.
const ${names.host} = React.useRef(null)${
hasSqlEvents(node)
? `
// Event scripts are declared after these hooks, so they read the record and the
// mode through refs rather than through the closure they were created in.
const ${names.recordRef} = React.useRef(${names.record})
${names.recordRef}.current = ${names.record}
const ${names.modeRef} = React.useRef(${names.mode})`
: ''
}
// 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})
${
selectFilters
? ` // Column filters configured on the Select source. They are rebuilt on every
// render so a master record change reaches the URL, and the memo below is keyed
// on their serialized form rather than on each value.
const ${names.filters} = ${selectFilters}
const ${names.filterKey} = JSON.stringify(${names.filters})
`
: ''
} const ${names.selectUrl} = React.useMemo(() => {
if (!${names.slot('selectEndpoint')}) return ""
const base = ${names.urlKey}
? (() => {
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.slot('selectEndpoint')}.url
${
selectFilters
? `// An unmet required filter yields no URL at all, which stops the Select
// from loading the unfiltered collection.
return buildFilteredUrl(base, ${names.filters})`
: 'return base'
}
}, [${names.urlKey}${selectFilters ? `, ${names.filterKey}` : ''}])
// 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}])${
sqlEventHandler(node, 'onLoad')
? `
// Fires once the Select result has settled, whether it carries rows or not —
// an empty result is exactly the case a script wants to react to.
React.useEffect(() => {
if (${selectData} === null || ${selectData} === undefined) return
void ${sqlEventHandler(node, 'onLoad')}({ rows: ${names.rows}, count: ${names.rows}.length, record: ${names.rows}[${names.index}] ?? {}, index: ${names.index} })
}, [${names.rows}])`
: ''
}
React.useEffect(() => {
const row = ${names.rows}[${names.index}]
if (!row) return
${names.setRecord}(row)
${names.setOriginal}(row)
${names.setMode}("edit")${
sqlEventHandler(node, 'onRecordChange')
? `
void ${sqlEventHandler(node, 'onRecordChange')}({ record: row, index: ${names.index}, rows: ${names.rows}, count: ${names.rows}.length })`
: ''
}
}, [${names.rows}, ${names.index}])${
sqlEventHandler(node, 'onModeChange')
? `
// The ref starts at the initial mode, so the first render is not reported as a
// change; only real new/edit transitions reach the script.
React.useEffect(() => {
if (${names.modeRef}.current === ${names.mode}) return
${names.modeRef}.current = ${names.mode}
void ${sqlEventHandler(node, 'onModeChange')}({ mode: ${names.mode}, record: ${names.recordRef}.current })
}, [${names.mode}])`
: ''
}
const ${names.hasKey} = (() => {
const value = readSqlField(${names.record}, ${names.key})
return value !== undefined && value !== null && value !== ""
})()
const ${names.setField} = React.useCallback(${
sqlEventHandler(node, 'onFieldChange')
? `(path, value) => {
${names.setRecord}((current) => setSqlField(current, path, value))
// The script is handed the record with the write already applied, so a
// calculation can read the sibling columns without re-deriving the change.
void ${sqlEventHandler(node, 'onFieldChange')}({ field: path, value, previous: getByPath(${names.recordRef}.current, path), record: setSqlField(${names.recordRef}.current, path, value) })
}`
: `(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))${sqlErrorEventCall(node, 'reload')}
} finally {
${names.setBusy}(false)
}
}, [${names.reload}])
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
${names.setError}("")
// Defaults are resolved on every New, so an @today column carries the day the
// record was opened rather than the day the component was generated.
const ${names.draft} = buildSqlDefaults(${JSON.stringify(collectSqlDefaultFields(node))})
${names.setRecord}(${names.draft})
${names.setOriginal}({})
${names.setMode}("new")
// After the re-render that paints the empty form, not before it.
focusFirstSqlField(${names.host})${
sqlEventHandler(node, 'onNewRecord')
? `
${sqlEventCall(node, 'onNewRecord', `{ record: ${names.draft} }`)}`
: ''
}
}, [])
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
}${
sqlEventHandler(node, 'onBeforeSave')
? `
// Runs before the busy flag is raised: a vetoed save must leave the form in
// exactly the state the user left it in.
${sqlCancelableEventCall(node, 'onBeforeSave', `{ record: ${names.record}, original: ${names.original}, payload, mode: ${names.mode}, isNew }`)}`
: ''
}
${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")${
sqlEventHandler(node, 'onAfterSave')
? `
${sqlEventCall(node, 'onAfterSave', `{ record: ${names.record}, response: response?.data, mode: ${names.mode}, isNew }`)}`
: ''
}
} catch (error) {
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'save')}
} finally {
${names.setBusy}(false)
}
}
const ${names.remove} = async () => {
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
sqlEventHandler(node, 'onBeforeDelete')
? `
// Asked before the confirm dialog, so a script can block the delete without
// the user ever being prompted.
${sqlCancelableEventCall(node, 'onBeforeDelete', `{ record: ${names.record}, key: readSqlField(${names.record}, ${names.key}) }`)}`
: ''
}
// 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 {${
sqlEventHandler(node, 'onAfterDelete')
? `
const deleted = ${names.record}`
: ''
}
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
await ${names.reload}()${
sqlEventHandler(node, 'onAfterDelete')
? `
${sqlEventCall(node, 'onAfterDelete', '{ record: deleted }')}`
: ''
}
} catch (error) {
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'delete')}
} 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 columnCount = getSqlDataSourceColumnCount(node)
const gap = Number(node.props.gap) || 0
// Children sit one level deeper once they are wrapped in the column grid.
const children = node.children
.map((child) => nodeToCode(child, level + (columnCount > 1 ? 3 : 2), itemVariable, formScope))
.join('\n')
const className = JSON.stringify(String(node.props.className || ''))
const style = `{ display: "flex", flexDirection: "column", gap: ${gap} }`
// The toolbar and the error line stay in the outer column, so only the fields
// are laid out side by side. A single column emits no wrapper at all, which
// keeps the markup of every page saved before the setting existed unchanged.
const gridStyle = `{ display: "grid", gridTemplateColumns: "repeat(${columnCount}, minmax(0, 1fr))", gap: ${gap}, alignItems: "start" }`
const content =
columnCount > 1
? `${indent(`<div style={${gridStyle}}>`, level + 2)}\n${children}\n${indent('</div>', level + 2)}`
: children
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 + 3)}
${indent('<>', level + 4)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 5)}
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 5)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 5)}
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 5)}
${indent('</>', level + 4)}
${indent(') : null}', level + 3)}`
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 + 2)}${navigation}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 3)}
${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 + 3)}
${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 + 3)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 3)}
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 3)}
${indent('</div>', level + 2)}`
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 + 2)}`
// A filled Title turns the container into a Card header; a `::` value is a
// localization key, so `staticValueExpression` emits a translate() call for it
// and the header follows the active language. The header extra carries the key
// of the record on screen.
const title = String(node.props.title ?? '').trim()
const headerExtra = `{${names.hasKey} ? ${names.key} + ": " + readSqlField(${names.record}, ${names.key}) : ${names.mode} === "new" ? "Yeni kayıt" : ""}`
const cardStart = title
? `<UiKit.Card bodyClass="p-4" header={${staticValueExpression(title)}} headerExtra={<span className="text-xs text-slate-500 dark:text-slate-400">${headerExtra}</span>}>`
: '<UiKit.Card bodyClass="p-4">'
return `${indent(cardStart, level)}
${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level + 1)}
${content}${toolbar}${error}
${indent('</div>', level + 1)}
${indent('</UiKit.Card>', level)}`
}
/**
* Wraps a node in its visibility guard. Tabs is the one component emitted as a
* braced expression rather than an element, so its outer `{…}` is turned into
* parentheses inside the ternary the code is read as an expression, not as a
* JSX child.
*/
const withVisibilityGuard = (code: string, node: DesignerNode, level: number) => {
if (!isRefControlled(node)) return code
const trimmed = code.trim()
const body =
trimmed.startsWith('{') && trimmed.endsWith('}')
? code.replace(/^(\s*)\{/, '$1(').replace(/\}(\s*)$/, ')$1')
: code
return `${indent(`{designerRefHidden(${JSON.stringify(node.ref)}) ? null : (`, level)}
${body}
${indent(')}', level)}`
}
const nodeToCode = (
node: DesignerNode,
level = 0,
itemVariable?: string,
formScope?: FormScope,
): string =>
withVisibilityGuard(
renderNodeToCode(node, isRefControlled(node) ? level + 1 : level, itemVariable, formScope),
node,
level,
)
const renderNodeToCode = (
node: DesignerNode,
level = 0,
itemVariable?: string,
formScope?: FormScope,
): string => {
if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable)
2026-08-05 20:51:43 +00:00
if (node.type === 'Spacer') {
return indent(
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
2026-08-05 20:51:43 +00:00
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))
2026-08-05 20:51:43 +00:00
.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))
2026-08-05 20:51:43 +00:00
.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)}`
2026-08-05 20:51:43 +00:00
}
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(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)}
${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)}
${indent(`if (${itemsVariable} === null || ${itemsVariable} === undefined) return null`, level + 2)}
2026-08-05 20:51:43 +00:00
${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)}
2026-08-05 20:51:43 +00:00
${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))
2026-08-05 20:51:43 +00:00
.join('\n')
const repeatedChildren = node.children.length
? node.children
.map((child) => nodeToCode(child, level + 4, repeatedItemVariable, formScope))
.join('\n')
2026-08-05 20:51:43 +00:00
: 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 + 4,
2026-08-05 20:51:43 +00:00
)
// The collection is read once into a local: it is tested for three cases
// before it is mapped, and repeating the expression for each of them made
// the emitted line unreadable.
const repeatedRowsVariable = `rows_${safeIdentifier(node.id)}`
2026-08-05 20:51:43 +00:00
const children = collectionExpression
? `${indent('{(() => {', level + 1)}
${indent(`const ${repeatedRowsVariable} = ${collectionExpression}`, level + 2)}
${indent(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)}
${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)}
${indent(`if (${repeatedRowsVariable} === null || ${repeatedRowsVariable} === undefined) return null`, level + 2)}
${indent(`if (!Array.isArray(${repeatedRowsVariable})) 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(`return ${repeatedRowsVariable}.map((${repeatedItemVariable}, index) => (`, level + 2)}
${indent(`<React.Fragment key={${repeatedItemVariable}?.id ?? index}>`, level + 3)}
2026-08-05 20:51:43 +00:00
${repeatedChildren}
${indent('</React.Fragment>', level + 3)}
${indent('))', level + 2)}
${indent('})()}', level + 1)}`
2026-08-05 20:51:43 +00:00
: 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',
}
const platformParams = `platformParams_${safeIdentifier(node.id)}`
const hasFilters = getDesignerNodeFilters(node).length > 0
const host = `<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))}${hasFilters ? ` searchParams={${platformParams}}` : ''} />`
if (!hasFilters) return indent(host, level)
// A required filter without a value renders nothing: showing the unfiltered
// list instead would read as a filter that is not applied.
return `${indent(`{${platformParams} === null ? null : (`, level)}
${indent(host, level + 1)}
${indent(')}', level)}`
2026-08-05 20:51:43 +00:00
}
if (node.type === 'Menu') {
const { node: menuNode, props: menuProps } = withoutCollection(node)
delete menuProps.variant
2026-08-05 20:51:43 +00:00
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const menuPropsCode = propsToCode(menuNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable)
2026-08-05 20:51:43 +00:00
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)}`
}
2026-08-06 13:17:59 +00:00
if (node.type === 'Pagination') {
const { node: paginationNode } = withoutCollection(node)
const paginationPropsCode = propsToCode(paginationNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable)
2026-08-06 13:17:59 +00:00
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)}`
}
2026-08-05 20:51:43 +00:00
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)
2026-08-05 20:51:43 +00:00
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')
// Static text is emitted through the ref override so `setText` reaches it: a
// spread `children` prop loses to JSX children, which is what a label is.
const staticText = isRefControlled(node)
? `${refPropsExpression(node.ref as string)}.children ?? ${staticValueExpression(childrenText)}`
: staticValueExpression(childrenText)
2026-08-05 20:51:43 +00:00
const text = childrenBinding
? indent(`{${childrenBinding}}`, level + 1)
: childrenText
? indent(`{${staticText}}`, level + 1)
2026-08-05 20:51:43 +00:00
: ''
const body = [text, nested].filter(Boolean).join('\n')
return `${indent(`<${tag}${propCode}>`, level)}\n${body}\n${indent(`</${tag}>`, level)}`
}
/** Every script of the document, so ref usage can be detected before emitting. */
const collectDesignerScripts = (document: DesignerDocument) => {
const scripts: string[] = [document.lifecycle?.onMount || '']
const visit = (nodes: DesignerNode[]) =>
nodes.forEach((node) => {
Object.values(node.events || {}).forEach((script) => scripts.push(script || ''))
visit(node.children || [])
})
visit(document.nodes)
return scripts.join('\n')
}
interface RefTarget {
node: DesignerNode
/** Reads the node's current value, whatever holds it. */
getExpression: string
/** Statement that writes a new value back to that same holder. */
setStatement: string
extras: string[]
}
/**
* `refs.<name>` entry of a node: the value accessor is wired to whichever state
* actually owns the value the enclosing SqlDataSource record, the node's own
* runtime state, or the ref override store for a component that has neither.
*/
const refTargetFor = (
node: DesignerNode,
formField: ReturnType<typeof getFormScopeField>,
runtimeState: RuntimeStateSpec | null,
): RefTarget => {
const ref = node.ref as string
const extras: string[] = []
if (isSqlDataSourceNode(node.type)) {
const names = sqlIdentifiers(node)
extras.push(
` getRecord: () => ${names.record}`,
` setRecord: (record) => ${names.setRecord}(record || {})`,
` getField: (path) => getByPath(${names.record}, path)`,
` setField: (path, value) => ${names.setField}(path, value)`,
// A patch is the readable way to write several columns at once; each write
// still goes through setField so onFieldChange fires for every column.
` setFields: (patch) => Object.entries(patch || {}).forEach(([path, value]) => ${names.setField}(path, value))`,
` clearFields: (...paths) => paths.flat().forEach((path) => ${names.setField}(path, null))`,
` getOriginal: () => ${names.original}`,
` getChanges: () => toSqlChanges(${names.original}, ${names.record}, ${names.key})`,
` hasChanges: () => Object.keys(toSqlChanges(${names.original}, ${names.record}, ${names.key})).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length > 0`,
` getKey: () => readSqlField(${names.record}, ${names.key})`,
` getKeyField: () => ${names.key}`,
` getRows: () => ${names.rows}`,
` getRowCount: () => ${names.rows}.length`,
` getIndex: () => ${names.index}`,
` getMode: () => ${names.mode}`,
` isNew: () => ${names.mode} === "new"`,
` isBusy: () => ${names.busy}`,
` getError: () => ${names.error}`,
` setError: (message) => ${names.setError}(message == null ? "" : String(message))`,
` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`,
` nextRow: () => ${names.next}()`,
` prevRow: () => ${names.previous}()`,
` firstRow: () => ${names.setIndex}(0)`,
` lastRow: () => ${names.setIndex}(Math.max(0, ${names.rows}.length - 1))`,
` reload: () => ${names.refresh}()`,
` newRecord: () => ${names.create}()`,
` save: () => ${names.save}()`,
` remove: () => ${names.remove}()`,
)
return {
node,
getExpression: names.record,
setStatement: `${names.setRecord}(value)`,
extras,
}
}
const valueProperty =
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
const base = formField
? {
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
}
: runtimeState
? {
getExpression: runtimeState.stateName,
setStatement: `${runtimeState.setterName}(value)`,
}
: {
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
}
// Select/AutoComplete/Menu ekranda yalnızca Label gösterir, ama seçenek nesnesi
// kaynak satırın bütün sütunlarını taşır. Bu erişimciler sayesinde bir script
// görünmeyen sütunları okuyup başka bir komponente ya da kayda yazabilir.
if (isDesignerOptionComponent(node.type)) {
const optionsExpression = optionCollectionExpression(node)
const selected = `findDesignerOption(${optionsExpression}, ${base.getExpression})`
const columns = getDesignerBindingColumns(node.bindings?.[getDesignerCollectionProperty(node.type)])
extras.push(
` getOptions: () => ${optionsExpression}`,
` getSelectedOption: () => ${selected}`,
` getColumn: (path) => readDesignerOptionColumn(${selected}, path)`,
` getLabel: () => readDesignerOptionColumn(${selected}, "label") ?? ""`,
// Tasarımda bildirilen ek sütunlar; bir script hangi sütunların garanti
// altında olduğunu buradan öğrenebilir.
` getColumnNames: () => ${JSON.stringify(columns)}`,
)
}
return { node, ...base, extras }
}
/**
* The `refs` object handed to every event script and to `onMount`. Overrides are
* kept in one state map keyed by ref name, so a script can change any prop of any
* other component without that component needing a hook of its own.
*/
const refRuntimeCode = (targets: RefTarget[]) => {
const entries = targets
.map((target) => {
const ref = target.node.ref as string
const key = JSON.stringify(ref)
const disabledProperty = JSON.stringify(getDisabledProperty(target.node.type))
return ` ${key}: {
name: ${key},
type: ${JSON.stringify(target.node.type)},
getValue: () => ${target.getExpression},
setValue: (value) => { ${target.setStatement} },
getProps: () => designerRefProps(${key}),
setProps: (patch) => patchDesignerRef(${key}, { props: patch || {} }),
setProp: (property, value) => patchDesignerRef(${key}, { props: { [property]: value } }),
isVisible: () => !designerRefHidden(${key}),
setVisible: (visible) => patchDesignerRef(${key}, { hidden: visible === false }),
show: () => patchDesignerRef(${key}, { hidden: false }),
hide: () => patchDesignerRef(${key}, { hidden: true }),
isEnabled: () => (designerRefProps(${key})[${disabledProperty}] ?? ${JSON.stringify(Boolean(target.node.props[getDisabledProperty(target.node.type)]))}) !== true,
setEnabled: (enabled) => patchDesignerRef(${key}, { props: { [${disabledProperty}]: enabled === false } }),
setReadOnly: (readOnly) => patchDesignerRef(${key}, { props: { readOnly: readOnly !== false } }),
setText: (text) => patchDesignerRef(${key}, { props: { children: text } }),
reset: () => patchDesignerRef(${key}, { props: null, hidden: false }),${
target.extras.length ? `\n${target.extras.join(',\n')},` : ''
}
}`
})
.join(',\n')
// Only emitted when an option component is actually addressable by ref.
const optionHelpers = targets.some((target) => isDesignerOptionComponent(target.node.type))
? ` // Seçili option nesnesi: bir Select değeri kimi zaman option'ın kendisi, kimi
// zaman kayda yazılan ham value olur; ikisi de aynı nesneye çözülür.
const findDesignerOption = (options, value) => {
const list = Array.isArray(options) ? options : []
if (Array.isArray(value)) return value.map((item) => findDesignerOption(list, item)).filter(Boolean)
if (value && typeof value === "object") return value
return list.find((option) => option?.value === value)
?? list.find((option) => String(option?.value ?? "") === String(value ?? ""))
?? null
}
const readDesignerOptionColumn = (option, path) => {
if (Array.isArray(option)) return option.map((item) => readDesignerOptionColumn(item, path))
return String(path || "").split(".").filter(Boolean).reduce((current, key) => current?.[key], option)
}
`
: ''
return `${optionHelpers} // Component references. Every node on the canvas is addressable by its ref,
// so an event script can read or change another component without prop drilling.
const [designerRefState, setDesignerRefState] = React.useState({})
const designerRefProps = (name) => designerRefState[name]?.props || {}
const designerRefHidden = (name) => Boolean(designerRefState[name]?.hidden)
const patchDesignerRef = (name, patch) => setDesignerRefState((current) => ({
...current,
[name]: {
...current[name],
...patch,
// \`props: null\` resets the overrides; anything else is merged on top.
props: patch.props === null ? {} : { ...(current[name]?.props || {}), ...(patch.props || {}) },
},
}))
const refs = {
${entries}
}`
}
/**
* `api` and `notify`, the two helpers an event script needs to do something that
* is not just moving values between components. `apiService`, `toast` and
* `Notification` are already in the compiled component's scope, so this is only
* about giving the scripts a small, obvious surface over them.
*/
const SCRIPT_API_CODE = ` const api = {
get: (url, params) => apiService.fetchData({ url, method: "GET", params }).then((response) => response.data),
post: (url, data, params) => apiService.fetchData({ url, method: "POST", data, params }).then((response) => response.data),
put: (url, data, params) => apiService.fetchData({ url, method: "PUT", data, params }).then((response) => response.data),
patch: (url, data, params) => apiService.fetchData({ url, method: "PATCH", data, params }).then((response) => response.data),
delete: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
remove: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
request: (config) => apiService.fetchData(config).then((response) => response.data),
errorMessage: (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı.",
}`
const SCRIPT_NOTIFY_CODE = ` const notify = (message, type = "info") => toast.push(
<Notification type={type} duration={2500}>{typeof message === "string" ? message : JSON.stringify(message)}</Notification>,
{ placement: "bottom-end" },
)`
2026-08-05 20:51:43 +00:00
export const generateDesignerCode = (name: string, document: DesignerDocument) => {
const componentName = safeIdentifier(name || 'VisualComponent')
const scripts = collectDesignerScripts(document)
// Only pages whose scripts address a ref pay for the ref runtime.
refRuntimeEnabled = /\brefs\b/.test(scripts)
const scriptHelpers = [
/\bapi\b/.test(scripts) ? SCRIPT_API_CODE : '',
/\bnotify\b/.test(scripts) ? SCRIPT_NOTIFY_CODE : '',
]
.filter(Boolean)
.join('\n\n')
const refTargets: RefTarget[] = []
2026-08-05 20:51:43 +00:00
const handlers: string[] = []
const runtimeStateHooks: string[] = []
2026-08-05 20:51:43 +00:00
let hasSelect = false
let hasDataTable = false
let hasDropdown = false
let hasSelectComponent = false
let hasDatePicker = false
let hasTabs = false
const sqlDataSourceNodes: DesignerNode[] = []
/** Platform views carrying their own filters; they get a params hook each. */
const platformFilterNodes: DesignerNode[] = []
/** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */
const sqlManagedSelectSourceIds = new Set<string>()
2026-08-05 20:51:43 +00:00
const visit = (nodes: DesignerNode[], formScope?: FormScope) => {
2026-08-05 20:51:43 +00:00
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 (node.kind === 'platform' && getDesignerNodeFilters(node).length) {
platformFilterNodes.push(node)
}
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 (refRuntimeEnabled && node.ref) {
refTargets.push(refTargetFor(node, formField, runtimeState))
}
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})`,
)
}
// A SqlDataSource event is not a React prop: it is called by the container's
// own runtime with a single payload object, and always awaited so a script
// can veto a command or finish an API call before the save continues.
if (isSqlDataSourceNode(node.type)) {
getSqlDataSourceScriptedEvents(node).forEach((event) => {
const script = node.events[event.name].trim()
handlers.push(
` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`,
)
})
visit(node.children, { sourceId: node.id, setterName: sqlIdentifiers(node).setField })
return
}
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'
// A script that awaits an API call needs an async handler, otherwise the
// `await` is a syntax error the user only finds out about at compile time.
const asyncPrefix = usesAwait(script) ? 'async ' : ''
2026-08-05 20:51:43 +00:00
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = ${asyncPrefix}(valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`,
2026-08-05 20:51:43 +00:00
)
})
visit(
node.children,
isSqlDataSourceNode(node.type)
? { sourceId: node.id, setterName: sqlIdentifiers(node).setField }
: formScope,
)
2026-08-05 20:51:43 +00:00
})
}
visit(document.nodes)
const dataSources = document.dataSources || []
// Ref → record state variable of every SqlDataSource on the page. A `record`
// filter addresses its master through this map, so a detail source can be
// filtered by the row the container currently shows.
const sqlRecordByRef = new Map<string, string>(
sqlDataSourceNodes
.filter((node) => node.ref)
.map((node) => [String(node.ref), sqlIdentifiers(node).record] as const),
)
const hasDataSourceFilters = dataSources.some(
(source) => getDesignerDataSourceFilters(source).length > 0,
)
const hasFilters = hasDataSourceFilters || platformFilterNodes.length > 0
2026-08-05 20:51:43 +00:00
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)
2026-08-05 20:51:43 +00:00
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)
}
// A new record starts at the top of the form: the first enabled editor takes the
// cursor, so a Yeni click is immediately followed by typing.
const focusFirstSqlField = (host) => {
if (typeof window === "undefined") return
window.requestAnimationFrame(() => {
const field = host.current?.querySelector("input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])")
if (!field) return
field.focus()
if (typeof field.select === "function") field.select()
})
}
// Defaults declared on the bound children; only a new record starts from them.
const buildSqlDefaults = (fields) => fields.reduce((record, field) => setSqlField(record, field.path, resolveSqlDefault(field)), {})
const resolveSqlDefault = (field) => {
if (field.token !== "today" && field.token !== "now") return field.value
const now = new Date()
const pad = (value) => String(value).padStart(2, "0")
const day = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate())
if (field.token === "today" || field.dateOnly) return day
return day + "T" + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds())
}
const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."`
: ''
const filterHelpers = hasFilters
? ` // Value of a filter that reads the page URL: a query string parameter, or a
// path segment — the one after a segment named like the parameter, otherwise
// the last one, which is the /orders/{id} detail page shape.
const readDesignerUrlValue = (kind, name) => {
if (typeof window === "undefined") return ""
if (kind === "query") return new URLSearchParams(window.location.search).get(name) ?? ""
const segments = window.location.pathname.split("/").filter(Boolean)
if (!segments.length) return ""
const index = name ? segments.findIndex((segment) => segment.toLowerCase() === String(name).toLowerCase()) : -1
if (index >= 0 && index < segments.length - 1) return decodeURIComponent(segments[index + 1])
return decodeURIComponent(segments[segments.length - 1])
}
// Filters are appended as query parameters: \`Column\` for an equality and
// \`Column.operator\` for anything else, which is what the endpoint parses.
// Returns "" when a required filter has no value yet — the caller must skip the
// request instead of loading the unfiltered collection.
const buildFilteredUrl = (url, filters) => {
const parts = []
for (const filter of filters || []) {
const value = filter.value === undefined || filter.value === null ? "" : String(filter.value)
if (!value) {
if (filter.required) return ""
continue
}
parts.push(encodeURIComponent(filter.param) + "=" + encodeURIComponent(value))
}
if (!parts.length) return url
return url + (url.includes("?") ? "&" : "?") + parts.join("&")
}`
: ''
// A platform view is filtered through the same `filter` expression a list form
// page carries in its address, so an embedded view needs no change to the list
// form itself. Returns null when a required filter has no value yet.
const platformFilterHelpers = platformFilterNodes.length
? ` const buildPlatformFilterParams = (filters) => {
const expressions = []
for (const filter of filters || []) {
if (filter.kind === "isnull" || filter.kind === "notnull") {
expressions.push([filter.field, filter.operator, null])
continue
}
const value = filter.value === undefined || filter.value === null ? "" : String(filter.value)
if (!value) {
if (filter.required) return null
continue
}
if (filter.kind === "in") {
const entries = value.split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => [filter.field, "=", entry])
if (!entries.length) continue
expressions.push(entries.reduce((result, entry) => (result ? [result, "or", entry] : entry), null))
continue
}
expressions.push([filter.field, filter.operator, value])
}
const params = new URLSearchParams()
const merged = expressions.reduce((result, expression) => (result ? [result, "and", expression] : expression), null)
if (merged) params.set("filter", JSON.stringify(merged))
return params
}`
: ''
// Declared after the SqlDataSource hooks: a filter may read the record of the
// container the view sits next to.
const platformFilterHooks = platformFilterNodes
.map((node) => {
const identifier = safeIdentifier(node.id)
return ` const platformFilters_${identifier} = ${platformFilterEntriesExpression(node, sqlRecordByRef)}
const platformKey_${identifier} = JSON.stringify(platformFilters_${identifier})
// The views keep the params object in their own state and write back to it, so
// it must stay the same object until a filter value actually changes.
const platformParams_${identifier} = React.useMemo(() => buildPlatformFilterParams(platformFilters_${identifier}), [platformKey_${identifier}])`
})
.join('\n\n')
const sqlHooks = sqlDataSourceNodes
.map((node) => sqlDataSourceHooks(node, dataSources, sqlRecordByRef))
.join('\n\n')
2026-08-05 20:51:43 +00:00
const dataHelpers =
dataSources.length || hasDataTable || sqlDataSourceNodes.length
2026-08-05 20:51:43 +00:00
? ` 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)
}`
: ''
// The state of every source is declared before the SqlDataSource hooks, which
// write through the setters; the fetch effects come after them, because a
// filtered source may read the record a container owns.
const dataStateHooks = dataSources
2026-08-05 20:51:43 +00:00
.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/')) {
2026-08-05 20:51:43 +00:00
return ` const [data_${identifier}] = React.useState(null)`
}
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)`
})
.join('\n')
const dataFetchHooks = dataSources
.map((source) => {
const identifier = safeIdentifier(source.id)
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) return ''
// 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, through the setter declared above.
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
return ''
}
const entries = filterEntriesExpression(source, sqlRecordByRef)
// Without filters the source keeps its previous mount-only fetch, byte for
// byte; with them the effect re-runs whenever a filter value changes.
if (!entries) {
return ` React.useEffect(() => {
2026-08-05 20:51:43 +00:00
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 }
}, [])`
}
return ` const filters_${identifier} = ${entries}
const filterKey_${identifier} = JSON.stringify(filters_${identifier})
React.useEffect(() => {
const url = buildFilteredUrl(${JSON.stringify(source.url)}, filters_${identifier})
// An unmet required filter leaves the component empty on purpose.
if (!url) {
setData_${identifier}(null)
return
}
let active = true
apiService.fetchData({ 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 }
}, [filterKey_${identifier}])`
2026-08-05 20:51:43 +00:00
})
.filter(Boolean)
2026-08-05 20:51:43 +00:00
.join('\n\n')
// Declared after the data/SQL/runtime state it reads, and before the handlers
// and the mount effect that call into it.
const refRuntime = refTargets.length ? refRuntimeCode(refTargets) : ''
// An effect callback must not return a promise, so an awaiting onMount is run
// through an async IIFE instead of being made async itself.
2026-08-05 20:51:43 +00:00
const mount = document.lifecycle.onMount.trim()
? usesAwait(document.lifecycle.onMount)
? ` React.useEffect(() => {\n void (async () => {\n${indent(document.lifecycle.onMount, 3)}\n })()\n }, [])`
: ` React.useEffect(() => {\n${indent(document.lifecycle.onMount, 2)}\n }, [])`
2026-08-05 20:51:43 +00:00
: ''
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, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, filterHelpers, platformFilterHelpers, dataStateHooks, sqlHooks, dataFetchHooks, platformFilterHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
2026-08-05 20:51:43 +00:00
}