import { collectSqlDefaultFields, getDesignerBindingColumns, getDesignerCollectionProperty, getDesignerTabSlotValue, getDesignerDataSourceFilters, getDesignerNodeFilters, getSqlDataSourceColumnCount, getSqlDataSourceEndpointId, getSqlDataSourceKeyField, getSqlDataSourceKeyParam, getSqlDataSourceKeySource, getSqlDataSourceScriptedEvents, getSqlFormValueProperty, hasSqlDataSourceUrlParams, isDesignerDateComponent, isDesignerDateOnlyComponent, isDesignerDateProperty, isDesignerOptionComponent, isSqlDataSourceNode, isValuelessDesignerFilter, DESIGNER_FILTER_DX_OPERATORS, toDesignerFilterParamName, normalizeDesignerKeyList, DESIGNER_DROPDOWN_PLACEHOLDER, SQL_DATA_SOURCE_SLOTS, SQL_DEFAULT_VALUE_PROP, type DesignerDataSource, type DesignerDataSourceFilter, type DesignerDocument, type DesignerNode, } from './types' /** * Record scope opened by a SqlDataSource. The record state is named * `data_`, 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 = { 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).value), ) : options.find( (option) => option && typeof option === 'object' && !Array.isArray(option) && (option as Record).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 && value !== '' && value !== undefined, ) .map(([key, value]) => // Dates live in the document as ISO strings; pickers need Date instances. isDesignerDateProperty(node.type, key) ? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}` : `${key}=${serializeValue(value)}`, ) Object.keys(node.bindings || {}).forEach((propertyName) => { if (propertyName === 'children') return const expression = bindingExpression(node, propertyName, itemVariable) if (expression) { // Label/value columns come from the bound property itself; the collection // is named `items` on some data components and `options` on others. const binding = node.bindings?.[propertyName] const propertyExpression = isOptionCollectionProperty(node, propertyName) ? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})` : // Pickers need a Date instance; an endpoint column is an ISO string. isDesignerDateProperty(node.type, propertyName) ? `toDesignerDate(${expression}, ${isDesignerDateOnlyComponent(node.type)})` : // Select is controlled by the option object, not by the raw column value. node.type === 'Select' && propertyName === 'value' ? `toSelectValue(${optionCollectionExpression(node, itemVariable)}, ${expression}, ${Boolean(node.props.isMulti)})` : // An empty record (New mode) would otherwise hand React `undefined`, // which flips the field to uncontrolled and makes it unusable. formField?.propertyName === propertyName ? `(${expression}) ?? ${propertyName === 'checked' ? 'false' : '""'}` : expression props.push(`${propertyName}={${propertyExpression}}`) } }) if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}') if (runtimeState && !omitted.has(runtimeState.propertyName)) { props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`) } const handlerNames = new Set( Object.entries(node.events) .filter(([, script]) => script.trim()) .map(([eventName]) => eventName), ) if (runtimeState) handlerNames.add(runtimeState.eventName) if (formField) handlerNames.add(formField.eventName) handlerNames.forEach((eventName) => props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`), ) // Spread first so anything configured on the node overrides the defaults. const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : [] // …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, ) => { 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, ) => { 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, ) => { 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, ) => { 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(`
`, level + 2)}\n${children}\n${indent('
', 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(``, level + 5)} ${indent(`{\`\${${names.index} + 1} / \${${names.rows}.length}\`}`, level + 5)} ${indent(``, level + 5)} ${indent('', level + 5)} ${indent('', level + 4)} ${indent(') : null}', level + 3)}` const toolbar = node.props.showToolbar === false ? '' : ` ${indent('
', level + 2)}${navigation} ${indent(``, level + 3)} ${indent(``, level + 3)} ${indent(``, level + 3)} ${indent(``, level + 3)} ${indent(`{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}`, level + 3)} ${indent('
', level + 2)}` const error = ` ${indent(`{${names.error} ?
{${names.error}}
: 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 ? `${headerExtra}
}>` : '' return `${indent(cardStart, level)} ${indent(`
`, level + 1)} ${content}${toolbar}${error} ${indent('
', level + 1)} ${indent('
', 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( `', level)}` } if ( node.type === 'PageContainer' || node.type === 'TwoColumns' || node.type === 'SidebarContent' || node.type === 'HeaderContent' ) { const gap = Number(node.props.gap) || 0 const className = JSON.stringify(String(node.props.className || '')) const style = node.type === 'PageContainer' ? `{ display: "flex", flexDirection: "column", gap: ${gap}, margin: "0 auto", maxWidth: ${JSON.stringify(String(node.props.maxWidth || '1280px'))}, padding: ${Number(node.props.padding) || 0}, width: "100%" }` : node.type === 'HeaderContent' ? `{ display: "flex", flexDirection: "column", gap: ${gap} }` : `{ display: "grid", gap: ${gap}, gridTemplateColumns: ${JSON.stringify(node.type === 'SidebarContent' ? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)` : 'repeat(2, minmax(0, 1fr))')} }` const children = node.children .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .join('\n') return `${indent(`
`, level)}\n${children}\n${indent('
', 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('', level + 3) return `${indent('', level + 2)}\n${content}\n${indent('', level + 2)}` }).join('\n') return `${indent('', level + 1)}\n${cells}\n${indent('', level + 1)}` }).join('\n') const className = JSON.stringify(String(node.props.className || '')) return `${indent(`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('', level + 1)} ${rows} ${indent('', level + 1)} ${indent('', level)}` } if (node.type === 'Grid') { const columns = Number(node.props.cols) || 3 const gap = (Number(node.props.gap) || 0) * 4 const itemsExpression = bindingExpression(node, 'items', itemVariable) || (Array.isArray(node.props.items) ? JSON.stringify(node.props.items) : '') const itemsBinding = node.bindings?.items const sourceRootExpression = !itemVariable && itemsBinding?.sourceId ? `data_${safeIdentifier(itemsBinding.sourceId)}` : '' const collectionExpression = sourceRootExpression && sourceRootExpression !== itemsExpression ? `(Array.isArray(${itemsExpression}) ? ${itemsExpression} : Array.isArray(${sourceRootExpression}) ? ${sourceRootExpression} : null)` : itemsExpression const gridClassName = JSON.stringify(String(node.props.className || '')) const gridStart = `
` if (collectionExpression && node.children.length === 0) { const identifier = safeIdentifier(node.id) const itemsVariable = `gridItems_${identifier}` const columnsVariable = `gridColumns_${identifier}` const itemVariable = `gridItem_${identifier}` const valueVariable = `cellValue_${identifier}` const configuredColumns = Array.isArray(node.props.dataColumns) ? node.props.dataColumns.filter((column): column is string => typeof column === 'string') : null const columnsExpression = configuredColumns ? JSON.stringify(configuredColumns) : `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]` const compact = Boolean(node.props.compact) const borderlessRow = Boolean(node.props.borderlessRow) const hoverable = node.props.hoverable !== false const overflow = node.props.overflow !== false const tableTextClass = compact ? 'text-[11px]' : 'text-xs' const cellSpacingClass = compact ? 'px-2 py-1' : 'px-3 py-2' const rowClass = [ borderlessRow ? '' : 'border-b border-slate-100 last:border-b-0 dark:border-slate-800', hoverable ? 'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70' : '', ] .filter(Boolean) .join(' ') const overflowClass = overflow ? 'overflow-auto' : 'overflow-visible' const table = `${indent('{(() => {', level + 1)} ${indent(`const ${itemsVariable} = ${collectionExpression}`, level + 2)} ${indent(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)} ${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)} ${indent(`if (${itemsVariable} === null || ${itemsVariable} === undefined) return null`, level + 2)} ${indent(`if (!Array.isArray(${itemsVariable})) return
Grid items bağlantısı bir koleksiyon döndürmelidir.
`, level + 2)} ${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)} ${indent(`if (!${columnsVariable}.length) return
Preview için en az bir sütun seçin.
`, level + 2)} ${indent('return (', level + 2)} ${indent(`
`, level + 3)} ${indent(``, level + 4)} ${indent('', level + 5)} ${indent(`{${columnsVariable}.map((column) => )}`, level + 6)} ${indent('', level + 5)} ${indent('', level + 5)} ${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)} ${indent(``, level + 7)} ${indent(`{${columnsVariable}.map((column) => {`, level + 8)} ${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)} ${indent(`const text = typeof ${valueVariable} === "object" && ${valueVariable} !== null && !(${valueVariable} instanceof Date) ? JSON.stringify(${valueVariable}) : formatLocaleValue(${valueVariable}) || "—"`, level + 9)} ${indent(`return `, level + 9)} ${indent('})}', level + 8)} ${indent('', level + 7)} ${indent('))}', level + 6)} ${indent('', level + 5)} ${indent('
{column === "value" ? "Value" : column}
{text}
', level + 4)} ${indent('
', level + 3)} ${indent(')', level + 2)} ${indent('})()}', level + 1)}` return `${indent(gridStart, level)}\n${table}\n${indent('
', 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( `
{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}
`, 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
Grid items bağlantısı bir koleksiyon döndürmelidir.
`, level + 2)} ${indent(`return ${repeatedRowsVariable}.map((${repeatedItemVariable}, index) => (`, level + 2)} ${indent(``, level + 3)} ${repeatedChildren} ${indent('', level + 3)} ${indent('))', level + 2)} ${indent('})()}', level + 1)}` : staticChildren return `${indent(gridStart, level)}\n${children}\n${indent('', level)}` } if (node.kind === 'platform') { const viewNames: Record = { ListView: 'List', DataGridView: 'Grid', TreeView: 'Tree', GanttView: 'GanttView', TodoBoard: 'TodoBoard', CardView: 'CardView', SchedulerView: 'SchedulerView', PivotView: 'Pivot', ChartView: 'Chart', } const platformParams = `platformParams_${safeIdentifier(node.id)}` const hasFilters = getDesignerNodeFilters(node).length > 0 const host = `` 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(``, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => {String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)})}`, level + 1)}\n${indent('', 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(``, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => {String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)})}`, level + 1)}\n${indent('', level)}` } if (node.type === 'Pagination') { const { node: paginationNode } = withoutCollection(node) const paginationPropsCode = propsToCode(paginationNode, itemVariable, [], formScope) const optionsExpression = optionCollectionExpression(node, itemVariable) return indent( ``, 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:` 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() 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(``, level + 2)} ${indent('', level + 3)} ${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => {String(${optionVariable}.label ?? ${optionVariable}.value ?? \`Sekme \${tabIndex + 1}\`)})}`, level + 4)} ${indent('', level + 3)} ${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => (`, level + 3)} ${indent(``, level + 4)} ${indent(`{${optionVariable}.content ?
{String(${optionVariable}.content)}
: null}`, level + 5)} ${indent(`{${childrenVariable}[String(${optionVariable}.value ?? tabIndex)] ?? null}`, level + 5)} ${indent(`{tabIndex === 0 ? ${fallbackVariable} : null}`, level + 5)} ${indent('
', level + 4)} ${indent('))}', level + 3)} ${indent('', 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(``, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => {String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)})}`, level + 1)}\n${indent('', 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(``, 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.` 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, 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( {typeof message === "string" ? message : JSON.stringify(message)}, { 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 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() const visit = (nodes: DesignerNode[], formScope?: FormScope) => { nodes.forEach((node) => { if (isDesignerOptionComponent(node.type)) hasSelect = true if (node.type === 'Select') hasSelectComponent = true if (node.type === 'Dropdown') hasDropdown = true if (node.type === 'Grid') hasDataTable = true if (node.type === 'Tabs') hasTabs = true if (isDesignerDateComponent(node.type)) hasDatePicker = true if (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( 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 ?? "") : "" }` : '' // Grid cells print raw endpoint columns, so dates and decimals are localised // here — the culture is published on `` 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, 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` }