sozsoft-platform/ui/src/components/visualDesigner/codeGenerator.ts
2026-08-20 11:50:16 +03:00

1949 lines
100 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 {
collectSqlDefaultFields,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerTabSlotValue,
getDesignerDataSourceFilters,
getDesignerNodeFilters,
getSqlDataSourceColumnCount,
getDesignerColumnCaptions,
getUsableDesignerColumnLookups,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
getSqlDataSourceScriptedEvents,
getSqlFormValueProperty,
hasSqlDataSourceUrlParams,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
DESIGNER_ICON_PROPERTIES,
DESIGNER_SELECT_COMPONENT_PROPERTY,
flexRowStyle,
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'
/**
* 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}`
}
/**
* 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)
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 = 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)
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 &&
// Seeds a new SqlDataSource record; it is not a prop of the component.
key !== SQL_DEFAULT_VALUE_PROP &&
!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 &&
// A zero width or height is the toolbox default, not a request for a zero
// sized element - emitting it would collapse the image to nothing.
!((key === 'width' || key === 'height') && value === 0) &&
value !== '' &&
value !== undefined,
)
.map(([key, value]) =>
// An icon is stored as a name; PlatformIcon turns it back into an element at
// runtime and renders nothing for an unknown one.
key === DESIGNER_SELECT_COMPONENT_PROPERTY && typeof value === 'string'
? // The name is resolved at runtime, where the react-select flavours live.
`${key}={PlatformSelectComponent(${JSON.stringify(value)})}`
: DESIGNER_ICON_PROPERTIES.has(key) && typeof value === 'string'
? `${key}={<PlatformIcon name=${JSON.stringify(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}'] : []
// …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(' ')}` : ''
}
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)
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 = JSON.stringify(flexRowStyle(node.props))
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') {
const gap = Number(node.props.gap) || 0
const className = JSON.stringify(String(node.props.className || ''))
const style = `{ 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%" }`
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
// Columns configured to show a related text instead of the raw key. The
// option rows come from a data source of the page, which already has its
// own state and mount fetch, so nothing extra is requested for them.
const lookupsVariable = `gridLookups_${identifier}`
const lookupEntries = Object.entries(getUsableDesignerColumnLookups(node)).filter(
([column]) => !configuredColumns || configuredColumns.includes(column),
)
// Header overrides. `staticValueExpression` turns a `::` value into a
// translate() call, so a caption follows the active language.
const captionsVariable = `gridCaptions_${identifier}`
const captionEntries = Object.entries(getDesignerColumnCaptions(node)).filter(
([column]) => !configuredColumns || configuredColumns.includes(column),
)
const captionsLiteral = `{ ${captionEntries
.map(([column, caption]) => `${JSON.stringify(column)}: ${staticValueExpression(caption)}`)
.join(', ')} }`
const lookupsLiteral = `{ ${lookupEntries
.map(
([column, lookup]) =>
`${JSON.stringify(column)}: { rows: data_${safeIdentifier(lookup.sourceId)}, path: ${JSON.stringify(lookup.path)}, value: ${JSON.stringify(lookup.valueField)}, text: ${JSON.stringify(lookup.textField)} }`,
)
.join(', ')} }`
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)}
${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)}
${captionEntries.length ? `${indent(`const ${captionsVariable} = ${captionsLiteral}`, level + 2)}\n` : ''}${lookupEntries.length ? `${indent(`const ${lookupsVariable} = ${lookupsLiteral}`, level + 2)}\n` : ''}${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}">{${captionEntries.length ? `${captionsVariable}[column] ?? ` : ''}(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)}
${lookupEntries.length ? `${indent(`const lookedUp = resolveLookupText(${lookupsVariable}[column], ${valueVariable})`, level + 9)}\n` : ''}${indent(`const cellText = ${lookupEntries.length ? `lookedUp === undefined ? ${valueVariable} : lookedUp` : valueVariable}`, level + 9)}
${indent(`const text = typeof cellText === "object" && cellText !== null && !(cellText instanceof Date) ? JSON.stringify(cellText) : formatLocaleValue(cellText) || "—"`, 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 + 4, 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 + 4,
)
// 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)}`
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)}
${repeatedChildren}
${indent('</React.Fragment>', level + 3)}
${indent('))', 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',
}
const platformParams = `platformParams_${safeIdentifier(node.id)}`
const hasFilters = getDesignerNodeFilters(node).length > 0
// The class list is only emitted when it carries something, so an untouched
// view keeps generating exactly the markup it did before.
const platformClassName = String(node.props.className || '').trim()
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'))}${platformClassName ? ` className=${JSON.stringify(platformClassName)}` : ''}${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)}`
}
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)}>{option.icon ? <span className="mr-2 inline-flex items-center"><PlatformIcon name={String(option.icon)} /></span> : null}{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')
// 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)
const text = childrenBinding
? indent(`{${childrenBinding}}`, level + 1)
: childrenText
? indent(`{${staticText}}`, level + 1)
: ''
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" },
)`
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[] = []
const handlers: string[] = []
const runtimeStateHooks: string[] = []
let hasSelect = false
let hasDataTable = false
/** A Grid column shows a related text instead of its raw key. */
let hasColumnLookup = 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>()
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 (Object.keys(getUsableDesignerColumnLookups(node)).length) hasColumnLookup = 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 ' : ''
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = ${asyncPrefix}(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 || []
// 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
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 ?? "") : ""
}`
: ''
// A lookup column matches its raw value against the option rows of another
// endpoint and prints the related text. The key is stringified on both sides
// because an id arrives as a number in one response and as a string in the
// other often enough that a strict compare would silently show nothing.
const lookupHelpers = hasColumnLookup
? ` const resolveLookupText = (lookup, value) => {
if (!lookup || value === null || value === undefined || value === "") return undefined
const rows = getByPath(lookup.rows, lookup.path)
if (!Array.isArray(rows)) return undefined
const key = String(value)
const row = rows.find((item) => item && typeof item === "object" && String(getByPath(item, lookup.value) ?? "") === key)
if (!row) return undefined
const text = getByPath(row, lookup.text)
return text === null || text === undefined || text === "" ? undefined : text
}`
: ''
// 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')
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)
}`
: ''
// 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
.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)`
}
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(() => {
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}])`
})
.filter(Boolean)
.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.
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 }, [])`
: ''
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, lookupHelpers, 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`
}