import { getDesignerCollectionProperty, getDesignerTabSlotValue, getSqlDataSourceEndpointId, getSqlDataSourceKeyField, getSqlDataSourceKeyParam, getSqlDataSourceKeySource, hasSqlDataSourceUrlParams, isDesignerDateComponent, isDesignerDateOnlyComponent, isDesignerDateProperty, isDesignerOptionComponent, isSqlDataSourceNode, normalizeDesignerKeyList, DESIGNER_DROPDOWN_PLACEHOLDER, SQL_DATA_SOURCE_SLOTS, type DesignerDataSource, type DesignerDocument, type DesignerNode, } from './types' /** * Record scope opened by a SqlDataSource. The record state is named * `data_`, which is exactly what `bindingExpression` emits for a binding * whose `sourceId` is the container's node id — so children bind to columns * through the regular binding machinery and write back through `setterName`. */ interface FormScope { sourceId: string setterName: string } const safeIdentifier = (value: string) => { const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_') return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}` } const staticValueExpression = (value: unknown): string => { if (typeof value === 'string') { return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value) } if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]` if (value && typeof value === 'object') { return `{ ${Object.entries(value) .map(([key, item]) => `${JSON.stringify(key)}: ${staticValueExpression(item)}`) .join(', ')} }` } if (value === undefined) return 'undefined' return JSON.stringify(value) } const serializeValue = (value: unknown) => `{${staticValueExpression(value)}}` const bindingExpression = (node: DesignerNode, propertyName: string, itemVariable?: string) => { const binding = node.bindings?.[propertyName] if (!binding?.sourceId) return '' const path = binding.path.trim() if (itemVariable && (path === '$item' || path.startsWith('$item.'))) { return `getByPath(${itemVariable}, ${JSON.stringify(path === '$item' ? '' : path.slice(6))})` } return `getByPath(data_${safeIdentifier(binding.sourceId)}, ${JSON.stringify(binding.path || '')})` } interface RuntimeStateSpec { propertyName: string eventName: string stateName: string setterName: string initialValue: unknown } const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => { const configured: Record = { 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 = (['value', 'checked'] as const).find( (name) => node.bindings?.[name]?.sourceId === formScope.sourceId, ) if (!propertyName) return null return { propertyName, eventName: node.type === 'AutoComplete' ? 'onInputChange' : 'onChange', path: node.bindings[propertyName].path, setterName: formScope.setterName, } } const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) => isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type) /** * Options expression for a data component: the endpoint binding mapped through * `toSelectOptions` (honouring the configured label/value columns), otherwise the * static collection maintained in the designer. */ const optionCollectionExpression = (node: DesignerNode, itemVariable?: string) => { const collectionProperty = getDesignerCollectionProperty(node.type) const binding = node.bindings?.[collectionProperty] const boundExpression = binding?.sourceId ? bindingExpression(node, collectionProperty, itemVariable) : '' if (boundExpression) { return `toSelectOptions(${boundExpression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})` } const staticOptions = node.props[collectionProperty] return staticValueExpression(Array.isArray(staticOptions) ? staticOptions : []) } /** Node stripped of its collection prop/binding; those are rendered as children. */ const withoutCollection = (node: DesignerNode) => { const collectionProperty = getDesignerCollectionProperty(node.type) const props = { ...node.props } const bindings = { ...(node.bindings || {}) } delete props[collectionProperty] delete bindings[collectionProperty] return { node: { ...node, props, bindings }, props, bindings } } const propsToCode = ( node: DesignerNode, itemVariable?: string, omitProperties: string[] = [], formScope?: FormScope, ) => { const omitted = new Set(omitProperties) const runtimeState = getRuntimeStateSpec(node) const formField = getFormScopeField(node, formScope) const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId) // Inside a SqlDataSource the bound value is editable: the change is written to // the record, so the input must not be forced read-only. const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField) const props = Object.entries(node.props) .filter( ([key, value]) => key !== 'children' && key !== runtimeState?.propertyName && !omitted.has(key) && !(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) && !(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') && // A field the SqlDataSource writes back must stay editable, whatever the // node happens to carry from the toolbox defaults. !(formField && (key === 'readOnly' || key === 'disabled')) && !node.bindings?.[key]?.sourceId && value !== '' && value !== undefined, ) .map(([key, value]) => // Dates live in the document as ISO strings; pickers need Date instances. isDesignerDateProperty(node.type, key) ? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}` : `${key}=${serializeValue(value)}`, ) Object.keys(node.bindings || {}).forEach((propertyName) => { if (propertyName === 'children') return const expression = bindingExpression(node, propertyName, itemVariable) if (expression) { // Label/value columns come from the bound property itself; the collection // is named `items` on some data components and `options` on others. const binding = node.bindings?.[propertyName] const propertyExpression = isOptionCollectionProperty(node, propertyName) ? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})` : // Pickers need a Date instance; an endpoint column is an ISO string. isDesignerDateProperty(node.type, propertyName) ? `toDesignerDate(${expression}, ${isDesignerDateOnlyComponent(node.type)})` : // Select is controlled by the option object, not by the raw column value. node.type === 'Select' && propertyName === 'value' ? `toSelectValue(${optionCollectionExpression(node, itemVariable)}, ${expression}, ${Boolean(node.props.isMulti)})` : // An empty record (New mode) would otherwise hand React `undefined`, // which flips the field to uncontrolled and makes it unusable. formField?.propertyName === propertyName ? `(${expression}) ?? ${propertyName === 'checked' ? 'false' : '""'}` : expression props.push(`${propertyName}={${propertyExpression}}`) } }) if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}') if (runtimeState && !omitted.has(runtimeState.propertyName)) { props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`) } const handlerNames = new Set( Object.entries(node.events) .filter(([, script]) => script.trim()) .map(([eventName]) => eventName), ) if (runtimeState) handlerNames.add(runtimeState.eventName) if (formField) handlerNames.add(formField.eventName) handlerNames.forEach((eventName) => props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`), ) // Spread first so anything configured on the node overrides the defaults. const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : [] const allProps = [...leading, ...props] return allProps.length ? ` ${allProps.join(' ')}` : '' } const indent = (text: string, level: number) => text .split('\n') .map((line) => `${' '.repeat(level)}${line}`) .join('\n') const sqlIdentifiers = (node: DesignerNode) => { const identifier = safeIdentifier(node.id) return { identifier, record: `data_${identifier}`, setRecord: `setData_${identifier}`, rows: `sqlRows_${identifier}`, busy: `sqlBusy_${identifier}`, setBusy: `setSqlBusy_${identifier}`, error: `sqlError_${identifier}`, setError: `setSqlError_${identifier}`, key: `sqlKey_${identifier}`, keyParam: `sqlKeyParam_${identifier}`, hasKey: `sqlHasKey_${identifier}`, original: `sqlOriginal_${identifier}`, setOriginal: `setSqlOriginal_${identifier}`, mode: `sqlMode_${identifier}`, setMode: `setSqlMode_${identifier}`, index: `sqlIndex_${identifier}`, setIndex: `setSqlIndex_${identifier}`, selectUrl: `sqlSelectUrl_${identifier}`, urlKey: `sqlUrlKey_${identifier}`, setField: `sqlSetField_${identifier}`, reload: `sqlReload_${identifier}`, refresh: `sqlRefresh_${identifier}`, create: `sqlNew_${identifier}`, save: `sqlSave_${identifier}`, remove: `sqlDelete_${identifier}`, previous: `sqlPrev_${identifier}`, next: `sqlNext_${identifier}`, slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`, } } /** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */ const sqlSlotLiteral = ( node: DesignerNode, property: string, method: string, dataSources: DesignerDataSource[], ) => { const source = dataSources.find( (candidate) => candidate.id === getSqlDataSourceEndpointId(node, property), ) if (!source?.url.trim()) return 'null' return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }` } const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[]) => { const names = sqlIdentifiers(node) const keyField = getSqlDataSourceKeyField(node) const selectSource = dataSources.find( (candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'), ) // The Select endpoint already owns a fetch hook, so the container reuses that // state instead of issuing a second request for the same URL. const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null' // Must mirror the setter emitted by the data hooks, or the reload would call an // identifier that was never declared. const setSelectData = selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/') ? `setData_${safeIdentifier(selectSource.id)}` : '' const keySource = getSqlDataSourceKeySource(node) const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})` const slots = SQL_DATA_SOURCE_SLOTS.map( (slot) => ` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`, ).join('\n') return `${slots} const ${names.key} = ${JSON.stringify(keyField)} const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))} const [${names.record}, ${names.setRecord}] = React.useState({}) const [${names.original}, ${names.setOriginal}] = React.useState({}) const [${names.mode}, ${names.setMode}] = React.useState("edit") const [${names.index}, ${names.setIndex}] = React.useState(0) const [${names.busy}, ${names.setBusy}] = React.useState(false) const [${names.error}, ${names.setError}] = React.useState("") // The Select key can come from the page URL, which is how a detail page reads // /api/app/orders/{id} or /api/app/orders?id=… for a single record. const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam}) const ${names.selectUrl} = React.useMemo(() => { if (!${names.slot('selectEndpoint')}) return "" if (!${names.urlKey}) return ${names.slot('selectEndpoint')}.url const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam}) return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey}) }, [${names.urlKey}]) // A key in the page URL always narrows the result: a list endpoint ignores the // parameter server side, so the requested record is picked out here. Without a // key every row is kept and the navigation below takes over. const ${names.rows} = React.useMemo( () => filterSqlRowsByKey(${rowsExpression}, ${names.key}, ${names.urlKey}), [${selectData}, ${names.urlKey}], ) // A fresh result set resets the position; New mode is left untouched so an // unsaved draft is not overwritten by a re-render. React.useEffect(() => { ${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0)) }, [${names.rows}]) React.useEffect(() => { const row = ${names.rows}[${names.index}] if (!row) return ${names.setRecord}(row) ${names.setOriginal}(row) ${names.setMode}("edit") }, [${names.rows}, ${names.index}]) const ${names.hasKey} = (() => { const value = readSqlField(${names.record}, ${names.key}) return value !== undefined && value !== null && value !== "" })() const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), []) const ${names.reload} = React.useCallback(async () => {${ setSelectData ? ` if (!${names.selectUrl}) return // Calling a URL that still holds a placeholder is a guaranteed 400, so the // missing key is reported in the component instead. if (hasSqlUrlParams(${names.selectUrl})) { throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.") } const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" }) ${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))` : '' } }, [${setSelectData ? names.selectUrl : ''}]) // Reload wrapped with the busy/error handling the toolbar and mount effect need, // so a failing Select is reported in the component instead of the console. const ${names.refresh} = React.useCallback(async () => { ${names.setBusy}(true) ${names.setError}("") try { await ${names.reload}() } catch (error) { ${names.setError}(toSqlErrorMessage(error)) } finally { ${names.setBusy}(false) } }, [${names.reload}]) const ${names.create} = React.useCallback(() => { ${names.setError}("") ${names.setRecord}({}) ${names.setOriginal}({}) ${names.setMode}("new") }, []) const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), []) const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}]) const ${names.save} = async () => { const isNew = ${names.mode} === "new" const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')} if (!target) { ${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.") return } // Update carries only the edited columns plus the key; Insert sends the record. const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key}) if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) { ${names.setError}("Kaydedilecek bir değişiklik yok.") return } ${names.setBusy}(true) ${names.setError}("") try { const response = await callSqlEndpoint(target, ${names.record}, ${names.key}, payload) // With a Select endpoint the reload is the source of truth and its effect // repopulates the form; without one, the response row is all there is. if (${names.slot('selectEndpoint')}) { await ${names.reload}() } else { const saved = toSqlRows(response?.data, "")[0] if (saved) { ${names.setRecord}(saved) ${names.setOriginal}(saved) } } ${names.setMode}("edit") } catch (error) { ${names.setError}(toSqlErrorMessage(error)) } finally { ${names.setBusy}(false) } } const ${names.remove} = async () => { if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return // Deleting cannot be undone from the form, so it always asks first. const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?" if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return ${names.setBusy}(true) ${names.setError}("") try { await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key}) ${names.setRecord}({}) ${names.setOriginal}({}) ${names.setMode}("new") await ${names.reload}() } catch (error) { ${names.setError}(toSqlErrorMessage(error)) } finally { ${names.setBusy}(false) } }${ node.props.autoLoad === false ? '' : ` React.useEffect(() => { void ${names.refresh}() }, [${names.refresh}])` }` } const SQL_TOOLBAR_BUTTON_CLASS = 'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40' const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => { const names = sqlIdentifiers(node) const formScope: FormScope = { sourceId: node.id, setterName: names.setField } const children = node.children .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .join('\n') const className = JSON.stringify(String(node.props.className || '')) const style = `{ display: "flex", flexDirection: "column", gap: ${Number(node.props.gap) || 0} }` const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300` // Navigation appears on its own whenever there is more than one record to walk. const navigation = ` ${indent(`{${names.rows}.length > 1 ? (`, level + 2)} ${indent('<>', level + 3)} ${indent(``, level + 4)} ${indent(`{\`\${${names.index} + 1} / \${${names.rows}.length}\`}`, level + 4)} ${indent(``, level + 4)} ${indent('', level + 4)} ${indent('', level + 3)} ${indent(') : null}', level + 2)}` const toolbar = node.props.showToolbar === false ? '' : ` ${indent('
', level + 1)}${navigation} ${indent(``, level + 2)} ${indent(``, level + 2)} ${indent(``, level + 2)} ${indent(``, level + 2)} ${indent(`{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}`, level + 2)} ${indent('
', level + 1)}` const error = ` ${indent(`{${names.error} ?
{${names.error}}
: null}`, level + 1)}` return `${indent(`
`, level)} ${children}${toolbar}${error} ${indent('
', level)}` } const nodeToCode = ( node: DesignerNode, level = 0, itemVariable?: string, formScope?: FormScope, ): string => { if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable) if (node.type === 'Spacer') { return indent( `', 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(`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 + 3, repeatedItemVariable, formScope)) .join('\n') : indent( `
{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}
`, level + 3, ) const children = collectionExpression ? `${indent(`{Array.isArray(${collectionExpression}) ? ${collectionExpression}.map((${repeatedItemVariable}, index) => (`, level + 1)} ${indent(``, level + 2)} ${repeatedChildren} ${indent('', level + 2)} ${indent(')) : (', level + 1)} ${indent('
Grid items bağlantısı bir koleksiyon döndürmelidir.
', 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', } return 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') const text = childrenBinding ? indent(`{${childrenBinding}}`, level + 1) : childrenText ? indent(`{${staticValueExpression(childrenText)}}`, level + 1) : '' const body = [text, nested].filter(Boolean).join('\n') return `${indent(`<${tag}${propCode}>`, level)}\n${body}\n${indent(``, level)}` } export const generateDesignerCode = (name: string, document: DesignerDocument) => { const componentName = safeIdentifier(name || 'VisualComponent') const handlers: string[] = [] const runtimeStateHooks: string[] = [] let hasSelect = false let hasDataTable = false let hasDropdown = false let hasSelectComponent = false let hasDatePicker = false let hasTabs = false const sqlDataSourceNodes: DesignerNode[] = [] /** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */ const sqlManagedSelectSourceIds = new Set() const visit = (nodes: DesignerNode[], formScope?: FormScope) => { nodes.forEach((node) => { if (isDesignerOptionComponent(node.type)) hasSelect = true if (node.type === 'Select') hasSelectComponent = true if (node.type === 'Dropdown') hasDropdown = true if (node.type === 'Grid') hasDataTable = true if (node.type === 'Tabs') hasTabs = true if (isDesignerDateComponent(node.type)) hasDatePicker = true if (isSqlDataSourceNode(node.type)) { sqlDataSourceNodes.push(node) const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') if (selectId) sqlManagedSelectSourceIds.add(selectId) } const formField = getFormScopeField(node, formScope) const runtimeState = getRuntimeStateSpec(node) if (runtimeState) { // Date pickers hold a Date in state but an ISO string in the document. const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName) ? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)}, ${isDesignerDateOnlyComponent(node.type)})` : JSON.stringify(runtimeState.initialValue) runtimeStateHooks.push( ` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`, ) } const eventNames = new Set( Object.entries(node.events) .filter(([, script]) => script.trim()) .map(([eventName]) => eventName), ) if (runtimeState) eventNames.add(runtimeState.eventName) if (formField) eventNames.add(formField.eventName) eventNames.forEach((eventName) => { const script = node.events[eventName]?.trim() || '' // A form scoped field replaces the local state update: the edit belongs // to the SqlDataSource record, which is what Save posts back. const formUpdate = formField?.eventName === eventName ? `${formField.setterName}(${JSON.stringify(formField.path)}, ${ node.type === 'Select' ? 'Array.isArray(valueOrEvent) ? valueOrEvent.map((option) => option?.value ?? option) : (valueOrEvent?.value ?? null)' : formField.propertyName === 'checked' ? 'typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent)' : isDesignerDateComponent(node.type) ? `fromDesignerDate(valueOrEvent ?? null, ${isDesignerDateOnlyComponent(node.type)})` : node.type === 'AutoComplete' ? 'valueOrEvent ?? ""' : 'valueOrEvent?.target?.value ?? valueOrEvent' })` : '' const stateUpdate = formUpdate || (runtimeState?.eventName === eventName ? node.type === 'Select' ? `${runtimeState.setterName}(valueOrEvent)` : node.type === 'AutoComplete' ? `${runtimeState.setterName}(valueOrEvent ?? "")` : node.type === 'Menu' ? `${runtimeState.setterName}([String(valueOrEvent ?? "")])` : node.type === 'Tabs' ? `${runtimeState.setterName}(String(valueOrEvent ?? ""))` : node.type === 'Dropdown' ? `${runtimeState.setterName}(String(valueOrEvent ?? ""))` : node.type === 'Pagination' ? `${runtimeState.setterName}(Number(valueOrEvent) || 1)` : node.type === 'Steps' || node.type === 'ImageViewer' ? `${runtimeState.setterName}(Number(valueOrEvent) || 0)` : node.type === 'Upload' ? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])` : runtimeState.propertyName === 'checked' ? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))` : node.type === 'Input' || node.kind === 'html' ? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)` : `${runtimeState.setterName}(valueOrEvent)` : '') const eventDeclaration = node.type === 'Checkbox' && eventName === 'onChange' ? ' const event = { checked: Boolean(valueOrEvent), originalEvent, target: originalEvent?.target }' : ' const event = valueOrEvent' handlers.push( ` const handle_${safeIdentifier(node.id)}_${eventName} = (valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`, ) }) visit( node.children, isSqlDataSourceNode(node.type) ? { sourceId: node.id, setterName: sqlIdentifiers(node).setField } : formScope, ) }) } visit(document.nodes) const dataSources = document.dataSources || [] const selectHelpers = hasSelect ? ` const toSelectOptions = (value, labelPath = "", valuePath = "") => { if (!Array.isArray(value)) return [] return value.map((item, index) => { if (item === null || typeof item !== "object" || Array.isArray(item)) return { label: String(item ?? ""), value: item ?? index } const primitiveKeys = Object.keys(item).filter((key) => item[key] === null || ["string", "number", "boolean"].includes(typeof item[key])) const labelKey = ["label", "children", "name", "title", "text", "description"].find((key) => item[key] !== undefined) const valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined) const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]] const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? primitiveKeys[0]] return { ...item, label: String(optionLabel ?? \`Seçenek \${index + 1}\`), value: optionValue ?? index } }) }` : '' // A bound Select receives a raw column value, but react-select is controlled by // the option object itself. const selectValueHelpers = hasSelectComponent ? ` const toSelectValue = (options, value, isMulti) => { if (isMulti) return options.filter((option) => Array.isArray(value) && value.includes(option.value)) return options.find((option) => option.value === value) ?? null }` : '' // Dropdown paints its toggle from `title`; `activeKey` only marks the item // inside the closed menu, so the label of the active option is resolved here. const dropdownHelpers = hasDropdown ? ` const toDropdownTitle = (options, activeKey, fallbackTitle) => { const key = activeKey === undefined || activeKey === null ? "" : String(activeKey) const selected = key ? options.find((option) => String(option?.value ?? "") === key) : undefined if (selected) return String(selected.label ?? selected.value ?? key) return String(fallbackTitle ?? "") || ${JSON.stringify(DESIGNER_DROPDOWN_PLACEHOLDER)} }` : '' // Falls back to the first tab while an endpoint driven tab list is still empty // or when the stored value no longer exists. const tabHelpers = hasTabs ? ` const toTabValue = (options, value) => { const key = value === undefined || value === null ? "" : String(value) if (key && options.some((option) => String(option?.value ?? "") === key)) return key return options.length ? String(options[0]?.value ?? "") : "" }` : '' // Grid cells print raw endpoint columns, so dates and decimals are localised // here — the culture is published on `` because a runtime compiled // component has neither hooks nor imports to reach the store. const localeHelpers = hasDataTable ? ` const localeCulture = () => (typeof document !== "undefined" && document.documentElement.lang) || "en" const localeDateOptions = { year: "numeric", month: "2-digit", day: "2-digit" } const localeTimeOptions = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" } const localeIsoPattern = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/ const formatLocaleValue = (value) => { if (value === null || value === undefined) return "" const culture = localeCulture() if (typeof value === "number") { if (!Number.isFinite(value)) return String(value) return new Intl.NumberFormat(culture, { useGrouping: false, maximumFractionDigits: 20 }).format(value) } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? "" : new Intl.DateTimeFormat(culture, localeTimeOptions).format(value) } if (typeof value !== "string") return String(value) const parts = localeIsoPattern.exec(value.trim()) if (!parts) return value const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return value if (parts[4] === undefined) { const day = new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) return new Intl.DateTimeFormat(culture, localeDateOptions).format(day) } return new Intl.DateTimeFormat(culture, localeTimeOptions).format(parsed) }` : '' // Pickers check `instanceof Date`, but the document only stores ISO strings. // Both directions stay on the local wall clock: a date column carries no // timezone, so converting through UTC would move 29/07 to 28/07. const dateHelpers = hasDatePicker ? ` const sqlPad = (value) => String(value).padStart(2, "0") const isoDateTime = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2}))?)?/ const toDesignerDate = (value, dateOnly) => { if (Array.isArray(value)) return value.map((entry) => toDesignerDate(entry, dateOnly)) if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value if (typeof value !== "string" || !value.trim()) return null const parts = isoDateTime.exec(value.trim()) const midnight = parts && dateOnly && !Number(parts[4]) && !Number(parts[5]) && !Number(parts[6]) if (parts && (parts[4] === undefined || midnight)) { return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) } const parsed = new Date(value) return Number.isNaN(parsed.getTime()) ? null : parsed } const fromDesignerDate = (value, dateOnly) => { if (Array.isArray(value)) return value.map((entry) => fromDesignerDate(entry, dateOnly)) if (value instanceof Date) { if (Number.isNaN(value.getTime())) return null const day = value.getFullYear() + "-" + sqlPad(value.getMonth() + 1) + "-" + sqlPad(value.getDate()) if (dateOnly) return day return day + "T" + sqlPad(value.getHours()) + ":" + sqlPad(value.getMinutes()) + ":" + sqlPad(value.getSeconds()) } return typeof value === "string" && value.trim() ? value : null }` : '' // Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table, // Card) would cut it off. A body portal keeps the menu fully visible. const selectMenuHelpers = hasSelectComponent ? ` const selectMenuProps = { menuPosition: "fixed", menuPortalTarget: typeof window === "undefined" ? null : window.document.body, styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) }, }` : '' // Shared SqlDataSource runtime: row extraction, record writes, URL parameter // binding and the actual Insert/Update/Delete calls. const sqlHelpers = sqlDataSourceNodes.length ? ` const readSqlField = (record, field) => { if (!record || typeof record !== "object" || Array.isArray(record)) return undefined if (field in record) return record[field] const matched = Object.keys(record).find((key) => key.toLowerCase() === String(field).toLowerCase()) return matched === undefined ? undefined : record[matched] } const toSqlRows = (value, collectionPath) => { const source = collectionPath ? getByPath(value, collectionPath) : value const rows = Array.isArray(source) ? source : source && typeof source === "object" ? (Array.isArray(source.items) ? source.items : [source]) : [] return rows.filter((item) => item && typeof item === "object" && !Array.isArray(item)) } const setSqlField = (record, path, value) => { const keys = String(path || "").split(".").filter(Boolean) if (!keys.length) return record const next = { ...(record || {}) } let target = next for (let index = 0; index < keys.length - 1; index += 1) { const child = target[keys[index]] target[keys[index]] = child && typeof child === "object" && !Array.isArray(child) ? { ...child } : {} target = target[keys[index]] } target[keys[keys.length - 1]] = value return next } const bindSqlUrl = (url, record, keyField) => { let keyBound = false const boundUrl = String(url).replace(/\\{([^}]+)\\}|(?<=\\/):([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, colon) => { const name = (braced || colon || "").trim() if (!name) return match const value = readSqlField(record, name) ?? readSqlField(record, keyField) if (value === undefined || value === null || value === "") return match if (name.toLowerCase() === String(keyField).toLowerCase()) keyBound = true return encodeURIComponent(String(value)) }) return { url: boundUrl, keyBound } } const appendQueryParam = (url, name, value) => url + (url.includes("?") ? "&" : "?") + encodeURIComponent(name) + "=" + encodeURIComponent(value) const hasSqlUrlParams = (url) => /\\{[^}]+\\}|(?<=\\/):[A-Za-z_][A-Za-z0-9_]*/.test(String(url)) const readUrlKey = (source, name) => { if (typeof window === "undefined") return "" if (source === "query") return new URLSearchParams(window.location.search).get(name) ?? "" const segments = window.location.pathname.split("/").filter(Boolean) return segments.length ? decodeURIComponent(segments[segments.length - 1]) : "" } const filterSqlRowsByKey = (rows, keyField, keyValue) => { if (!keyValue) return rows const matched = rows.filter((row) => String(readSqlField(row, keyField) ?? "") === String(keyValue)) // No match means the endpoint already filtered server side, or the key column // is named differently — keeping the rows beats showing an empty form. return matched.length ? matched : rows } // Update payload: only the columns the user actually edited, plus the key. const toSqlChanges = (original, current, keyField) => { const changes = {} const isSame = (left, right) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null) Object.keys(current || {}).forEach((column) => { if (!isSame(original ? original[column] : undefined, current[column])) changes[column] = current[column] }) const keyColumn = Object.keys(current || {}).find((column) => column.toLowerCase() === String(keyField).toLowerCase()) if (keyColumn !== undefined && changes[keyColumn] === undefined) changes[keyColumn] = current[keyColumn] return changes } const callSqlEndpoint = (target, record, keyField, payload) => { // The URL is always bound from the full record: a placeholder may reference a // column that the update payload does not carry. const bound = bindSqlUrl(target.url, record, keyField) const request = { url: bound.url, method: target.method } if (target.method === "POST" || target.method === "PUT") request.data = payload === undefined ? record : payload const keyValue = readSqlField(record, keyField) const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== "" // The key still has to reach the endpoint when the URL carries no placeholder. if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue } return apiService.fetchData(request) } const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."` : '' const sqlHooks = sqlDataSourceNodes .map((node) => sqlDataSourceHooks(node, dataSources)) .join('\n\n') const dataHelpers = dataSources.length || hasDataTable || sqlDataSourceNodes.length ? ` const getByPath = (value, path) => { if (!path) return value const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target) const directValue = readPath(value, path) if (directValue !== undefined) return directValue if (path.startsWith('result.')) return readPath(value, path.slice('result.'.length)) return value?.result === undefined ? undefined : readPath(value.result, path) }` : '' const dataHooks = dataSources .map((source) => { const identifier = safeIdentifier(source.id) // POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command; // they hold no readable state and must never be written to. if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) { return ` const [data_${identifier}] = React.useState(null)` } // A GetById style URL is not fetched on mount — requesting a literal `{id}` // is a guaranteed 400 — and neither is a source the owning SqlDataSource // loads itself. Both still need the setter that container writes through. if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) { return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)` } return ` const [data_${identifier}, setData_${identifier}] = React.useState(null) React.useEffect(() => { let active = true apiService.fetchData({ url: ${JSON.stringify(source.url)}, method: 'GET' }).then((response) => { if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')})) }).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error)) return () => { active = false } }, [])` }) .join('\n\n') const mount = document.lifecycle.onMount.trim() ? ` React.useEffect(() => {\n${indent(document.lifecycle.onMount, 2)}\n }, [])` : '' const body = document.nodes.map((node) => nodeToCode(node, 3)).join('\n') const designerBackup = encodeURIComponent(JSON.stringify(document)) return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, dataHooks, sqlHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n \n )\n}\n\nexport default ${componentName}\n` }