1949 lines
100 KiB
TypeScript
1949 lines
100 KiB
TypeScript
import {
|
||
collectSqlDefaultFields,
|
||
getDesignerBindingColumns,
|
||
getDesignerCollectionProperty,
|
||
getDesignerTabSlotValue,
|
||
getDesignerDataSourceFilters,
|
||
getDesignerNodeFilters,
|
||
getSqlDataSourceColumnCount,
|
||
getDesignerColumnCaptions,
|
||
getUsableDesignerColumnLookups,
|
||
getSqlDataSourceEndpointId,
|
||
getSqlDataSourceKeyField,
|
||
getSqlDataSourceKeyParam,
|
||
getSqlDataSourceKeySource,
|
||
getSqlDataSourceScriptedEvents,
|
||
getSqlFormValueProperty,
|
||
hasSqlDataSourceUrlParams,
|
||
isDesignerDateComponent,
|
||
isDesignerDateOnlyComponent,
|
||
isDesignerDateProperty,
|
||
isDesignerOptionComponent,
|
||
DESIGNER_ICON_PROPERTIES,
|
||
DESIGNER_SELECT_COMPONENT_PROPERTY,
|
||
flexRowStyle,
|
||
isSqlDataSourceNode,
|
||
isValuelessDesignerFilter,
|
||
DESIGNER_FILTER_DX_OPERATORS,
|
||
toDesignerFilterParamName,
|
||
normalizeDesignerKeyList,
|
||
DESIGNER_DROPDOWN_PLACEHOLDER,
|
||
SQL_DATA_SOURCE_SLOTS,
|
||
SQL_DEFAULT_VALUE_PROP,
|
||
type DesignerDataSource,
|
||
type DesignerDataSourceFilter,
|
||
type DesignerDocument,
|
||
type DesignerNode,
|
||
} from './types'
|
||
|
||
/**
|
||
* Record scope opened by a SqlDataSource. The record state is named
|
||
* `data_<nodeId>`, which is exactly what `bindingExpression` emits for a binding
|
||
* whose `sourceId` is the container's node id — so children bind to columns
|
||
* through the regular binding machinery and write back through `setterName`.
|
||
*/
|
||
interface FormScope {
|
||
sourceId: string
|
||
setterName: string
|
||
}
|
||
|
||
const safeIdentifier = (value: string) => {
|
||
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
|
||
return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}`
|
||
}
|
||
|
||
/**
|
||
* Whether the emitted component carries the `refs` runtime. It is switched on by
|
||
* `generateDesignerCode` for the whole pass when a script actually addresses
|
||
* another component, so a page that uses no refs keeps its previous output byte
|
||
* for byte instead of paying for a state hook and a guard around every node.
|
||
*/
|
||
let refRuntimeEnabled = false
|
||
|
||
/** Prop that turns a component off; react-select spells it differently. */
|
||
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')
|
||
|
||
/** A script with `await` in it has to be wrapped in an async function. */
|
||
export const usesAwait = (script: string) => /\bawait\b/.test(script || '')
|
||
|
||
const refPropsExpression = (ref: string) => `designerRefProps(${JSON.stringify(ref)})`
|
||
|
||
const isRefControlled = (node: DesignerNode) => refRuntimeEnabled && Boolean(node.ref)
|
||
|
||
const staticValueExpression = (value: unknown): string => {
|
||
if (typeof value === 'string') {
|
||
return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
|
||
}
|
||
if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]`
|
||
if (value && typeof value === 'object') {
|
||
return `{ ${Object.entries(value)
|
||
.map(([key, item]) => `${JSON.stringify(key)}: ${staticValueExpression(item)}`)
|
||
.join(', ')} }`
|
||
}
|
||
if (value === undefined) return 'undefined'
|
||
return JSON.stringify(value)
|
||
}
|
||
|
||
const serializeValue = (value: unknown) => `{${staticValueExpression(value)}}`
|
||
|
||
const bindingExpression = (node: DesignerNode, propertyName: string, itemVariable?: string) => {
|
||
const binding = node.bindings?.[propertyName]
|
||
if (!binding?.sourceId) return ''
|
||
const path = binding.path.trim()
|
||
if (itemVariable && (path === '$item' || path.startsWith('$item.'))) {
|
||
return `getByPath(${itemVariable}, ${JSON.stringify(path === '$item' ? '' : path.slice(6))})`
|
||
}
|
||
return `getByPath(data_${safeIdentifier(binding.sourceId)}, ${JSON.stringify(binding.path || '')})`
|
||
}
|
||
|
||
interface RuntimeStateSpec {
|
||
propertyName: string
|
||
eventName: string
|
||
stateName: string
|
||
setterName: string
|
||
initialValue: unknown
|
||
}
|
||
|
||
const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
|
||
const configured: Record<string, { propertyName: string; eventName: string }> = {
|
||
AutoComplete: { propertyName: 'value', eventName: 'onInputChange' },
|
||
Dropdown: { propertyName: 'activeKey', eventName: 'onSelect' },
|
||
Input: { propertyName: 'value', eventName: 'onChange' },
|
||
ImageViewer: { propertyName: 'activeIndex', eventName: 'onIndexChange' },
|
||
Menu: { propertyName: 'defaultActiveKeys', eventName: 'onSelect' },
|
||
Pagination: { propertyName: 'currentPage', eventName: 'onChange' },
|
||
'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
|
||
Select: { propertyName: 'value', eventName: 'onChange' },
|
||
Steps: { propertyName: 'current', eventName: 'onChange' },
|
||
Tabs: { propertyName: 'value', eventName: 'onChange' },
|
||
Upload: { propertyName: 'fileList', eventName: 'onChange' },
|
||
}
|
||
const fallback =
|
||
'checked' in node.props
|
||
? { propertyName: 'checked', eventName: 'onChange' }
|
||
: 'value' in node.props
|
||
? { propertyName: 'value', eventName: 'onChange' }
|
||
: null
|
||
const state = configured[node.type] || fallback
|
||
if (!state || node.bindings?.[state.propertyName]?.sourceId) return null
|
||
|
||
let initialValue = node.props[state.propertyName]
|
||
if (node.type === 'Select') {
|
||
const options = Array.isArray(node.props.options) ? node.props.options : []
|
||
initialValue = node.props.isMulti
|
||
? options.filter(
|
||
(option) =>
|
||
option &&
|
||
typeof option === 'object' &&
|
||
!Array.isArray(option) &&
|
||
Array.isArray(node.props.value) &&
|
||
node.props.value.includes((option as Record<string, unknown>).value),
|
||
)
|
||
: options.find(
|
||
(option) =>
|
||
option &&
|
||
typeof option === 'object' &&
|
||
!Array.isArray(option) &&
|
||
(option as Record<string, unknown>).value === node.props.value,
|
||
) || null
|
||
} else if (node.type === 'Menu') {
|
||
initialValue = normalizeDesignerKeyList(initialValue)
|
||
} else if (node.type === 'Tabs') {
|
||
initialValue = initialValue === undefined || initialValue === null ? '' : String(initialValue)
|
||
} else if (node.type === 'Pagination') {
|
||
initialValue = Number(initialValue) || 1
|
||
} else if (initialValue === undefined) {
|
||
initialValue = state.propertyName === 'checked' ? false : ''
|
||
}
|
||
|
||
const identifier = safeIdentifier(node.id)
|
||
return {
|
||
...state,
|
||
stateName: `runtime_${identifier}_${safeIdentifier(state.propertyName)}`,
|
||
setterName: `setRuntime_${identifier}_${safeIdentifier(state.propertyName)}`,
|
||
initialValue,
|
||
}
|
||
}
|
||
|
||
/** Prop of a node that the enclosing SqlDataSource writes the record back from. */
|
||
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
|
||
if (!formScope) return null
|
||
const propertyName = getSqlFormValueProperty(node, formScope.sourceId)
|
||
if (!propertyName) return null
|
||
return {
|
||
propertyName,
|
||
eventName: node.type === 'AutoComplete' ? 'onInputChange' : 'onChange',
|
||
path: node.bindings[propertyName].path,
|
||
setterName: formScope.setterName,
|
||
sourceId: formScope.sourceId,
|
||
}
|
||
}
|
||
|
||
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
|
||
isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type)
|
||
|
||
/**
|
||
* Options expression for a data component: the endpoint binding mapped through
|
||
* `toSelectOptions` (honouring the configured label/value columns), otherwise the
|
||
* static collection maintained in the designer.
|
||
*/
|
||
const optionCollectionExpression = (node: DesignerNode, itemVariable?: string) => {
|
||
const collectionProperty = getDesignerCollectionProperty(node.type)
|
||
const binding = node.bindings?.[collectionProperty]
|
||
const boundExpression = binding?.sourceId
|
||
? bindingExpression(node, collectionProperty, itemVariable)
|
||
: ''
|
||
if (boundExpression) {
|
||
return `toSelectOptions(${boundExpression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
|
||
}
|
||
const staticOptions = node.props[collectionProperty]
|
||
return staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
|
||
}
|
||
|
||
/** Node stripped of its collection prop/binding; those are rendered as children. */
|
||
const withoutCollection = (node: DesignerNode) => {
|
||
const collectionProperty = getDesignerCollectionProperty(node.type)
|
||
const props = { ...node.props }
|
||
const bindings = { ...(node.bindings || {}) }
|
||
delete props[collectionProperty]
|
||
delete bindings[collectionProperty]
|
||
return { node: { ...node, props, bindings }, props, bindings }
|
||
}
|
||
|
||
const propsToCode = (
|
||
node: DesignerNode,
|
||
itemVariable?: string,
|
||
omitProperties: string[] = [],
|
||
formScope?: FormScope,
|
||
) => {
|
||
const omitted = new Set(omitProperties)
|
||
const runtimeState = getRuntimeStateSpec(node)
|
||
const formField = getFormScopeField(node, formScope)
|
||
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
|
||
// Inside a SqlDataSource the bound value is editable: the change is written to
|
||
// the record, so the input must not be forced read-only.
|
||
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField)
|
||
const props = Object.entries(node.props)
|
||
.filter(
|
||
([key, value]) =>
|
||
key !== 'children' &&
|
||
key !== runtimeState?.propertyName &&
|
||
// Seeds a new SqlDataSource record; it is not a prop of the component.
|
||
key !== SQL_DEFAULT_VALUE_PROP &&
|
||
!omitted.has(key) &&
|
||
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
|
||
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
|
||
// A field the SqlDataSource writes back must stay editable, whatever the
|
||
// node happens to carry from the toolbox defaults.
|
||
!(formField && (key === 'readOnly' || key === 'disabled')) &&
|
||
!node.bindings?.[key]?.sourceId &&
|
||
// A zero width or height is the toolbox default, not a request for a zero
|
||
// sized element - emitting it would collapse the image to nothing.
|
||
!((key === 'width' || key === 'height') && value === 0) &&
|
||
value !== '' &&
|
||
value !== undefined,
|
||
)
|
||
.map(([key, value]) =>
|
||
// An icon is stored as a name; PlatformIcon turns it back into an element at
|
||
// runtime and renders nothing for an unknown one.
|
||
key === DESIGNER_SELECT_COMPONENT_PROPERTY && typeof value === 'string'
|
||
? // The name is resolved at runtime, where the react-select flavours live.
|
||
`${key}={PlatformSelectComponent(${JSON.stringify(value)})}`
|
||
: DESIGNER_ICON_PROPERTIES.has(key) && typeof value === 'string'
|
||
? `${key}={<PlatformIcon name=${JSON.stringify(value)} />}`
|
||
: // Dates live in the document as ISO strings; pickers need Date instances.
|
||
isDesignerDateProperty(node.type, key)
|
||
? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}`
|
||
: `${key}=${serializeValue(value)}`,
|
||
)
|
||
|
||
Object.keys(node.bindings || {}).forEach((propertyName) => {
|
||
if (propertyName === 'children') return
|
||
const expression = bindingExpression(node, propertyName, itemVariable)
|
||
if (expression) {
|
||
// Label/value columns come from the bound property itself; the collection
|
||
// is named `items` on some data components and `options` on others.
|
||
const binding = node.bindings?.[propertyName]
|
||
const propertyExpression = isOptionCollectionProperty(node, propertyName)
|
||
? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
|
||
: // Pickers need a Date instance; an endpoint column is an ISO string.
|
||
isDesignerDateProperty(node.type, propertyName)
|
||
? `toDesignerDate(${expression}, ${isDesignerDateOnlyComponent(node.type)})`
|
||
: // Select is controlled by the option object, not by the raw column value.
|
||
node.type === 'Select' && propertyName === 'value'
|
||
? `toSelectValue(${optionCollectionExpression(node, itemVariable)}, ${expression}, ${Boolean(node.props.isMulti)})`
|
||
: // An empty record (New mode) would otherwise hand React `undefined`,
|
||
// which flips the field to uncontrolled and makes it unusable.
|
||
formField?.propertyName === propertyName
|
||
? `(${expression}) ?? ${propertyName === 'checked' ? 'false' : '""'}`
|
||
: expression
|
||
props.push(`${propertyName}={${propertyExpression}}`)
|
||
}
|
||
})
|
||
|
||
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
|
||
|
||
if (runtimeState && !omitted.has(runtimeState.propertyName)) {
|
||
props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
|
||
}
|
||
|
||
const handlerNames = new Set(
|
||
Object.entries(node.events)
|
||
.filter(([, script]) => script.trim())
|
||
.map(([eventName]) => eventName),
|
||
)
|
||
if (runtimeState) handlerNames.add(runtimeState.eventName)
|
||
if (formField) handlerNames.add(formField.eventName)
|
||
handlerNames.forEach((eventName) =>
|
||
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`),
|
||
)
|
||
|
||
// Spread first so anything configured on the node overrides the defaults.
|
||
const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : []
|
||
// …and the ref overrides spread last, because a script that disables or
|
||
// refills a component has to win over both the defaults and the design values.
|
||
const trailing = isRefControlled(node) ? [`{...${refPropsExpression(node.ref as string)}}`] : []
|
||
const allProps = [...leading, ...props, ...trailing]
|
||
return allProps.length ? ` ${allProps.join(' ')}` : ''
|
||
}
|
||
|
||
const indent = (text: string, level: number) =>
|
||
text
|
||
.split('\n')
|
||
.map((line) => `${' '.repeat(level)}${line}`)
|
||
.join('\n')
|
||
|
||
const sqlIdentifiers = (node: DesignerNode) => {
|
||
const identifier = safeIdentifier(node.id)
|
||
return {
|
||
identifier,
|
||
record: `data_${identifier}`,
|
||
setRecord: `setData_${identifier}`,
|
||
rows: `sqlRows_${identifier}`,
|
||
busy: `sqlBusy_${identifier}`,
|
||
setBusy: `setSqlBusy_${identifier}`,
|
||
error: `sqlError_${identifier}`,
|
||
setError: `setSqlError_${identifier}`,
|
||
key: `sqlKey_${identifier}`,
|
||
keyParam: `sqlKeyParam_${identifier}`,
|
||
hasKey: `sqlHasKey_${identifier}`,
|
||
original: `sqlOriginal_${identifier}`,
|
||
setOriginal: `setSqlOriginal_${identifier}`,
|
||
mode: `sqlMode_${identifier}`,
|
||
setMode: `setSqlMode_${identifier}`,
|
||
index: `sqlIndex_${identifier}`,
|
||
setIndex: `setSqlIndex_${identifier}`,
|
||
selectUrl: `sqlSelectUrl_${identifier}`,
|
||
filters: `sqlFilters_${identifier}`,
|
||
filterKey: `sqlFilterKey_${identifier}`,
|
||
urlKey: `sqlUrlKey_${identifier}`,
|
||
setField: `sqlSetField_${identifier}`,
|
||
reload: `sqlReload_${identifier}`,
|
||
refresh: `sqlRefresh_${identifier}`,
|
||
create: `sqlNew_${identifier}`,
|
||
draft: `sqlDraft_${identifier}`,
|
||
host: `sqlHost_${identifier}`,
|
||
save: `sqlSave_${identifier}`,
|
||
remove: `sqlDelete_${identifier}`,
|
||
previous: `sqlPrev_${identifier}`,
|
||
next: `sqlNext_${identifier}`,
|
||
// Mirrors of the record/mode state, so an event handler declared further down
|
||
// the component body reads the current value instead of a closed over one.
|
||
recordRef: `sqlRecordRef_${identifier}`,
|
||
modeRef: `sqlModeRef_${identifier}`,
|
||
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
|
||
}
|
||
}
|
||
|
||
/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */
|
||
const sqlEventHandler = (node: DesignerNode, eventName: string) =>
|
||
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : ''
|
||
|
||
const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0
|
||
|
||
/**
|
||
* `await`ed call of an event script, emitted only when the event carries one.
|
||
* `payload` is the object literal the script sees as `event`.
|
||
*/
|
||
const sqlEventCall = (node: DesignerNode, eventName: string, payload: string) => {
|
||
const handler = sqlEventHandler(node, eventName)
|
||
return handler ? `await ${handler}(${payload})` : ''
|
||
}
|
||
|
||
/**
|
||
* Cancelable variant: the command stops when the script returns exactly `false`,
|
||
* so a script that returns nothing (the common case) never blocks anything.
|
||
*/
|
||
const sqlCancelableEventCall = (node: DesignerNode, eventName: string, payload: string) => {
|
||
const handler = sqlEventHandler(node, eventName)
|
||
return handler ? `if ((await ${handler}(${payload})) === false) return` : ''
|
||
}
|
||
|
||
/** Same, but inside a `try` that already owns the busy flag — needs a reset. */
|
||
const sqlErrorEventCall = (node: DesignerNode, action: string) => {
|
||
const handler = sqlEventHandler(node, 'onError')
|
||
const names = sqlIdentifiers(node)
|
||
return handler
|
||
? `\n await ${handler}({ message: toSqlErrorMessage(error), action: ${JSON.stringify(action)}, error, record: ${names.recordRef}.current })`
|
||
: ''
|
||
}
|
||
|
||
/**
|
||
* Runtime expression a filter reads its value from. A `record` filter addresses
|
||
* another SqlDataSource by its ref, which is what turns a master container and a
|
||
* detail Grid into a filtered pair without a line of script.
|
||
*/
|
||
const filterValueExpression = (
|
||
filter: DesignerDataSourceFilter,
|
||
sqlRecordByRef: Map<string, string>,
|
||
) => {
|
||
if (filter.source === 'static') return JSON.stringify(filter.value)
|
||
if (filter.source === 'query' || filter.source === 'route') {
|
||
return `readDesignerUrlValue(${JSON.stringify(filter.source)}, ${JSON.stringify(filter.value)})`
|
||
}
|
||
const [ref, ...rest] = String(filter.value).split('.')
|
||
const record = sqlRecordByRef.get(ref)
|
||
const column = rest.join('.')
|
||
// A ref that no longer exists resolves to nothing; a required filter then
|
||
// cancels the request instead of loading the whole collection.
|
||
if (!record || !column) return '""'
|
||
return `readSqlField(${record}, ${JSON.stringify(column)})`
|
||
}
|
||
|
||
/**
|
||
* Filter descriptor array `buildPlatformFilterParams` consumes. A platform view
|
||
* is filtered through a DevExtreme expression rather than through query
|
||
* parameters, so the operator is emitted in its DevExtreme form.
|
||
*/
|
||
const platformFilterEntriesExpression = (
|
||
node: DesignerNode,
|
||
sqlRecordByRef: Map<string, string>,
|
||
) => {
|
||
const filters = getDesignerNodeFilters(node)
|
||
if (!filters.length) return ''
|
||
return `[${filters
|
||
.map(
|
||
(filter) =>
|
||
`{ field: ${JSON.stringify(filter.field)}, operator: ${JSON.stringify(
|
||
DESIGNER_FILTER_DX_OPERATORS[filter.operator],
|
||
)}, kind: ${JSON.stringify(filter.operator)}, value: ${
|
||
isValuelessDesignerFilter(filter) ? '""' : filterValueExpression(filter, sqlRecordByRef)
|
||
}, required: ${Boolean(filter.required)} }`,
|
||
)
|
||
.join(', ')}]`
|
||
}
|
||
|
||
/** Filter descriptor array `buildFilteredUrl` consumes, or `''` when there is none. */
|
||
const filterEntriesExpression = (
|
||
source: DesignerDataSource | undefined,
|
||
sqlRecordByRef: Map<string, string>,
|
||
) => {
|
||
const filters = getDesignerDataSourceFilters(source)
|
||
if (!filters.length) return ''
|
||
return `[${filters
|
||
.map(
|
||
(filter) =>
|
||
`{ param: ${JSON.stringify(toDesignerFilterParamName(filter))}, value: ${
|
||
isValuelessDesignerFilter(filter) ? '"true"' : filterValueExpression(filter, sqlRecordByRef)
|
||
}, required: ${Boolean(filter.required)} }`,
|
||
)
|
||
.join(', ')}]`
|
||
}
|
||
|
||
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
|
||
const sqlSlotLiteral = (
|
||
node: DesignerNode,
|
||
property: string,
|
||
method: string,
|
||
dataSources: DesignerDataSource[],
|
||
) => {
|
||
const source = dataSources.find(
|
||
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, property),
|
||
)
|
||
if (!source?.url.trim()) return 'null'
|
||
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
|
||
}
|
||
|
||
const sqlDataSourceHooks = (
|
||
node: DesignerNode,
|
||
dataSources: DesignerDataSource[],
|
||
sqlRecordByRef: Map<string, string>,
|
||
) => {
|
||
const names = sqlIdentifiers(node)
|
||
const keyField = getSqlDataSourceKeyField(node)
|
||
const selectSource = dataSources.find(
|
||
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
|
||
)
|
||
const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef)
|
||
// The Select endpoint already owns a fetch hook, so the container reuses that
|
||
// state instead of issuing a second request for the same URL.
|
||
const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null'
|
||
// Must mirror the setter emitted by the data hooks, or the reload would call an
|
||
// identifier that was never declared.
|
||
const setSelectData =
|
||
selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/')
|
||
? `setData_${safeIdentifier(selectSource.id)}`
|
||
: ''
|
||
const keySource = getSqlDataSourceKeySource(node)
|
||
const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})`
|
||
const slots = SQL_DATA_SOURCE_SLOTS.map(
|
||
(slot) =>
|
||
` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`,
|
||
).join('\n')
|
||
|
||
return `${slots}
|
||
const ${names.key} = ${JSON.stringify(keyField)}
|
||
const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))}
|
||
const [${names.record}, ${names.setRecord}] = React.useState({})
|
||
const [${names.original}, ${names.setOriginal}] = React.useState({})
|
||
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
||
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
||
const [${names.busy}, ${names.setBusy}] = React.useState(false)
|
||
const [${names.error}, ${names.setError}] = React.useState("")
|
||
// Container element, so New can hand the cursor to the first field of the form.
|
||
const ${names.host} = React.useRef(null)${
|
||
hasSqlEvents(node)
|
||
? `
|
||
// Event scripts are declared after these hooks, so they read the record and the
|
||
// mode through refs rather than through the closure they were created in.
|
||
const ${names.recordRef} = React.useRef(${names.record})
|
||
${names.recordRef}.current = ${names.record}
|
||
const ${names.modeRef} = React.useRef(${names.mode})`
|
||
: ''
|
||
}
|
||
// The Select key can come from the page URL, which is how a detail page reads
|
||
// /api/app/orders/{id} or /api/app/orders?id=… for a single record.
|
||
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
|
||
${
|
||
selectFilters
|
||
? ` // Column filters configured on the Select source. They are rebuilt on every
|
||
// render so a master record change reaches the URL, and the memo below is keyed
|
||
// on their serialized form rather than on each value.
|
||
const ${names.filters} = ${selectFilters}
|
||
const ${names.filterKey} = JSON.stringify(${names.filters})
|
||
`
|
||
: ''
|
||
} const ${names.selectUrl} = React.useMemo(() => {
|
||
if (!${names.slot('selectEndpoint')}) return ""
|
||
const base = ${names.urlKey}
|
||
? (() => {
|
||
const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam})
|
||
return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey})
|
||
})()
|
||
: ${names.slot('selectEndpoint')}.url
|
||
${
|
||
selectFilters
|
||
? `// An unmet required filter yields no URL at all, which stops the Select
|
||
// from loading the unfiltered collection.
|
||
return buildFilteredUrl(base, ${names.filters})`
|
||
: 'return base'
|
||
}
|
||
}, [${names.urlKey}${selectFilters ? `, ${names.filterKey}` : ''}])
|
||
// A key in the page URL always narrows the result: a list endpoint ignores the
|
||
// parameter server side, so the requested record is picked out here. Without a
|
||
// key every row is kept and the navigation below takes over.
|
||
const ${names.rows} = React.useMemo(
|
||
() => filterSqlRowsByKey(${rowsExpression}, ${names.key}, ${names.urlKey}),
|
||
[${selectData}, ${names.urlKey}],
|
||
)
|
||
// A fresh result set resets the position; New mode is left untouched so an
|
||
// unsaved draft is not overwritten by a re-render.
|
||
React.useEffect(() => {
|
||
${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0))
|
||
}, [${names.rows}])${
|
||
sqlEventHandler(node, 'onLoad')
|
||
? `
|
||
// Fires once the Select result has settled, whether it carries rows or not —
|
||
// an empty result is exactly the case a script wants to react to.
|
||
React.useEffect(() => {
|
||
if (${selectData} === null || ${selectData} === undefined) return
|
||
void ${sqlEventHandler(node, 'onLoad')}({ rows: ${names.rows}, count: ${names.rows}.length, record: ${names.rows}[${names.index}] ?? {}, index: ${names.index} })
|
||
}, [${names.rows}])`
|
||
: ''
|
||
}
|
||
React.useEffect(() => {
|
||
const row = ${names.rows}[${names.index}]
|
||
if (!row) return
|
||
${names.setRecord}(row)
|
||
${names.setOriginal}(row)
|
||
${names.setMode}("edit")${
|
||
sqlEventHandler(node, 'onRecordChange')
|
||
? `
|
||
void ${sqlEventHandler(node, 'onRecordChange')}({ record: row, index: ${names.index}, rows: ${names.rows}, count: ${names.rows}.length })`
|
||
: ''
|
||
}
|
||
}, [${names.rows}, ${names.index}])${
|
||
sqlEventHandler(node, 'onModeChange')
|
||
? `
|
||
// The ref starts at the initial mode, so the first render is not reported as a
|
||
// change; only real new/edit transitions reach the script.
|
||
React.useEffect(() => {
|
||
if (${names.modeRef}.current === ${names.mode}) return
|
||
${names.modeRef}.current = ${names.mode}
|
||
void ${sqlEventHandler(node, 'onModeChange')}({ mode: ${names.mode}, record: ${names.recordRef}.current })
|
||
}, [${names.mode}])`
|
||
: ''
|
||
}
|
||
const ${names.hasKey} = (() => {
|
||
const value = readSqlField(${names.record}, ${names.key})
|
||
return value !== undefined && value !== null && value !== ""
|
||
})()
|
||
const ${names.setField} = React.useCallback(${
|
||
sqlEventHandler(node, 'onFieldChange')
|
||
? `(path, value) => {
|
||
${names.setRecord}((current) => setSqlField(current, path, value))
|
||
// The script is handed the record with the write already applied, so a
|
||
// calculation can read the sibling columns without re-deriving the change.
|
||
void ${sqlEventHandler(node, 'onFieldChange')}({ field: path, value, previous: getByPath(${names.recordRef}.current, path), record: setSqlField(${names.recordRef}.current, path, value) })
|
||
}`
|
||
: `(path, value) => ${names.setRecord}((current) => setSqlField(current, path, value))`
|
||
}, [])
|
||
const ${names.reload} = React.useCallback(async () => {${
|
||
setSelectData
|
||
? `
|
||
if (!${names.selectUrl}) return
|
||
// Calling a URL that still holds a placeholder is a guaranteed 400, so the
|
||
// missing key is reported in the component instead.
|
||
if (hasSqlUrlParams(${names.selectUrl})) {
|
||
throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.")
|
||
}
|
||
const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" })
|
||
${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))`
|
||
: ''
|
||
}
|
||
}, [${setSelectData ? names.selectUrl : ''}])
|
||
// Reload wrapped with the busy/error handling the toolbar and mount effect need,
|
||
// so a failing Select is reported in the component instead of the console.
|
||
const ${names.refresh} = React.useCallback(async () => {
|
||
${names.setBusy}(true)
|
||
${names.setError}("")
|
||
try {
|
||
await ${names.reload}()
|
||
} catch (error) {
|
||
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'reload')}
|
||
} finally {
|
||
${names.setBusy}(false)
|
||
}
|
||
}, [${names.reload}])
|
||
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
||
${names.setError}("")
|
||
// Defaults are resolved on every New, so an @today column carries the day the
|
||
// record was opened rather than the day the component was generated.
|
||
const ${names.draft} = buildSqlDefaults(${JSON.stringify(collectSqlDefaultFields(node))})
|
||
${names.setRecord}(${names.draft})
|
||
${names.setOriginal}({})
|
||
${names.setMode}("new")
|
||
// After the re-render that paints the empty form, not before it.
|
||
focusFirstSqlField(${names.host})${
|
||
sqlEventHandler(node, 'onNewRecord')
|
||
? `
|
||
${sqlEventCall(node, 'onNewRecord', `{ record: ${names.draft} }`)}`
|
||
: ''
|
||
}
|
||
}, [])
|
||
const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), [])
|
||
const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}])
|
||
const ${names.save} = async () => {
|
||
const isNew = ${names.mode} === "new"
|
||
const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')}
|
||
if (!target) {
|
||
${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.")
|
||
return
|
||
}
|
||
// Update carries only the edited columns plus the key; Insert sends the record.
|
||
const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key})
|
||
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
|
||
${names.setError}("Kaydedilecek bir değişiklik yok.")
|
||
return
|
||
}${
|
||
sqlEventHandler(node, 'onBeforeSave')
|
||
? `
|
||
// Runs before the busy flag is raised: a vetoed save must leave the form in
|
||
// exactly the state the user left it in.
|
||
${sqlCancelableEventCall(node, 'onBeforeSave', `{ record: ${names.record}, original: ${names.original}, payload, mode: ${names.mode}, isNew }`)}`
|
||
: ''
|
||
}
|
||
${names.setBusy}(true)
|
||
${names.setError}("")
|
||
try {
|
||
const response = await callSqlEndpoint(target, ${names.record}, ${names.key}, payload)
|
||
// With a Select endpoint the reload is the source of truth and its effect
|
||
// repopulates the form; without one, the response row is all there is.
|
||
if (${names.slot('selectEndpoint')}) {
|
||
await ${names.reload}()
|
||
} else {
|
||
const saved = toSqlRows(response?.data, "")[0]
|
||
if (saved) {
|
||
${names.setRecord}(saved)
|
||
${names.setOriginal}(saved)
|
||
}
|
||
}
|
||
${names.setMode}("edit")${
|
||
sqlEventHandler(node, 'onAfterSave')
|
||
? `
|
||
${sqlEventCall(node, 'onAfterSave', `{ record: ${names.record}, response: response?.data, mode: ${names.mode}, isNew }`)}`
|
||
: ''
|
||
}
|
||
} catch (error) {
|
||
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'save')}
|
||
} finally {
|
||
${names.setBusy}(false)
|
||
}
|
||
}
|
||
const ${names.remove} = async () => {
|
||
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
|
||
sqlEventHandler(node, 'onBeforeDelete')
|
||
? `
|
||
// Asked before the confirm dialog, so a script can block the delete without
|
||
// the user ever being prompted.
|
||
${sqlCancelableEventCall(node, 'onBeforeDelete', `{ record: ${names.record}, key: readSqlField(${names.record}, ${names.key}) }`)}`
|
||
: ''
|
||
}
|
||
// Deleting cannot be undone from the form, so it always asks first.
|
||
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
|
||
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
|
||
${names.setBusy}(true)
|
||
${names.setError}("")
|
||
try {${
|
||
sqlEventHandler(node, 'onAfterDelete')
|
||
? `
|
||
const deleted = ${names.record}`
|
||
: ''
|
||
}
|
||
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
|
||
${names.setRecord}({})
|
||
${names.setOriginal}({})
|
||
${names.setMode}("new")
|
||
await ${names.reload}()${
|
||
sqlEventHandler(node, 'onAfterDelete')
|
||
? `
|
||
${sqlEventCall(node, 'onAfterDelete', '{ record: deleted }')}`
|
||
: ''
|
||
}
|
||
} catch (error) {
|
||
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'delete')}
|
||
} finally {
|
||
${names.setBusy}(false)
|
||
}
|
||
}${
|
||
node.props.autoLoad === false
|
||
? ''
|
||
: `
|
||
React.useEffect(() => { void ${names.refresh}() }, [${names.refresh}])`
|
||
}`
|
||
}
|
||
|
||
const SQL_TOOLBAR_BUTTON_CLASS =
|
||
'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40'
|
||
|
||
const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
|
||
const names = sqlIdentifiers(node)
|
||
const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
|
||
const columnCount = getSqlDataSourceColumnCount(node)
|
||
const gap = Number(node.props.gap) || 0
|
||
// Children sit one level deeper once they are wrapped in the column grid.
|
||
const children = node.children
|
||
.map((child) => nodeToCode(child, level + (columnCount > 1 ? 3 : 2), itemVariable, formScope))
|
||
.join('\n')
|
||
const className = JSON.stringify(String(node.props.className || ''))
|
||
const style = `{ display: "flex", flexDirection: "column", gap: ${gap} }`
|
||
// The toolbar and the error line stay in the outer column, so only the fields
|
||
// are laid out side by side. A single column emits no wrapper at all, which
|
||
// keeps the markup of every page saved before the setting existed unchanged.
|
||
const gridStyle = `{ display: "grid", gridTemplateColumns: "repeat(${columnCount}, minmax(0, 1fr))", gap: ${gap}, alignItems: "start" }`
|
||
const content =
|
||
columnCount > 1
|
||
? `${indent(`<div style={${gridStyle}}>`, level + 2)}\n${children}\n${indent('</div>', level + 2)}`
|
||
: children
|
||
const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
|
||
// Navigation appears on its own whenever there is more than one record to walk.
|
||
const navigation = `
|
||
${indent(`{${names.rows}.length > 1 ? (`, level + 3)}
|
||
${indent('<>', level + 4)}
|
||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 5)}
|
||
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 5)}
|
||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 5)}
|
||
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 5)}
|
||
${indent('</>', level + 4)}
|
||
${indent(') : null}', level + 3)}`
|
||
const toolbar =
|
||
node.props.showToolbar === false
|
||
? ''
|
||
: `
|
||
${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 2)}${navigation}
|
||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 3)}
|
||
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 3)}
|
||
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 3)}
|
||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 3)}
|
||
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 3)}
|
||
${indent('</div>', level + 2)}`
|
||
const error = `
|
||
${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 2)}`
|
||
// A filled Title turns the container into a Card header; a `::` value is a
|
||
// localization key, so `staticValueExpression` emits a translate() call for it
|
||
// and the header follows the active language. The header extra carries the key
|
||
// of the record on screen.
|
||
const title = String(node.props.title ?? '').trim()
|
||
const headerExtra = `{${names.hasKey} ? ${names.key} + ": " + readSqlField(${names.record}, ${names.key}) : ${names.mode} === "new" ? "Yeni kayıt" : ""}`
|
||
const cardStart = title
|
||
? `<UiKit.Card bodyClass="p-4" header={${staticValueExpression(title)}} headerExtra={<span className="text-xs text-slate-500 dark:text-slate-400">${headerExtra}</span>}>`
|
||
: '<UiKit.Card bodyClass="p-4">'
|
||
|
||
return `${indent(cardStart, level)}
|
||
${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level + 1)}
|
||
${content}${toolbar}${error}
|
||
${indent('</div>', level + 1)}
|
||
${indent('</UiKit.Card>', level)}`
|
||
}
|
||
|
||
/**
|
||
* Wraps a node in its visibility guard. Tabs is the one component emitted as a
|
||
* braced expression rather than an element, so its outer `{…}` is turned into
|
||
* parentheses — inside the ternary the code is read as an expression, not as a
|
||
* JSX child.
|
||
*/
|
||
const withVisibilityGuard = (code: string, node: DesignerNode, level: number) => {
|
||
if (!isRefControlled(node)) return code
|
||
const trimmed = code.trim()
|
||
const body =
|
||
trimmed.startsWith('{') && trimmed.endsWith('}')
|
||
? code.replace(/^(\s*)\{/, '$1(').replace(/\}(\s*)$/, ')$1')
|
||
: code
|
||
return `${indent(`{designerRefHidden(${JSON.stringify(node.ref)}) ? null : (`, level)}
|
||
${body}
|
||
${indent(')}', level)}`
|
||
}
|
||
|
||
const nodeToCode = (
|
||
node: DesignerNode,
|
||
level = 0,
|
||
itemVariable?: string,
|
||
formScope?: FormScope,
|
||
): string =>
|
||
withVisibilityGuard(
|
||
renderNodeToCode(node, isRefControlled(node) ? level + 1 : level, itemVariable, formScope),
|
||
node,
|
||
level,
|
||
)
|
||
|
||
const renderNodeToCode = (
|
||
node: DesignerNode,
|
||
level = 0,
|
||
itemVariable?: string,
|
||
formScope?: FormScope,
|
||
): string => {
|
||
if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable)
|
||
if (node.type === 'Spacer') {
|
||
return indent(
|
||
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
|
||
level,
|
||
)
|
||
}
|
||
|
||
if (node.type === 'FlexRow') {
|
||
const style = JSON.stringify(flexRowStyle(node.props))
|
||
const className = node.props.className
|
||
? ` className=${JSON.stringify(node.props.className)}`
|
||
: ''
|
||
const children = node.children
|
||
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
|
||
.join('\n')
|
||
return `${indent(`<div${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
|
||
}
|
||
|
||
if (node.type === 'PageContainer') {
|
||
const gap = Number(node.props.gap) || 0
|
||
const className = JSON.stringify(String(node.props.className || ''))
|
||
const style = `{ display: "flex", flexDirection: "column", gap: ${gap}, margin: "0 auto", maxWidth: ${JSON.stringify(String(node.props.maxWidth || '1280px'))}, padding: ${Number(node.props.padding) || 0}, width: "100%" }`
|
||
const children = node.children
|
||
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
|
||
.join('\n')
|
||
return `${indent(`<div className=${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
|
||
}
|
||
|
||
if (node.type === 'Table') {
|
||
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
|
||
const columnCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.columnCount) || 3)))
|
||
const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
|
||
const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
|
||
const slot = `table:${rowIndex}:${columnIndex}`
|
||
const cellChildren = node.children.filter(
|
||
(child, childIndex) =>
|
||
(child.slot ||
|
||
`table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`) === slot,
|
||
)
|
||
const content = cellChildren.length
|
||
? cellChildren
|
||
.map((child) => nodeToCode(child, level + 3, itemVariable, formScope))
|
||
.join('\n')
|
||
: indent('<span />', level + 3)
|
||
return `${indent('<td>', level + 2)}\n${content}\n${indent('</td>', level + 2)}`
|
||
}).join('\n')
|
||
return `${indent('<tr>', level + 1)}\n${cells}\n${indent('</tr>', level + 1)}`
|
||
}).join('\n')
|
||
const className = JSON.stringify(String(node.props.className || ''))
|
||
return `${indent(`<UiKit.Table className={${className} + " [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5"} borderlessRow compact={${Boolean(node.props.compact)}} hoverable={${node.props.hoverable !== false}} overflow={${node.props.overflow !== false}} style={{ minWidth: "100%", tableLayout: "fixed", width: "100%" }}>`, level)}
|
||
${indent('<tbody>', level + 1)}
|
||
${rows}
|
||
${indent('</tbody>', level + 1)}
|
||
${indent('</UiKit.Table>', level)}`
|
||
}
|
||
|
||
if (node.type === 'Grid') {
|
||
const columns = Number(node.props.cols) || 3
|
||
const gap = (Number(node.props.gap) || 0) * 4
|
||
const itemsExpression =
|
||
bindingExpression(node, 'items', itemVariable) ||
|
||
(Array.isArray(node.props.items) ? JSON.stringify(node.props.items) : '')
|
||
const itemsBinding = node.bindings?.items
|
||
const sourceRootExpression =
|
||
!itemVariable && itemsBinding?.sourceId ? `data_${safeIdentifier(itemsBinding.sourceId)}` : ''
|
||
const collectionExpression =
|
||
sourceRootExpression && sourceRootExpression !== itemsExpression
|
||
? `(Array.isArray(${itemsExpression}) ? ${itemsExpression} : Array.isArray(${sourceRootExpression}) ? ${sourceRootExpression} : null)`
|
||
: itemsExpression
|
||
const gridClassName = JSON.stringify(String(node.props.className || ''))
|
||
const gridStart = `<div className=${gridClassName} style={{ display: "grid", gridTemplateColumns: "repeat(${columns}, minmax(0, 1fr))", gap: ${gap} }}>`
|
||
|
||
if (collectionExpression && node.children.length === 0) {
|
||
const identifier = safeIdentifier(node.id)
|
||
const itemsVariable = `gridItems_${identifier}`
|
||
const columnsVariable = `gridColumns_${identifier}`
|
||
const itemVariable = `gridItem_${identifier}`
|
||
const valueVariable = `cellValue_${identifier}`
|
||
const configuredColumns = Array.isArray(node.props.dataColumns)
|
||
? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
|
||
: null
|
||
// Columns configured to show a related text instead of the raw key. The
|
||
// option rows come from a data source of the page, which already has its
|
||
// own state and mount fetch, so nothing extra is requested for them.
|
||
const lookupsVariable = `gridLookups_${identifier}`
|
||
const lookupEntries = Object.entries(getUsableDesignerColumnLookups(node)).filter(
|
||
([column]) => !configuredColumns || configuredColumns.includes(column),
|
||
)
|
||
// Header overrides. `staticValueExpression` turns a `::` value into a
|
||
// translate() call, so a caption follows the active language.
|
||
const captionsVariable = `gridCaptions_${identifier}`
|
||
const captionEntries = Object.entries(getDesignerColumnCaptions(node)).filter(
|
||
([column]) => !configuredColumns || configuredColumns.includes(column),
|
||
)
|
||
const captionsLiteral = `{ ${captionEntries
|
||
.map(([column, caption]) => `${JSON.stringify(column)}: ${staticValueExpression(caption)}`)
|
||
.join(', ')} }`
|
||
const lookupsLiteral = `{ ${lookupEntries
|
||
.map(
|
||
([column, lookup]) =>
|
||
`${JSON.stringify(column)}: { rows: data_${safeIdentifier(lookup.sourceId)}, path: ${JSON.stringify(lookup.path)}, value: ${JSON.stringify(lookup.valueField)}, text: ${JSON.stringify(lookup.textField)} }`,
|
||
)
|
||
.join(', ')} }`
|
||
const columnsExpression = configuredColumns
|
||
? JSON.stringify(configuredColumns)
|
||
: `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]`
|
||
const compact = Boolean(node.props.compact)
|
||
const borderlessRow = Boolean(node.props.borderlessRow)
|
||
const hoverable = node.props.hoverable !== false
|
||
const overflow = node.props.overflow !== false
|
||
const tableTextClass = compact ? 'text-[11px]' : 'text-xs'
|
||
const cellSpacingClass = compact ? 'px-2 py-1' : 'px-3 py-2'
|
||
const rowClass = [
|
||
borderlessRow ? '' : 'border-b border-slate-100 last:border-b-0 dark:border-slate-800',
|
||
hoverable ? 'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
const overflowClass = overflow ? 'overflow-auto' : 'overflow-visible'
|
||
const table = `${indent('{(() => {', level + 1)}
|
||
${indent(`const ${itemsVariable} = ${collectionExpression}`, level + 2)}
|
||
${indent(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)}
|
||
${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)}
|
||
${indent(`if (${itemsVariable} === null || ${itemsVariable} === undefined) return null`, level + 2)}
|
||
${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
|
||
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)}
|
||
${captionEntries.length ? `${indent(`const ${captionsVariable} = ${captionsLiteral}`, level + 2)}\n` : ''}${lookupEntries.length ? `${indent(`const ${lookupsVariable} = ${lookupsLiteral}`, level + 2)}\n` : ''}${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)}
|
||
${indent('return (', level + 2)}
|
||
${indent(`<div className="min-w-0 ${overflowClass} rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900" style={{ gridColumn: "1 / -1" }}>`, level + 3)}
|
||
${indent(`<table className="w-full border-collapse text-left ${tableTextClass}">`, level + 4)}
|
||
${indent('<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200"><tr>', level + 5)}
|
||
${indent(`{${columnsVariable}.map((column) => <th key={column} className="whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${cellSpacingClass}">{${captionEntries.length ? `${captionsVariable}[column] ?? ` : ''}(column === "value" ? "Value" : column)}</th>)}`, level + 6)}
|
||
${indent('</tr></thead>', level + 5)}
|
||
${indent('<tbody>', level + 5)}
|
||
${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
|
||
${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, level + 7)}
|
||
${indent(`{${columnsVariable}.map((column) => {`, level + 8)}
|
||
${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)}
|
||
${lookupEntries.length ? `${indent(`const lookedUp = resolveLookupText(${lookupsVariable}[column], ${valueVariable})`, level + 9)}\n` : ''}${indent(`const cellText = ${lookupEntries.length ? `lookedUp === undefined ? ${valueVariable} : lookedUp` : valueVariable}`, level + 9)}
|
||
${indent(`const text = typeof cellText === "object" && cellText !== null && !(cellText instanceof Date) ? JSON.stringify(cellText) : formatLocaleValue(cellText) || "—"`, level + 9)}
|
||
${indent(`return <td key={column} className="max-w-64 truncate text-slate-800 dark:text-slate-100 ${cellSpacingClass}" title={text}>{text}</td>`, level + 9)}
|
||
${indent('})}', level + 8)}
|
||
${indent('</tr>', level + 7)}
|
||
${indent('))}', level + 6)}
|
||
${indent('</tbody>', level + 5)}
|
||
${indent('</table>', level + 4)}
|
||
${indent('</div>', level + 3)}
|
||
${indent(')', level + 2)}
|
||
${indent('})()}', level + 1)}`
|
||
return `${indent(gridStart, level)}\n${table}\n${indent('</div>', level)}`
|
||
}
|
||
|
||
const repeatedItemVariable = `item_${safeIdentifier(node.id)}`
|
||
const staticChildren = node.children
|
||
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
|
||
.join('\n')
|
||
const repeatedChildren = node.children.length
|
||
? node.children
|
||
.map((child) => nodeToCode(child, level + 4, repeatedItemVariable, formScope))
|
||
.join('\n')
|
||
: indent(
|
||
`<pre className="min-w-0 overflow-auto rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}</pre>`,
|
||
level + 4,
|
||
)
|
||
// The collection is read once into a local: it is tested for three cases
|
||
// before it is mapped, and repeating the expression for each of them made
|
||
// the emitted line unreadable.
|
||
const repeatedRowsVariable = `rows_${safeIdentifier(node.id)}`
|
||
const children = collectionExpression
|
||
? `${indent('{(() => {', level + 1)}
|
||
${indent(`const ${repeatedRowsVariable} = ${collectionExpression}`, level + 2)}
|
||
${indent(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)}
|
||
${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)}
|
||
${indent(`if (${repeatedRowsVariable} === null || ${repeatedRowsVariable} === undefined) return null`, level + 2)}
|
||
${indent(`if (!Array.isArray(${repeatedRowsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
|
||
${indent(`return ${repeatedRowsVariable}.map((${repeatedItemVariable}, index) => (`, level + 2)}
|
||
${indent(`<React.Fragment key={${repeatedItemVariable}?.id ?? index}>`, level + 3)}
|
||
${repeatedChildren}
|
||
${indent('</React.Fragment>', level + 3)}
|
||
${indent('))', level + 2)}
|
||
${indent('})()}', level + 1)}`
|
||
: staticChildren
|
||
return `${indent(gridStart, level)}\n${children}\n${indent('</div>', level)}`
|
||
}
|
||
|
||
if (node.kind === 'platform') {
|
||
const viewNames: Record<string, string> = {
|
||
ListView: 'List',
|
||
DataGridView: 'Grid',
|
||
TreeView: 'Tree',
|
||
GanttView: 'GanttView',
|
||
TodoBoard: 'TodoBoard',
|
||
CardView: 'CardView',
|
||
SchedulerView: 'SchedulerView',
|
||
PivotView: 'Pivot',
|
||
ChartView: 'Chart',
|
||
}
|
||
const platformParams = `platformParams_${safeIdentifier(node.id)}`
|
||
const hasFilters = getDesignerNodeFilters(node).length > 0
|
||
// The class list is only emitted when it carries something, so an untouched
|
||
// view keeps generating exactly the markup it did before.
|
||
const platformClassName = String(node.props.className || '').trim()
|
||
const host = `<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))}${platformClassName ? ` className=${JSON.stringify(platformClassName)}` : ''}${hasFilters ? ` searchParams={${platformParams}}` : ''} />`
|
||
if (!hasFilters) return indent(host, level)
|
||
// A required filter without a value renders nothing: showing the unfiltered
|
||
// list instead would read as a filter that is not applied.
|
||
return `${indent(`{${platformParams} === null ? null : (`, level)}
|
||
${indent(host, level + 1)}
|
||
${indent(')}', level)}`
|
||
}
|
||
|
||
if (node.type === 'Menu') {
|
||
const { node: menuNode, props: menuProps } = withoutCollection(node)
|
||
delete menuProps.variant
|
||
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
|
||
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
|
||
const menuPropsCode = propsToCode(menuNode, itemVariable, [], formScope)
|
||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{option.icon ? <span className="mr-2 inline-flex items-center"><PlatformIcon name={String(option.icon)} /></span> : null}{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
|
||
}
|
||
|
||
if (node.type === 'Dropdown') {
|
||
const { node: dropdownNode, props: dropdownProps, bindings } = withoutCollection(node)
|
||
// Rendered from the active option below, so `title` must not also be emitted
|
||
// by propsToCode — whether it is static or bound to an endpoint field.
|
||
const boundTitle = bindings.title?.sourceId
|
||
? bindingExpression(node, 'title', itemVariable)
|
||
: ''
|
||
const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '')
|
||
delete dropdownProps.title
|
||
delete bindings.title
|
||
const dropdownPropsCode = propsToCode(dropdownNode, itemVariable, [], formScope)
|
||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||
const activeKeyExpression =
|
||
getRuntimeStateSpec(node)?.stateName ||
|
||
bindingExpression(node, 'activeKey', itemVariable) ||
|
||
staticValueExpression(node.props.activeKey ?? '')
|
||
const titleExpression = `toDropdownTitle(${optionsExpression}, ${activeKeyExpression}, ${fallbackTitleExpression})`
|
||
return `${indent(`<UiKit.Dropdown${dropdownPropsCode} title={${titleExpression}}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
|
||
}
|
||
|
||
if (node.type === 'Pagination') {
|
||
const { node: paginationNode } = withoutCollection(node)
|
||
const paginationPropsCode = propsToCode(paginationNode, itemVariable, [], formScope)
|
||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||
return indent(
|
||
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
|
||
level,
|
||
)
|
||
}
|
||
|
||
if (node.type === 'Tabs') {
|
||
const { node: tabsNode } = withoutCollection(node)
|
||
// `value` is emitted below, resolved against the (possibly async) tab list.
|
||
const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'], formScope)
|
||
const identifier = safeIdentifier(node.id)
|
||
const optionsVariable = `tabItems_${identifier}`
|
||
const activeVariable = `tabValue_${identifier}`
|
||
const optionVariable = `tabItem_${identifier}`
|
||
const activeStateExpression =
|
||
getRuntimeStateSpec(node)?.stateName ||
|
||
bindingExpression(node, 'value', itemVariable) ||
|
||
staticValueExpression(node.props.value ?? '')
|
||
const childrenVariable = `tabChildren_${identifier}`
|
||
const fallbackVariable = `tabFallback_${identifier}`
|
||
|
||
// Children are assigned to a tab through a `tab:<value>` slot. Building a
|
||
// lookup keyed by tab value keeps this working when the tab list itself is
|
||
// only known at runtime (endpoint driven tabs).
|
||
const slotGroups = new Map<string, DesignerNode[]>()
|
||
node.children.forEach((child) => {
|
||
const key = getDesignerTabSlotValue(child.slot)
|
||
const group = slotGroups.get(key)
|
||
if (group) group.push(child)
|
||
else slotGroups.set(key, [child])
|
||
})
|
||
const fragmentFor = (children: DesignerNode[], fragmentLevel: number) =>
|
||
`${indent('<>', fragmentLevel)}\n${children
|
||
.map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable, formScope))
|
||
.join('\n')}\n${indent('</>', fragmentLevel)}`
|
||
|
||
const slottedEntries = [...slotGroups]
|
||
.filter(([key]) => key !== '')
|
||
.map(
|
||
([key, children]) =>
|
||
`${indent(`${JSON.stringify(key)}: (`, level + 2)}\n${fragmentFor(children, level + 3)}\n${indent('),', level + 2)}`,
|
||
)
|
||
.join('\n')
|
||
// Children saved before per-tab slots existed belong to the first tab.
|
||
const fallbackChildren = slotGroups.get('') || []
|
||
|
||
return `${indent('{(() => {', level)}
|
||
${indent(`const ${optionsVariable} = ${optionCollectionExpression(node, itemVariable)}`, level + 1)}
|
||
${indent(`const ${activeVariable} = toTabValue(${optionsVariable}, ${activeStateExpression})`, level + 1)}
|
||
${indent(`const ${childrenVariable} = {`, level + 1)}
|
||
${slottedEntries}
|
||
${indent('}', level + 1)}
|
||
${indent(`const ${fallbackVariable} = (`, level + 1)}
|
||
${fallbackChildren.length ? fragmentFor(fallbackChildren, level + 2) : indent('null', level + 2)}
|
||
${indent(')', level + 1)}
|
||
${indent('return (', level + 1)}
|
||
${indent(`<UiKit.Tabs${tabsPropsCode} value={${activeVariable}}>`, level + 2)}
|
||
${indent('<UiKit.Tabs.TabList>', level + 3)}
|
||
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => <UiKit.Tabs.TabNav key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)} disabled={Boolean(${optionVariable}.disabled)}>{String(${optionVariable}.label ?? ${optionVariable}.value ?? \`Sekme \${tabIndex + 1}\`)}</UiKit.Tabs.TabNav>)}`, level + 4)}
|
||
${indent('</UiKit.Tabs.TabList>', level + 3)}
|
||
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => (`, level + 3)}
|
||
${indent(`<UiKit.Tabs.TabContent key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)}>`, level + 4)}
|
||
${indent(`{${optionVariable}.content ? <div>{String(${optionVariable}.content)}</div> : null}`, level + 5)}
|
||
${indent(`{${childrenVariable}[String(${optionVariable}.value ?? tabIndex)] ?? null}`, level + 5)}
|
||
${indent(`{tabIndex === 0 ? ${fallbackVariable} : null}`, level + 5)}
|
||
${indent('</UiKit.Tabs.TabContent>', level + 4)}
|
||
${indent('))}', level + 3)}
|
||
${indent('</UiKit.Tabs>', level + 2)}
|
||
${indent(')', level + 1)}
|
||
${indent('})()}', level)}`
|
||
}
|
||
|
||
if (node.type === 'Radio.Group') {
|
||
const { node: groupNode, props: groupProps } = withoutCollection(node)
|
||
delete groupProps.checked
|
||
delete groupProps.defaultChecked
|
||
delete groupProps.readOnly
|
||
const groupPropsCode = propsToCode(groupNode, itemVariable, [], formScope)
|
||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
|
||
}
|
||
|
||
const tag =
|
||
node.type === 'checkbox'
|
||
? 'input'
|
||
: node.kind === 'ui'
|
||
? `UiKit.${node.type}`
|
||
: node.kind === 'custom'
|
||
? safeIdentifier(node.type)
|
||
: node.type
|
||
const props = { ...node.props }
|
||
if (node.type === 'checkbox') props.type = 'checkbox'
|
||
const normalizedNode = { ...node, props }
|
||
const propCode = propsToCode(normalizedNode, itemVariable, [], formScope)
|
||
const childrenText = String(node.props.children ?? '')
|
||
const childrenBinding = bindingExpression(node, 'children', itemVariable)
|
||
const hasChildren = node.children.length > 0 || childrenText.length > 0 || !!childrenBinding
|
||
const voidElement = ['input', 'img'].includes(tag)
|
||
|
||
if (voidElement) return indent(`<${tag}${propCode} />`, level)
|
||
if (!hasChildren) return indent(`<${tag}${propCode} />`, level)
|
||
|
||
const nested = node.children
|
||
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
|
||
.join('\n')
|
||
// Static text is emitted through the ref override so `setText` reaches it: a
|
||
// spread `children` prop loses to JSX children, which is what a label is.
|
||
const staticText = isRefControlled(node)
|
||
? `${refPropsExpression(node.ref as string)}.children ?? ${staticValueExpression(childrenText)}`
|
||
: staticValueExpression(childrenText)
|
||
const text = childrenBinding
|
||
? indent(`{${childrenBinding}}`, level + 1)
|
||
: childrenText
|
||
? indent(`{${staticText}}`, level + 1)
|
||
: ''
|
||
const body = [text, nested].filter(Boolean).join('\n')
|
||
return `${indent(`<${tag}${propCode}>`, level)}\n${body}\n${indent(`</${tag}>`, level)}`
|
||
}
|
||
|
||
/** Every script of the document, so ref usage can be detected before emitting. */
|
||
const collectDesignerScripts = (document: DesignerDocument) => {
|
||
const scripts: string[] = [document.lifecycle?.onMount || '']
|
||
const visit = (nodes: DesignerNode[]) =>
|
||
nodes.forEach((node) => {
|
||
Object.values(node.events || {}).forEach((script) => scripts.push(script || ''))
|
||
visit(node.children || [])
|
||
})
|
||
visit(document.nodes)
|
||
return scripts.join('\n')
|
||
}
|
||
|
||
interface RefTarget {
|
||
node: DesignerNode
|
||
/** Reads the node's current value, whatever holds it. */
|
||
getExpression: string
|
||
/** Statement that writes a new value back to that same holder. */
|
||
setStatement: string
|
||
extras: string[]
|
||
}
|
||
|
||
/**
|
||
* `refs.<name>` entry of a node: the value accessor is wired to whichever state
|
||
* actually owns the value — the enclosing SqlDataSource record, the node's own
|
||
* runtime state, or the ref override store for a component that has neither.
|
||
*/
|
||
const refTargetFor = (
|
||
node: DesignerNode,
|
||
formField: ReturnType<typeof getFormScopeField>,
|
||
runtimeState: RuntimeStateSpec | null,
|
||
): RefTarget => {
|
||
const ref = node.ref as string
|
||
const extras: string[] = []
|
||
if (isSqlDataSourceNode(node.type)) {
|
||
const names = sqlIdentifiers(node)
|
||
extras.push(
|
||
` getRecord: () => ${names.record}`,
|
||
` setRecord: (record) => ${names.setRecord}(record || {})`,
|
||
` getField: (path) => getByPath(${names.record}, path)`,
|
||
` setField: (path, value) => ${names.setField}(path, value)`,
|
||
// A patch is the readable way to write several columns at once; each write
|
||
// still goes through setField so onFieldChange fires for every column.
|
||
` setFields: (patch) => Object.entries(patch || {}).forEach(([path, value]) => ${names.setField}(path, value))`,
|
||
` clearFields: (...paths) => paths.flat().forEach((path) => ${names.setField}(path, null))`,
|
||
` getOriginal: () => ${names.original}`,
|
||
` getChanges: () => toSqlChanges(${names.original}, ${names.record}, ${names.key})`,
|
||
` hasChanges: () => Object.keys(toSqlChanges(${names.original}, ${names.record}, ${names.key})).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length > 0`,
|
||
` getKey: () => readSqlField(${names.record}, ${names.key})`,
|
||
` getKeyField: () => ${names.key}`,
|
||
` getRows: () => ${names.rows}`,
|
||
` getRowCount: () => ${names.rows}.length`,
|
||
` getIndex: () => ${names.index}`,
|
||
` getMode: () => ${names.mode}`,
|
||
` isNew: () => ${names.mode} === "new"`,
|
||
` isBusy: () => ${names.busy}`,
|
||
` getError: () => ${names.error}`,
|
||
` setError: (message) => ${names.setError}(message == null ? "" : String(message))`,
|
||
` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`,
|
||
` nextRow: () => ${names.next}()`,
|
||
` prevRow: () => ${names.previous}()`,
|
||
` firstRow: () => ${names.setIndex}(0)`,
|
||
` lastRow: () => ${names.setIndex}(Math.max(0, ${names.rows}.length - 1))`,
|
||
` reload: () => ${names.refresh}()`,
|
||
` newRecord: () => ${names.create}()`,
|
||
` save: () => ${names.save}()`,
|
||
` remove: () => ${names.remove}()`,
|
||
)
|
||
return {
|
||
node,
|
||
getExpression: names.record,
|
||
setStatement: `${names.setRecord}(value)`,
|
||
extras,
|
||
}
|
||
}
|
||
const valueProperty =
|
||
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
|
||
const base = formField
|
||
? {
|
||
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
|
||
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
|
||
}
|
||
: runtimeState
|
||
? {
|
||
getExpression: runtimeState.stateName,
|
||
setStatement: `${runtimeState.setterName}(value)`,
|
||
}
|
||
: {
|
||
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
|
||
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
|
||
}
|
||
|
||
// Select/AutoComplete/Menu ekranda yalnızca Label gösterir, ama seçenek nesnesi
|
||
// kaynak satırın bütün sütunlarını taşır. Bu erişimciler sayesinde bir script
|
||
// görünmeyen sütunları okuyup başka bir komponente ya da kayda yazabilir.
|
||
if (isDesignerOptionComponent(node.type)) {
|
||
const optionsExpression = optionCollectionExpression(node)
|
||
const selected = `findDesignerOption(${optionsExpression}, ${base.getExpression})`
|
||
const columns = getDesignerBindingColumns(node.bindings?.[getDesignerCollectionProperty(node.type)])
|
||
extras.push(
|
||
` getOptions: () => ${optionsExpression}`,
|
||
` getSelectedOption: () => ${selected}`,
|
||
` getColumn: (path) => readDesignerOptionColumn(${selected}, path)`,
|
||
` getLabel: () => readDesignerOptionColumn(${selected}, "label") ?? ""`,
|
||
// Tasarımda bildirilen ek sütunlar; bir script hangi sütunların garanti
|
||
// altında olduğunu buradan öğrenebilir.
|
||
` getColumnNames: () => ${JSON.stringify(columns)}`,
|
||
)
|
||
}
|
||
|
||
return { node, ...base, extras }
|
||
}
|
||
|
||
/**
|
||
* The `refs` object handed to every event script and to `onMount`. Overrides are
|
||
* kept in one state map keyed by ref name, so a script can change any prop of any
|
||
* other component without that component needing a hook of its own.
|
||
*/
|
||
const refRuntimeCode = (targets: RefTarget[]) => {
|
||
const entries = targets
|
||
.map((target) => {
|
||
const ref = target.node.ref as string
|
||
const key = JSON.stringify(ref)
|
||
const disabledProperty = JSON.stringify(getDisabledProperty(target.node.type))
|
||
return ` ${key}: {
|
||
name: ${key},
|
||
type: ${JSON.stringify(target.node.type)},
|
||
getValue: () => ${target.getExpression},
|
||
setValue: (value) => { ${target.setStatement} },
|
||
getProps: () => designerRefProps(${key}),
|
||
setProps: (patch) => patchDesignerRef(${key}, { props: patch || {} }),
|
||
setProp: (property, value) => patchDesignerRef(${key}, { props: { [property]: value } }),
|
||
isVisible: () => !designerRefHidden(${key}),
|
||
setVisible: (visible) => patchDesignerRef(${key}, { hidden: visible === false }),
|
||
show: () => patchDesignerRef(${key}, { hidden: false }),
|
||
hide: () => patchDesignerRef(${key}, { hidden: true }),
|
||
isEnabled: () => (designerRefProps(${key})[${disabledProperty}] ?? ${JSON.stringify(Boolean(target.node.props[getDisabledProperty(target.node.type)]))}) !== true,
|
||
setEnabled: (enabled) => patchDesignerRef(${key}, { props: { [${disabledProperty}]: enabled === false } }),
|
||
setReadOnly: (readOnly) => patchDesignerRef(${key}, { props: { readOnly: readOnly !== false } }),
|
||
setText: (text) => patchDesignerRef(${key}, { props: { children: text } }),
|
||
reset: () => patchDesignerRef(${key}, { props: null, hidden: false }),${
|
||
target.extras.length ? `\n${target.extras.join(',\n')},` : ''
|
||
}
|
||
}`
|
||
})
|
||
.join(',\n')
|
||
|
||
// Only emitted when an option component is actually addressable by ref.
|
||
const optionHelpers = targets.some((target) => isDesignerOptionComponent(target.node.type))
|
||
? ` // Seçili option nesnesi: bir Select değeri kimi zaman option'ın kendisi, kimi
|
||
// zaman kayda yazılan ham value olur; ikisi de aynı nesneye çözülür.
|
||
const findDesignerOption = (options, value) => {
|
||
const list = Array.isArray(options) ? options : []
|
||
if (Array.isArray(value)) return value.map((item) => findDesignerOption(list, item)).filter(Boolean)
|
||
if (value && typeof value === "object") return value
|
||
return list.find((option) => option?.value === value)
|
||
?? list.find((option) => String(option?.value ?? "") === String(value ?? ""))
|
||
?? null
|
||
}
|
||
const readDesignerOptionColumn = (option, path) => {
|
||
if (Array.isArray(option)) return option.map((item) => readDesignerOptionColumn(item, path))
|
||
return String(path || "").split(".").filter(Boolean).reduce((current, key) => current?.[key], option)
|
||
}
|
||
`
|
||
: ''
|
||
|
||
return `${optionHelpers} // Component references. Every node on the canvas is addressable by its ref,
|
||
// so an event script can read or change another component without prop drilling.
|
||
const [designerRefState, setDesignerRefState] = React.useState({})
|
||
const designerRefProps = (name) => designerRefState[name]?.props || {}
|
||
const designerRefHidden = (name) => Boolean(designerRefState[name]?.hidden)
|
||
const patchDesignerRef = (name, patch) => setDesignerRefState((current) => ({
|
||
...current,
|
||
[name]: {
|
||
...current[name],
|
||
...patch,
|
||
// \`props: null\` resets the overrides; anything else is merged on top.
|
||
props: patch.props === null ? {} : { ...(current[name]?.props || {}), ...(patch.props || {}) },
|
||
},
|
||
}))
|
||
const refs = {
|
||
${entries}
|
||
}`
|
||
}
|
||
|
||
/**
|
||
* `api` and `notify`, the two helpers an event script needs to do something that
|
||
* is not just moving values between components. `apiService`, `toast` and
|
||
* `Notification` are already in the compiled component's scope, so this is only
|
||
* about giving the scripts a small, obvious surface over them.
|
||
*/
|
||
const SCRIPT_API_CODE = ` const api = {
|
||
get: (url, params) => apiService.fetchData({ url, method: "GET", params }).then((response) => response.data),
|
||
post: (url, data, params) => apiService.fetchData({ url, method: "POST", data, params }).then((response) => response.data),
|
||
put: (url, data, params) => apiService.fetchData({ url, method: "PUT", data, params }).then((response) => response.data),
|
||
patch: (url, data, params) => apiService.fetchData({ url, method: "PATCH", data, params }).then((response) => response.data),
|
||
delete: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
|
||
remove: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
|
||
request: (config) => apiService.fetchData(config).then((response) => response.data),
|
||
errorMessage: (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı.",
|
||
}`
|
||
|
||
const SCRIPT_NOTIFY_CODE = ` const notify = (message, type = "info") => toast.push(
|
||
<Notification type={type} duration={2500}>{typeof message === "string" ? message : JSON.stringify(message)}</Notification>,
|
||
{ placement: "bottom-end" },
|
||
)`
|
||
|
||
export const generateDesignerCode = (name: string, document: DesignerDocument) => {
|
||
const componentName = safeIdentifier(name || 'VisualComponent')
|
||
const scripts = collectDesignerScripts(document)
|
||
// Only pages whose scripts address a ref pay for the ref runtime.
|
||
refRuntimeEnabled = /\brefs\b/.test(scripts)
|
||
const scriptHelpers = [
|
||
/\bapi\b/.test(scripts) ? SCRIPT_API_CODE : '',
|
||
/\bnotify\b/.test(scripts) ? SCRIPT_NOTIFY_CODE : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n\n')
|
||
const refTargets: RefTarget[] = []
|
||
const handlers: string[] = []
|
||
const runtimeStateHooks: string[] = []
|
||
let hasSelect = false
|
||
let hasDataTable = false
|
||
/** A Grid column shows a related text instead of its raw key. */
|
||
let hasColumnLookup = false
|
||
let hasDropdown = false
|
||
let hasSelectComponent = false
|
||
let hasDatePicker = false
|
||
let hasTabs = false
|
||
const sqlDataSourceNodes: DesignerNode[] = []
|
||
/** Platform views carrying their own filters; they get a params hook each. */
|
||
const platformFilterNodes: DesignerNode[] = []
|
||
/** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */
|
||
const sqlManagedSelectSourceIds = new Set<string>()
|
||
|
||
const visit = (nodes: DesignerNode[], formScope?: FormScope) => {
|
||
nodes.forEach((node) => {
|
||
if (isDesignerOptionComponent(node.type)) hasSelect = true
|
||
if (node.type === 'Select') hasSelectComponent = true
|
||
if (node.type === 'Dropdown') hasDropdown = true
|
||
if (node.type === 'Grid') hasDataTable = true
|
||
if (Object.keys(getUsableDesignerColumnLookups(node)).length) hasColumnLookup = true
|
||
if (node.type === 'Tabs') hasTabs = true
|
||
if (isDesignerDateComponent(node.type)) hasDatePicker = true
|
||
if (node.kind === 'platform' && getDesignerNodeFilters(node).length) {
|
||
platformFilterNodes.push(node)
|
||
}
|
||
if (isSqlDataSourceNode(node.type)) {
|
||
sqlDataSourceNodes.push(node)
|
||
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
|
||
if (selectId) sqlManagedSelectSourceIds.add(selectId)
|
||
}
|
||
const formField = getFormScopeField(node, formScope)
|
||
const runtimeState = getRuntimeStateSpec(node)
|
||
if (refRuntimeEnabled && node.ref) {
|
||
refTargets.push(refTargetFor(node, formField, runtimeState))
|
||
}
|
||
if (runtimeState) {
|
||
// Date pickers hold a Date in state but an ISO string in the document.
|
||
const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName)
|
||
? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)}, ${isDesignerDateOnlyComponent(node.type)})`
|
||
: JSON.stringify(runtimeState.initialValue)
|
||
runtimeStateHooks.push(
|
||
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
|
||
)
|
||
}
|
||
// A SqlDataSource event is not a React prop: it is called by the container's
|
||
// own runtime with a single payload object, and always awaited so a script
|
||
// can veto a command or finish an API call before the save continues.
|
||
if (isSqlDataSourceNode(node.type)) {
|
||
getSqlDataSourceScriptedEvents(node).forEach((event) => {
|
||
const script = node.events[event.name].trim()
|
||
handlers.push(
|
||
` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`,
|
||
)
|
||
})
|
||
visit(node.children, { sourceId: node.id, setterName: sqlIdentifiers(node).setField })
|
||
return
|
||
}
|
||
const eventNames = new Set(
|
||
Object.entries(node.events)
|
||
.filter(([, script]) => script.trim())
|
||
.map(([eventName]) => eventName),
|
||
)
|
||
if (runtimeState) eventNames.add(runtimeState.eventName)
|
||
if (formField) eventNames.add(formField.eventName)
|
||
eventNames.forEach((eventName) => {
|
||
const script = node.events[eventName]?.trim() || ''
|
||
// A form scoped field replaces the local state update: the edit belongs
|
||
// to the SqlDataSource record, which is what Save posts back.
|
||
const formUpdate =
|
||
formField?.eventName === eventName
|
||
? `${formField.setterName}(${JSON.stringify(formField.path)}, ${
|
||
node.type === 'Select'
|
||
? 'Array.isArray(valueOrEvent) ? valueOrEvent.map((option) => option?.value ?? option) : (valueOrEvent?.value ?? null)'
|
||
: formField.propertyName === 'checked'
|
||
? 'typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent)'
|
||
: isDesignerDateComponent(node.type)
|
||
? `fromDesignerDate(valueOrEvent ?? null, ${isDesignerDateOnlyComponent(node.type)})`
|
||
: node.type === 'AutoComplete'
|
||
? 'valueOrEvent ?? ""'
|
||
: 'valueOrEvent?.target?.value ?? valueOrEvent'
|
||
})`
|
||
: ''
|
||
const stateUpdate =
|
||
formUpdate ||
|
||
(runtimeState?.eventName === eventName
|
||
? node.type === 'Select'
|
||
? `${runtimeState.setterName}(valueOrEvent)`
|
||
: node.type === 'AutoComplete'
|
||
? `${runtimeState.setterName}(valueOrEvent ?? "")`
|
||
: node.type === 'Menu'
|
||
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
|
||
: node.type === 'Tabs'
|
||
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
|
||
: node.type === 'Dropdown'
|
||
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
|
||
: node.type === 'Pagination'
|
||
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
|
||
: node.type === 'Steps' || node.type === 'ImageViewer'
|
||
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
|
||
: node.type === 'Upload'
|
||
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
|
||
: runtimeState.propertyName === 'checked'
|
||
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
|
||
: node.type === 'Input' || node.kind === 'html'
|
||
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
|
||
: `${runtimeState.setterName}(valueOrEvent)`
|
||
: '')
|
||
const eventDeclaration =
|
||
node.type === 'Checkbox' && eventName === 'onChange'
|
||
? ' const event = { checked: Boolean(valueOrEvent), originalEvent, target: originalEvent?.target }'
|
||
: ' const event = valueOrEvent'
|
||
// A script that awaits an API call needs an async handler, otherwise the
|
||
// `await` is a syntax error the user only finds out about at compile time.
|
||
const asyncPrefix = usesAwait(script) ? 'async ' : ''
|
||
handlers.push(
|
||
` const handle_${safeIdentifier(node.id)}_${eventName} = ${asyncPrefix}(valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`,
|
||
)
|
||
})
|
||
visit(
|
||
node.children,
|
||
isSqlDataSourceNode(node.type)
|
||
? { sourceId: node.id, setterName: sqlIdentifiers(node).setField }
|
||
: formScope,
|
||
)
|
||
})
|
||
}
|
||
visit(document.nodes)
|
||
|
||
const dataSources = document.dataSources || []
|
||
// Ref → record state variable of every SqlDataSource on the page. A `record`
|
||
// filter addresses its master through this map, so a detail source can be
|
||
// filtered by the row the container currently shows.
|
||
const sqlRecordByRef = new Map<string, string>(
|
||
sqlDataSourceNodes
|
||
.filter((node) => node.ref)
|
||
.map((node) => [String(node.ref), sqlIdentifiers(node).record] as const),
|
||
)
|
||
const hasDataSourceFilters = dataSources.some(
|
||
(source) => getDesignerDataSourceFilters(source).length > 0,
|
||
)
|
||
const hasFilters = hasDataSourceFilters || platformFilterNodes.length > 0
|
||
const selectHelpers = hasSelect
|
||
? ` const toSelectOptions = (value, labelPath = "", valuePath = "") => {
|
||
if (!Array.isArray(value)) return []
|
||
return value.map((item, index) => {
|
||
if (item === null || typeof item !== "object" || Array.isArray(item)) return { label: String(item ?? ""), value: item ?? index }
|
||
const primitiveKeys = Object.keys(item).filter((key) => item[key] === null || ["string", "number", "boolean"].includes(typeof item[key]))
|
||
const labelKey = ["label", "children", "name", "title", "text", "description"].find((key) => item[key] !== undefined)
|
||
const valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined)
|
||
const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]]
|
||
const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? primitiveKeys[0]]
|
||
return { ...item, label: String(optionLabel ?? \`Seçenek \${index + 1}\`), value: optionValue ?? index }
|
||
})
|
||
}`
|
||
: ''
|
||
// A bound Select receives a raw column value, but react-select is controlled by
|
||
// the option object itself.
|
||
const selectValueHelpers = hasSelectComponent
|
||
? ` const toSelectValue = (options, value, isMulti) => {
|
||
if (isMulti) return options.filter((option) => Array.isArray(value) && value.includes(option.value))
|
||
return options.find((option) => option.value === value) ?? null
|
||
}`
|
||
: ''
|
||
// Dropdown paints its toggle from `title`; `activeKey` only marks the item
|
||
// inside the closed menu, so the label of the active option is resolved here.
|
||
const dropdownHelpers = hasDropdown
|
||
? ` const toDropdownTitle = (options, activeKey, fallbackTitle) => {
|
||
const key = activeKey === undefined || activeKey === null ? "" : String(activeKey)
|
||
const selected = key ? options.find((option) => String(option?.value ?? "") === key) : undefined
|
||
if (selected) return String(selected.label ?? selected.value ?? key)
|
||
return String(fallbackTitle ?? "") || ${JSON.stringify(DESIGNER_DROPDOWN_PLACEHOLDER)}
|
||
}`
|
||
: ''
|
||
// Falls back to the first tab while an endpoint driven tab list is still empty
|
||
// or when the stored value no longer exists.
|
||
const tabHelpers = hasTabs
|
||
? ` const toTabValue = (options, value) => {
|
||
const key = value === undefined || value === null ? "" : String(value)
|
||
if (key && options.some((option) => String(option?.value ?? "") === key)) return key
|
||
return options.length ? String(options[0]?.value ?? "") : ""
|
||
}`
|
||
: ''
|
||
// A lookup column matches its raw value against the option rows of another
|
||
// endpoint and prints the related text. The key is stringified on both sides
|
||
// because an id arrives as a number in one response and as a string in the
|
||
// other often enough that a strict compare would silently show nothing.
|
||
const lookupHelpers = hasColumnLookup
|
||
? ` const resolveLookupText = (lookup, value) => {
|
||
if (!lookup || value === null || value === undefined || value === "") return undefined
|
||
const rows = getByPath(lookup.rows, lookup.path)
|
||
if (!Array.isArray(rows)) return undefined
|
||
const key = String(value)
|
||
const row = rows.find((item) => item && typeof item === "object" && String(getByPath(item, lookup.value) ?? "") === key)
|
||
if (!row) return undefined
|
||
const text = getByPath(row, lookup.text)
|
||
return text === null || text === undefined || text === "" ? undefined : text
|
||
}`
|
||
: ''
|
||
// Grid cells print raw endpoint columns, so dates and decimals are localised
|
||
// here — the culture is published on `<html lang>` because a runtime compiled
|
||
// component has neither hooks nor imports to reach the store.
|
||
const localeHelpers = hasDataTable
|
||
? ` const localeCulture = () => (typeof document !== "undefined" && document.documentElement.lang) || "en"
|
||
const localeDateOptions = { year: "numeric", month: "2-digit", day: "2-digit" }
|
||
const localeTimeOptions = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }
|
||
const localeIsoPattern = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/
|
||
const formatLocaleValue = (value) => {
|
||
if (value === null || value === undefined) return ""
|
||
const culture = localeCulture()
|
||
if (typeof value === "number") {
|
||
if (!Number.isFinite(value)) return String(value)
|
||
return new Intl.NumberFormat(culture, { useGrouping: false, maximumFractionDigits: 20 }).format(value)
|
||
}
|
||
if (value instanceof Date) {
|
||
return Number.isNaN(value.getTime()) ? "" : new Intl.DateTimeFormat(culture, localeTimeOptions).format(value)
|
||
}
|
||
if (typeof value !== "string") return String(value)
|
||
const parts = localeIsoPattern.exec(value.trim())
|
||
if (!parts) return value
|
||
const parsed = new Date(value)
|
||
if (Number.isNaN(parsed.getTime())) return value
|
||
if (parts[4] === undefined) {
|
||
const day = new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
|
||
return new Intl.DateTimeFormat(culture, localeDateOptions).format(day)
|
||
}
|
||
return new Intl.DateTimeFormat(culture, localeTimeOptions).format(parsed)
|
||
}`
|
||
: ''
|
||
// Pickers check `instanceof Date`, but the document only stores ISO strings.
|
||
// Both directions stay on the local wall clock: a date column carries no
|
||
// timezone, so converting through UTC would move 29/07 to 28/07.
|
||
const dateHelpers = hasDatePicker
|
||
? ` const sqlPad = (value) => String(value).padStart(2, "0")
|
||
const isoDateTime = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2}))?)?/
|
||
const toDesignerDate = (value, dateOnly) => {
|
||
if (Array.isArray(value)) return value.map((entry) => toDesignerDate(entry, dateOnly))
|
||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
||
if (typeof value !== "string" || !value.trim()) return null
|
||
const parts = isoDateTime.exec(value.trim())
|
||
const midnight = parts && dateOnly && !Number(parts[4]) && !Number(parts[5]) && !Number(parts[6])
|
||
if (parts && (parts[4] === undefined || midnight)) {
|
||
return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
|
||
}
|
||
const parsed = new Date(value)
|
||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||
}
|
||
const fromDesignerDate = (value, dateOnly) => {
|
||
if (Array.isArray(value)) return value.map((entry) => fromDesignerDate(entry, dateOnly))
|
||
if (value instanceof Date) {
|
||
if (Number.isNaN(value.getTime())) return null
|
||
const day = value.getFullYear() + "-" + sqlPad(value.getMonth() + 1) + "-" + sqlPad(value.getDate())
|
||
if (dateOnly) return day
|
||
return day + "T" + sqlPad(value.getHours()) + ":" + sqlPad(value.getMinutes()) + ":" + sqlPad(value.getSeconds())
|
||
}
|
||
return typeof value === "string" && value.trim() ? value : null
|
||
}`
|
||
: ''
|
||
// Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table,
|
||
// Card) would cut it off. A body portal keeps the menu fully visible.
|
||
const selectMenuHelpers = hasSelectComponent
|
||
? ` const selectMenuProps = {
|
||
menuPosition: "fixed",
|
||
menuPortalTarget: typeof window === "undefined" ? null : window.document.body,
|
||
styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) },
|
||
}`
|
||
: ''
|
||
// Shared SqlDataSource runtime: row extraction, record writes, URL parameter
|
||
// binding and the actual Insert/Update/Delete calls.
|
||
const sqlHelpers = sqlDataSourceNodes.length
|
||
? ` const readSqlField = (record, field) => {
|
||
if (!record || typeof record !== "object" || Array.isArray(record)) return undefined
|
||
if (field in record) return record[field]
|
||
const matched = Object.keys(record).find((key) => key.toLowerCase() === String(field).toLowerCase())
|
||
return matched === undefined ? undefined : record[matched]
|
||
}
|
||
const toSqlRows = (value, collectionPath) => {
|
||
const source = collectionPath ? getByPath(value, collectionPath) : value
|
||
const rows = Array.isArray(source)
|
||
? source
|
||
: source && typeof source === "object"
|
||
? (Array.isArray(source.items) ? source.items : [source])
|
||
: []
|
||
return rows.filter((item) => item && typeof item === "object" && !Array.isArray(item))
|
||
}
|
||
const setSqlField = (record, path, value) => {
|
||
const keys = String(path || "").split(".").filter(Boolean)
|
||
if (!keys.length) return record
|
||
const next = { ...(record || {}) }
|
||
let target = next
|
||
for (let index = 0; index < keys.length - 1; index += 1) {
|
||
const child = target[keys[index]]
|
||
target[keys[index]] = child && typeof child === "object" && !Array.isArray(child) ? { ...child } : {}
|
||
target = target[keys[index]]
|
||
}
|
||
target[keys[keys.length - 1]] = value
|
||
return next
|
||
}
|
||
const bindSqlUrl = (url, record, keyField) => {
|
||
let keyBound = false
|
||
const boundUrl = String(url).replace(/\\{([^}]+)\\}|(?<=\\/):([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, colon) => {
|
||
const name = (braced || colon || "").trim()
|
||
if (!name) return match
|
||
const value = readSqlField(record, name) ?? readSqlField(record, keyField)
|
||
if (value === undefined || value === null || value === "") return match
|
||
if (name.toLowerCase() === String(keyField).toLowerCase()) keyBound = true
|
||
return encodeURIComponent(String(value))
|
||
})
|
||
return { url: boundUrl, keyBound }
|
||
}
|
||
const appendQueryParam = (url, name, value) =>
|
||
url + (url.includes("?") ? "&" : "?") + encodeURIComponent(name) + "=" + encodeURIComponent(value)
|
||
const hasSqlUrlParams = (url) => /\\{[^}]+\\}|(?<=\\/):[A-Za-z_][A-Za-z0-9_]*/.test(String(url))
|
||
const readUrlKey = (source, name) => {
|
||
if (typeof window === "undefined") return ""
|
||
if (source === "query") return new URLSearchParams(window.location.search).get(name) ?? ""
|
||
const segments = window.location.pathname.split("/").filter(Boolean)
|
||
return segments.length ? decodeURIComponent(segments[segments.length - 1]) : ""
|
||
}
|
||
const filterSqlRowsByKey = (rows, keyField, keyValue) => {
|
||
if (!keyValue) return rows
|
||
const matched = rows.filter((row) => String(readSqlField(row, keyField) ?? "") === String(keyValue))
|
||
// No match means the endpoint already filtered server side, or the key column
|
||
// is named differently — keeping the rows beats showing an empty form.
|
||
return matched.length ? matched : rows
|
||
}
|
||
// Update payload: only the columns the user actually edited, plus the key.
|
||
const toSqlChanges = (original, current, keyField) => {
|
||
const changes = {}
|
||
const isSame = (left, right) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
||
Object.keys(current || {}).forEach((column) => {
|
||
if (!isSame(original ? original[column] : undefined, current[column])) changes[column] = current[column]
|
||
})
|
||
const keyColumn = Object.keys(current || {}).find((column) => column.toLowerCase() === String(keyField).toLowerCase())
|
||
if (keyColumn !== undefined && changes[keyColumn] === undefined) changes[keyColumn] = current[keyColumn]
|
||
return changes
|
||
}
|
||
const callSqlEndpoint = (target, record, keyField, payload) => {
|
||
// The URL is always bound from the full record: a placeholder may reference a
|
||
// column that the update payload does not carry.
|
||
const bound = bindSqlUrl(target.url, record, keyField)
|
||
const request = { url: bound.url, method: target.method }
|
||
if (target.method === "POST" || target.method === "PUT") request.data = payload === undefined ? record : payload
|
||
const keyValue = readSqlField(record, keyField)
|
||
const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ""
|
||
// The key still has to reach the endpoint when the URL carries no placeholder.
|
||
if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue }
|
||
return apiService.fetchData(request)
|
||
}
|
||
// A new record starts at the top of the form: the first enabled editor takes the
|
||
// cursor, so a Yeni click is immediately followed by typing.
|
||
const focusFirstSqlField = (host) => {
|
||
if (typeof window === "undefined") return
|
||
window.requestAnimationFrame(() => {
|
||
const field = host.current?.querySelector("input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])")
|
||
if (!field) return
|
||
field.focus()
|
||
if (typeof field.select === "function") field.select()
|
||
})
|
||
}
|
||
// Defaults declared on the bound children; only a new record starts from them.
|
||
const buildSqlDefaults = (fields) => fields.reduce((record, field) => setSqlField(record, field.path, resolveSqlDefault(field)), {})
|
||
const resolveSqlDefault = (field) => {
|
||
if (field.token !== "today" && field.token !== "now") return field.value
|
||
const now = new Date()
|
||
const pad = (value) => String(value).padStart(2, "0")
|
||
const day = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate())
|
||
if (field.token === "today" || field.dateOnly) return day
|
||
return day + "T" + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds())
|
||
}
|
||
const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."`
|
||
: ''
|
||
const filterHelpers = hasFilters
|
||
? ` // Value of a filter that reads the page URL: a query string parameter, or a
|
||
// path segment — the one after a segment named like the parameter, otherwise
|
||
// the last one, which is the /orders/{id} detail page shape.
|
||
const readDesignerUrlValue = (kind, name) => {
|
||
if (typeof window === "undefined") return ""
|
||
if (kind === "query") return new URLSearchParams(window.location.search).get(name) ?? ""
|
||
const segments = window.location.pathname.split("/").filter(Boolean)
|
||
if (!segments.length) return ""
|
||
const index = name ? segments.findIndex((segment) => segment.toLowerCase() === String(name).toLowerCase()) : -1
|
||
if (index >= 0 && index < segments.length - 1) return decodeURIComponent(segments[index + 1])
|
||
return decodeURIComponent(segments[segments.length - 1])
|
||
}
|
||
// Filters are appended as query parameters: \`Column\` for an equality and
|
||
// \`Column.operator\` for anything else, which is what the endpoint parses.
|
||
// Returns "" when a required filter has no value yet — the caller must skip the
|
||
// request instead of loading the unfiltered collection.
|
||
const buildFilteredUrl = (url, filters) => {
|
||
const parts = []
|
||
for (const filter of filters || []) {
|
||
const value = filter.value === undefined || filter.value === null ? "" : String(filter.value)
|
||
if (!value) {
|
||
if (filter.required) return ""
|
||
continue
|
||
}
|
||
parts.push(encodeURIComponent(filter.param) + "=" + encodeURIComponent(value))
|
||
}
|
||
if (!parts.length) return url
|
||
return url + (url.includes("?") ? "&" : "?") + parts.join("&")
|
||
}`
|
||
: ''
|
||
// A platform view is filtered through the same `filter` expression a list form
|
||
// page carries in its address, so an embedded view needs no change to the list
|
||
// form itself. Returns null when a required filter has no value yet.
|
||
const platformFilterHelpers = platformFilterNodes.length
|
||
? ` const buildPlatformFilterParams = (filters) => {
|
||
const expressions = []
|
||
for (const filter of filters || []) {
|
||
if (filter.kind === "isnull" || filter.kind === "notnull") {
|
||
expressions.push([filter.field, filter.operator, null])
|
||
continue
|
||
}
|
||
const value = filter.value === undefined || filter.value === null ? "" : String(filter.value)
|
||
if (!value) {
|
||
if (filter.required) return null
|
||
continue
|
||
}
|
||
if (filter.kind === "in") {
|
||
const entries = value.split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => [filter.field, "=", entry])
|
||
if (!entries.length) continue
|
||
expressions.push(entries.reduce((result, entry) => (result ? [result, "or", entry] : entry), null))
|
||
continue
|
||
}
|
||
expressions.push([filter.field, filter.operator, value])
|
||
}
|
||
const params = new URLSearchParams()
|
||
const merged = expressions.reduce((result, expression) => (result ? [result, "and", expression] : expression), null)
|
||
if (merged) params.set("filter", JSON.stringify(merged))
|
||
return params
|
||
}`
|
||
: ''
|
||
// Declared after the SqlDataSource hooks: a filter may read the record of the
|
||
// container the view sits next to.
|
||
const platformFilterHooks = platformFilterNodes
|
||
.map((node) => {
|
||
const identifier = safeIdentifier(node.id)
|
||
return ` const platformFilters_${identifier} = ${platformFilterEntriesExpression(node, sqlRecordByRef)}
|
||
const platformKey_${identifier} = JSON.stringify(platformFilters_${identifier})
|
||
// The views keep the params object in their own state and write back to it, so
|
||
// it must stay the same object until a filter value actually changes.
|
||
const platformParams_${identifier} = React.useMemo(() => buildPlatformFilterParams(platformFilters_${identifier}), [platformKey_${identifier}])`
|
||
})
|
||
.join('\n\n')
|
||
const sqlHooks = sqlDataSourceNodes
|
||
.map((node) => sqlDataSourceHooks(node, dataSources, sqlRecordByRef))
|
||
.join('\n\n')
|
||
const dataHelpers =
|
||
dataSources.length || hasDataTable || sqlDataSourceNodes.length
|
||
? ` const getByPath = (value, path) => {
|
||
if (!path) return value
|
||
const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target)
|
||
const directValue = readPath(value, path)
|
||
if (directValue !== undefined) return directValue
|
||
if (path.startsWith('result.')) return readPath(value, path.slice('result.'.length))
|
||
return value?.result === undefined ? undefined : readPath(value.result, path)
|
||
}`
|
||
: ''
|
||
// The state of every source is declared before the SqlDataSource hooks, which
|
||
// write through the setters; the fetch effects come after them, because a
|
||
// filtered source may read the record a container owns.
|
||
const dataStateHooks = dataSources
|
||
.map((source) => {
|
||
const identifier = safeIdentifier(source.id)
|
||
// POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command;
|
||
// they hold no readable state and must never be written to.
|
||
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) {
|
||
return ` const [data_${identifier}] = React.useState(null)`
|
||
}
|
||
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)`
|
||
})
|
||
.join('\n')
|
||
const dataFetchHooks = dataSources
|
||
.map((source) => {
|
||
const identifier = safeIdentifier(source.id)
|
||
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) return ''
|
||
// A GetById style URL is not fetched on mount — requesting a literal `{id}`
|
||
// is a guaranteed 400 — and neither is a source the owning SqlDataSource
|
||
// loads itself, through the setter declared above.
|
||
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
|
||
return ''
|
||
}
|
||
const entries = filterEntriesExpression(source, sqlRecordByRef)
|
||
// Without filters the source keeps its previous mount-only fetch, byte for
|
||
// byte; with them the effect re-runs whenever a filter value changes.
|
||
if (!entries) {
|
||
return ` React.useEffect(() => {
|
||
let active = true
|
||
apiService.fetchData({ url: ${JSON.stringify(source.url)}, method: 'GET' }).then((response) => {
|
||
if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')}))
|
||
}).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error))
|
||
return () => { active = false }
|
||
}, [])`
|
||
}
|
||
return ` const filters_${identifier} = ${entries}
|
||
const filterKey_${identifier} = JSON.stringify(filters_${identifier})
|
||
React.useEffect(() => {
|
||
const url = buildFilteredUrl(${JSON.stringify(source.url)}, filters_${identifier})
|
||
// An unmet required filter leaves the component empty on purpose.
|
||
if (!url) {
|
||
setData_${identifier}(null)
|
||
return
|
||
}
|
||
let active = true
|
||
apiService.fetchData({ url, method: 'GET' }).then((response) => {
|
||
if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')}))
|
||
}).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error))
|
||
return () => { active = false }
|
||
}, [filterKey_${identifier}])`
|
||
})
|
||
.filter(Boolean)
|
||
.join('\n\n')
|
||
|
||
// Declared after the data/SQL/runtime state it reads, and before the handlers
|
||
// and the mount effect that call into it.
|
||
const refRuntime = refTargets.length ? refRuntimeCode(refTargets) : ''
|
||
// An effect callback must not return a promise, so an awaiting onMount is run
|
||
// through an async IIFE instead of being made async itself.
|
||
const mount = document.lifecycle.onMount.trim()
|
||
? usesAwait(document.lifecycle.onMount)
|
||
? ` React.useEffect(() => {\n void (async () => {\n${indent(document.lifecycle.onMount, 3)}\n })()\n }, [])`
|
||
: ` React.useEffect(() => {\n${indent(document.lifecycle.onMount, 2)}\n }, [])`
|
||
: ''
|
||
const body = document.nodes.map((node) => nodeToCode(node, 3)).join('\n')
|
||
|
||
const designerBackup = encodeURIComponent(JSON.stringify(document))
|
||
|
||
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, lookupHelpers, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, filterHelpers, platformFilterHelpers, dataStateHooks, sqlHooks, dataFetchHooks, platformFilterHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
|
||
}
|