sozsoft-platform/ui/src/components/visualDesigner/VisualCanvas.tsx

1696 lines
64 KiB
TypeScript
Raw Normal View History

2026-08-05 20:51:43 +00:00
import React from 'react'
import * as UiKit from '@/components/ui'
import PlatformViewHost, {
type PlatformViewName,
} from '@/components/componentEditor/PlatformViewHost'
2026-08-06 13:17:59 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatLocaleValue } from '@/utils/localeFormat'
2026-08-05 20:51:43 +00:00
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import {
fromDesignerDate,
getDesignerCollectionProperty,
getDesignerTabSlot,
2026-08-05 20:51:43 +00:00
getDesignerValueByPath,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
isSqlDataSourceNode,
2026-08-05 20:51:43 +00:00
normalizeDesignerKeyList,
readSqlDataSourceField,
resolveDesignerDropdownTitle,
resolveDesignerTabValue,
resolveSqlDataSourceRows,
toDesignerDate,
2026-08-05 20:51:43 +00:00
type DesignerBinding,
type DesignerNode,
type SqlDataSourceMode,
2026-08-05 20:51:43 +00:00
} from './types'
/**
* Editing scope opened by a SqlDataSource: a descendant whose `value`/`checked`
* is bound to `sourceId` writes back into the record instead of into its own
* static prop, which is what makes the container behave like an ASP.NET FormView.
*/
export interface DesignerFormScope {
sourceId: string
onFieldChange: (path: string, value: unknown) => void
}
2026-08-05 20:51:43 +00:00
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
interface DesignerDragPayload {
source?: 'library' | 'canvas'
name?: string
nodeId?: string
}
/** Drag payload of the designer; anything else dropped on the canvas is ignored. */
const readDesignerDragPayload = (
event: React.DragEvent<HTMLElement>,
): DesignerDragPayload | null => {
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
if (!raw) return null
try {
const payload = JSON.parse(raw) as DesignerDragPayload
return payload?.source === 'library' || payload?.source === 'canvas' ? payload : null
} catch {
return null
}
}
/** `dropEffect` must match the source's `effectAllowed` or the drop never fires. */
const acceptDesignerDrag = (event: React.DragEvent<HTMLElement>) => {
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}
const getTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
2026-08-06 13:17:59 +00:00
if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value
if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate))
// Only plain objects are walked: `Object.entries` on a Date yields nothing and
// would silently turn it into `{}`.
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
2026-08-06 13:17:59 +00:00
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
2026-08-06 13:17:59 +00:00
)
}
return value
}
2026-08-05 20:51:43 +00:00
interface VisualCanvasProps {
nodes: DesignerNode[]
selectedId: string | null
interactive?: boolean
onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
/** Adds a toolbox component next to an existing node instead of inside it. */
onDropComponentBeside?: (
definitionName: string,
targetId: string,
placement: 'before' | 'after',
) => void
/** Moves an existing node into a container/table cell, or to the root. */
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
2026-08-05 20:51:43 +00:00
onMove?: (id: string, direction: -1 | 1) => void
2026-08-06 13:17:59 +00:00
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
2026-08-05 20:51:43 +00:00
onDuplicate?: (id: string) => void
onDelete?: (id: string) => void
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
2026-08-05 20:51:43 +00:00
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
dataValues?: Record<string, unknown>
}
class PreviewBoundary extends React.Component<
{ name: string; resetKey: string; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidUpdate(previousProps: Readonly<{ resetKey: string }>) {
if (this.state.failed && previousProps.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
render() {
if (this.state.failed) {
return (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
{this.props.name} önizlemesi için ek veri veya alt bileşen gerekiyor.
</div>
)
}
return this.props.children
}
}
const resolveUiComponent = (name: string): React.ElementType | null => {
const parts = name.split('.')
let component: unknown = (UiKit as Record<string, unknown>)[parts[0]]
for (const part of parts.slice(1)) {
if ((typeof component !== 'object' && typeof component !== 'function') || !component) {
return null
}
component = (component as Record<string, unknown>)[part]
}
return component ? (component as React.ElementType) : null
}
const executeEvent = (script: string, event: unknown, node: DesignerNode) => {
if (!script.trim()) return
try {
const run = new Function('event', 'component', 'props', script)
run(event, node, node.props)
} catch (error) {
console.error(`Designer event error (${node.type}):`, error)
}
}
const getBindingValue = (
binding: DesignerBinding,
dataValues: Record<string, unknown>,
currentItem?: unknown,
) => {
const path = binding.path.trim()
if (currentItem !== undefined && (path === '$item' || path.startsWith('$item.'))) {
return getDesignerValueByPath(currentItem, path === '$item' ? '' : path.slice(6))
}
return getDesignerValueByPath(dataValues[binding.sourceId], path)
}
const toSelectOptions = (
value: unknown,
labelPath = '',
valuePath = '',
): Array<Record<string, unknown>> => {
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 record = item as Record<string, unknown>
const primitiveKeys = Object.keys(record).filter(
(key) => record[key] === null || ['string', 'number', 'boolean'].includes(typeof record[key]),
)
const labelKey = ['label', 'children', 'name', 'title', 'text', 'description'].find(
(key) => record[key] !== undefined,
)
const valueKey = ['value', 'eventKey', 'id', 'code', 'key'].find(
2026-08-05 20:51:43 +00:00
(key) => record[key] !== undefined,
)
const labelValue = labelPath
? getDesignerValueByPath(record, labelPath)
: labelKey
? record[labelKey]
: record[primitiveKeys[0]]
const optionValue = valuePath
? getDesignerValueByPath(record, valuePath)
: valueKey
? record[valueKey]
: record[primitiveKeys[1] || primitiveKeys[0]]
return {
...record,
label: String(labelValue ?? `Seçenek ${index + 1}`),
value: optionValue ?? index,
}
})
}
/**
* Select renders its menu inline, so any ancestor with `overflow: hidden/auto`
* (the canvas page, a Grid/Table scroll wrapper) clips it the menu of a Select
* in the last row would be cut off. Rendering it in a body portal keeps it
* visible; explicit props still win.
*/
const getSelectMenuProps = (props: Record<string, unknown>) => {
const menuProps: Record<string, unknown> = {}
if (props.menuPosition === undefined) menuProps.menuPosition = 'fixed'
if (props.menuPortalTarget === undefined && typeof window !== 'undefined') {
menuProps.menuPortalTarget = window.document.body
}
if (props.styles === undefined) {
menuProps.styles = {
menuPortal: (base: Record<string, unknown>) => ({ ...base, zIndex: 60 }),
}
}
return menuProps
}
/** Resolved tab list of a Tabs node, from static items or an endpoint binding. */
const getTabOptions = (props: Record<string, unknown>) =>
Array.isArray(props.items) ? (props.items as Array<Record<string, unknown>>) : []
/** Prop of a node that a SqlDataSource scope is allowed to write back to. */
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
formScope
? (['value', 'checked'] as const).find(
(propertyName) => node.bindings?.[propertyName]?.sourceId === formScope.sourceId,
)
: undefined
2026-08-05 20:51:43 +00:00
const getPreviewProps = (
node: DesignerNode,
dataValues: Record<string, unknown>,
currentItem?: unknown,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
2026-08-06 13:17:59 +00:00
translate: (key: string) => string = (key) => key,
formScope?: DesignerFormScope,
2026-08-05 20:51:43 +00:00
) => {
const props: Record<string, unknown> = {}
Object.entries(node.props).forEach(([key, value]) => {
2026-08-06 13:17:59 +00:00
if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
2026-08-05 20:51:43 +00:00
if (key.startsWith('on') && typeof value === 'string') return
2026-08-06 13:17:59 +00:00
props[key] = resolveStaticLanguageKeys(value, translate)
2026-08-05 20:51:43 +00:00
})
Object.entries(node.events).forEach(([name, script]) => {
if (!script.trim()) return
props[name] = (...args: unknown[]) => {
const event =
node.type === 'Checkbox' && name === 'onChange'
? {
checked: Boolean(args[0]),
originalEvent: args[1],
target:
args[1] && typeof args[1] === 'object' && 'target' in args[1]
? (args[1] as { target: unknown }).target
: undefined,
}
: args[0]
executeEvent(script, event, node)
}
})
Object.entries(node.bindings || {}).forEach(([propertyName, binding]) => {
if (propertyName !== 'children' && binding.sourceId) {
props[propertyName] = getBindingValue(binding, dataValues, currentItem)
}
})
// Stored as ISO strings; pickers only accept real Date instances.
if (isDesignerDateComponent(node.type)) {
Object.keys(props).forEach((propertyName) => {
if (isDesignerDateProperty(node.type, propertyName)) {
props[propertyName] = toDesignerDate(props[propertyName], node.type)
}
})
}
if (isDesignerOptionComponent(node.type)) {
const collectionProperty = getDesignerCollectionProperty(node.type)
2026-08-05 20:51:43 +00:00
if (node.type === 'Select') {
const legacyAliases: Record<string, string> = {
clearable: 'isClearable',
disabled: 'isDisabled',
multiple: 'isMulti',
searchable: 'isSearchable',
}
Object.entries(legacyAliases).forEach(([legacyName, runtimeName]) => {
if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
props[runtimeName] = props[legacyName]
}
delete props[legacyName]
})
Object.assign(props, getSelectMenuProps(props))
2026-08-05 20:51:43 +00:00
}
const optionsBinding = node.bindings?.[collectionProperty]
props[collectionProperty] = toSelectOptions(
props[collectionProperty],
optionsBinding?.labelPath,
optionsBinding?.valuePath,
)
2026-08-06 13:17:59 +00:00
if (node.type === 'Pagination') {
const pages = props.items as Array<Record<string, unknown>>
delete props.items
if (pages.length) props.total = pages.length
}
2026-08-05 20:51:43 +00:00
if (node.type === 'Select' && 'value' in props) {
const options = props.options as Array<Record<string, unknown>>
const selectedValue = props.value
props.value = props.isMulti
? options.filter(
(option) => Array.isArray(selectedValue) && selectedValue.includes(option.value),
)
: options.find((option) => option.value === selectedValue) || null
2026-08-05 20:51:43 +00:00
}
}
const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => {
const storedHandler =
typeof props[eventName] === 'function'
? (props[eventName] as (...args: unknown[]) => void)
: undefined
props[eventName] = (...args: unknown[]) => {
update(...args)
storedHandler?.(...args)
}
2026-08-05 20:51:43 +00:00
}
const updateProp = (propertyName: string, value: unknown) =>
onNodePropChange?.(node.id, propertyName, value)
const eventValue = (value: unknown) =>
value && typeof value === 'object' && 'target' in value
? (value as { target?: { value?: unknown } }).target?.value
: value
const selectedOptionValue = (selected: unknown) =>
Array.isArray(selected)
? selected.map((option) =>
option && typeof option === 'object' && 'value' in option
? (option as { value: unknown }).value
: option,
)
: selected && typeof selected === 'object' && 'value' in selected
? (selected as { value: unknown }).value
: null
const checkedValue = (value: unknown, originalEvent: unknown) =>
originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent
? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked)
: value && typeof value === 'object' && 'target' in value
? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
: Boolean(value)
// Inside a SqlDataSource the edited value belongs to the record, not to the
// node's static prop — otherwise typing into a bound Input would be discarded
// on the next render because the binding always wins.
const formScopeProperty = getFormScopeProperty(node, formScope)
if (formScope && formScopeProperty) {
const bindingPath = node.bindings[formScopeProperty].path
// An empty record (New mode) yields `undefined`, which React reads as
// "uncontrolled" and leaves the field stuck on its previous DOM value.
if (props[formScopeProperty] === undefined && !isDesignerDateComponent(node.type)) {
props[formScopeProperty] = formScopeProperty === 'checked' ? false : ''
}
const writeField = (value: unknown) => formScope.onFieldChange(bindingPath, value)
if (node.type === 'Select') {
chainHandler('onChange', (selected) => writeField(selectedOptionValue(selected)))
} else if (formScopeProperty === 'checked') {
chainHandler('onChange', (value, originalEvent) =>
writeField(checkedValue(value, originalEvent)),
)
} else if (isDesignerDateComponent(node.type)) {
chainHandler('onChange', (value) => writeField(fromDesignerDate(value, node.type)))
} else if (node.type === 'AutoComplete') {
chainHandler('onInputChange', (value) => writeField(value ?? ''))
} else {
chainHandler('onChange', (value) => writeField(eventValue(value)))
}
} else if (node.type === 'Select') {
chainHandler('onChange', (selected) => updateProp('value', selectedOptionValue(selected)))
} else if (node.type === 'AutoComplete') {
chainHandler('onInputChange', (value) => updateProp('value', value ?? ''))
} else if (node.type === 'Menu') {
chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')]))
} else if (node.type === 'Dropdown') {
chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? '')))
} else if (node.type === 'Tabs') {
chainHandler('onChange', (value) => updateProp('value', String(value ?? '')))
} else if (node.type === 'Radio.Group') {
chainHandler('onChange', (value) => updateProp('value', value))
} else if (node.type === 'Pagination') {
chainHandler('onChange', (value) => updateProp('currentPage', Number(value) || 1))
2026-08-06 13:17:59 +00:00
} else if (node.type === 'Steps') {
chainHandler('onChange', (value) => updateProp('current', Number(value) || 0))
} else if (node.type === 'ImageViewer') {
chainHandler('onIndexChange', (value) => updateProp('activeIndex', Number(value) || 0))
} else if (node.type === 'Upload') {
chainHandler('onChange', (_files, fileList) =>
updateProp('fileList', Array.isArray(fileList) ? fileList : []),
)
} else if (node.type === 'MenuItem') {
chainHandler('onSelect', () => updateProp('isActive', true))
} else if (isDesignerDateComponent(node.type)) {
// A Date is not JSON serialisable, so the picked value is kept as an ISO
// string — otherwise it is lost on the next render/save.
chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value, node.type)))
} else if ('checked' in props) {
chainHandler('onChange', (value, originalEvent) =>
updateProp('checked', checkedValue(value, originalEvent)),
)
} else if ('value' in props) {
chainHandler('onChange', (value) => updateProp('value', eventValue(value)))
2026-08-05 20:51:43 +00:00
}
props.key = node.id
2026-08-05 20:51:43 +00:00
return props
}
const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => (
<div
className="flex min-h-40 items-center justify-center rounded-lg border border-dashed border-sky-300 bg-gradient-to-br from-sky-50 to-indigo-50 p-6 text-center dark:border-sky-800 dark:from-slate-900 dark:to-sky-950"
style={{ minHeight: String(node.props.height || '320px') }}
>
<div>
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600 font-bold text-white">
S
</div>
<div className="font-semibold text-slate-800 dark:text-slate-100">
{node.type.replace(/View$/, '')}
</div>
<div className="mt-1 text-xs text-slate-500">
{String(node.props.listFormCode || 'Property panelinden List Form Code seçin')}
</div>
<div className="mt-3 text-[11px] uppercase tracking-wider text-sky-700 dark:text-sky-300">
Platform görünümü
</div>
</div>
</div>
)
const PLATFORM_VIEW_NAMES: Record<string, PlatformViewName> = {
ListView: 'List',
DataGridView: 'Grid',
TreeView: 'Tree',
GanttView: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
PivotView: 'Pivot',
ChartView: 'Chart',
}
const GridColumnHeaders = ({ columns }: { columns: string[] }) => (
<div className="min-w-0 overflow-auto rounded-md border border-slate-200 bg-slate-100 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-800">
{columns.length ? (
<div className="flex min-w-max divide-x divide-slate-200 dark:divide-slate-700">
{columns.map((column) => (
<div
key={column}
className="min-w-32 px-3 py-2 text-xs font-semibold text-slate-600 dark:text-slate-200"
>
{column}
</div>
))}
</div>
) : (
<div className="px-3 py-2 text-center text-xs text-slate-400">
Data panelinden gösterilecek sütunları seçin.
</div>
)}
</div>
)
// Dates and decimals follow the selected language, like the DevExtreme grids do.
2026-08-05 20:51:43 +00:00
const getGridCellText = (value: unknown) =>
typeof value === 'object' && value !== null && !(value instanceof Date)
? JSON.stringify(value)
: formatLocaleValue(value) || '—'
2026-08-05 20:51:43 +00:00
const GridDataTablePreview = ({
borderlessRow = false,
compact = false,
hoverable = true,
items,
overflow = true,
selectedColumns,
}: {
borderlessRow?: boolean
compact?: boolean
hoverable?: boolean
items: unknown[]
overflow?: boolean
selectedColumns?: string[]
}) => {
const firstObject = items.find(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
const columns = selectedColumns ?? (firstObject ? Object.keys(firstObject) : ['value'])
if (!columns.length) {
return (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
Preview için en az bir sütun seçin.
</div>
)
}
return (
<div
className={`min-w-0 rounded-lg border border-slate-200 bg-white shadow-sm [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 ${overflow ? 'overflow-auto' : 'overflow-visible'}`}
>
<table className={`w-full border-collapse text-left ${compact ? 'text-[11px]' : 'text-xs'}`}>
<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200">
<tr>
{columns.map((column) => (
<th
key={column}
className={`whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
>
{column === 'value' ? 'Value' : column}
</th>
))}
</tr>
</thead>
<tbody>
{items.slice(0, 100).map((item, rowIndex) => (
<tr
key={rowIndex}
className={`${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' : ''}`}
>
{columns.map((column) => {
const value = column === 'value' ? item : getDesignerValueByPath(item, column)
const text = getGridCellText(value)
return (
<td
key={column}
className={`max-w-64 truncate text-slate-800 dark:text-slate-100 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
title={text}
>
{text}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
/** Deep set on a dot path, used by both the canvas and the generated runtime. */
const setRecordField = (
record: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> => {
const keys = path.split('.').filter(Boolean)
if (!keys.length) return record
const next = { ...record }
let target = next
for (const key of keys.slice(0, -1)) {
const child = target[key]
const branch = child && typeof child === 'object' && !Array.isArray(child) ? { ...child } : {}
target[key] = branch
target = branch as Record<string, unknown>
}
target[keys[keys.length - 1]] = value
return next
}
const SqlDataSourceView = ({
node,
dataValues,
interactive,
renderChildren,
}: {
node: DesignerNode
dataValues: Record<string, unknown>
interactive: boolean
renderChildren: (
childDataValues: Record<string, unknown>,
formScope: DesignerFormScope,
) => React.ReactNode
}) => {
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
const keyField = getSqlDataSourceKeyField(node)
const collectionPath = String(node.props.collectionPath ?? '')
const rows = React.useMemo(
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
[collectionPath, dataValues, selectId],
)
const [rowIndex, setRowIndex] = React.useState(0)
const [mode, setMode] = React.useState<SqlDataSourceMode>('edit')
// Holds the whole record while editing, so New mode can show an empty form
// instead of falling back to the loaded row.
const [draft, setDraft] = React.useState<Record<string, unknown> | null>(null)
const activeRow = React.useMemo(() => rows[rowIndex] ?? {}, [rowIndex, rows])
// A fresh Select result invalidates the local edits, otherwise the canvas would
// keep showing values that no longer exist in the response.
const rowsFingerprint = React.useMemo(() => JSON.stringify(rows), [rows])
React.useEffect(() => {
setRowIndex(0)
setDraft(null)
setMode('edit')
}, [rowsFingerprint])
const record = draft ?? activeRow
const formScope = React.useMemo<DesignerFormScope>(
() => ({
sourceId: node.id,
onFieldChange: (path, value) =>
setDraft((current) => setRecordField(current ?? activeRow, path, value)),
}),
[activeRow, node.id],
)
const childDataValues = React.useMemo(
() => ({ ...dataValues, [node.id]: record }),
[dataValues, node.id, record],
)
const goToRow = (index: number) => {
setRowIndex(index)
setDraft(null)
setMode('edit')
}
const keyValue = readSqlDataSourceField(record, keyField)
const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ''
const canInsert = Boolean(getSqlDataSourceEndpointId(node, 'insertEndpoint'))
const canUpdate = Boolean(getSqlDataSourceEndpointId(node, 'updateEndpoint'))
const canDelete = Boolean(getSqlDataSourceEndpointId(node, 'deleteEndpoint'))
// Save follows the explicit mode, exactly like the generated runtime does.
const canSave = mode === 'new' ? canInsert : canUpdate
const designTimeTitle = 'Tasarım modunda endpoint çağrısı yapılmaz.'
const toolbarButton = (
label: string,
enabled: boolean,
tone: 'primary' | 'danger' | 'plain',
disabledTitle: string,
onClick?: () => void,
) => (
<button
key={label}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${
tone === 'primary'
? 'bg-sky-600 text-white hover:bg-sky-700'
: tone === 'danger'
? 'bg-red-600 text-white hover:bg-red-700'
: 'border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300'
}`}
disabled={!enabled}
title={enabled && !onClick ? designTimeTitle : disabledTitle}
type="button"
onClick={(event) => {
event.stopPropagation()
onClick?.()
}}
>
{label}
</button>
)
return (
<div
className={String(node.props.className || '')}
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
>
{interactive && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200">
<span className="font-semibold uppercase tracking-wider">SqlDataSource</span>
<span>
key: <code>{keyField}</code>
</span>
<span>{selectId ? `${rows.length} kayıt` : 'Select endpointi seçilmedi'}</span>
<span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
{mode === 'new' ? 'Yeni kayıt' : 'Düzenleme'}
</span>
{rows.length > 1 && (
<label className="flex items-center gap-1">
Satır
<select
className="rounded border border-sky-300 bg-white px-1 py-0.5 text-[10px] dark:border-sky-800 dark:bg-slate-900"
value={rowIndex}
onChange={(event) => goToRow(Number(event.target.value) || 0)}
onClick={(event) => event.stopPropagation()}
>
{rows.map((_, index) => (
<option key={index} value={index}>
{index + 1}
</option>
))}
</select>
</label>
)}
</div>
)}
{renderChildren(childDataValues, formScope)}
{/* The drop zone belongs with the content, above the command toolbar. */}
{interactive && !node.children.length && (
<div className="rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700">
Bileşeni buraya bırakın; Data sekmesinden sütununa bağlayın.
</div>
)}
{node.props.showToolbar !== false && (
<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
{/* Navigation appears on its own once there is more than one record. */}
{rows.length > 1 && (
<>
{toolbarButton('Önceki', rowIndex > 0, 'plain', 'İlk kayıttasınız.', () =>
goToRow(Math.max(0, rowIndex - 1)),
)}
<span className="text-xs text-slate-500">
{rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'}
</span>
{toolbarButton(
'Sonraki',
rowIndex < rows.length - 1,
'plain',
'Son kayıttasınız.',
() => goToRow(Math.min(rows.length - 1, rowIndex + 1)),
)}
<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />
</>
)}
{/* New and Reload only touch local state, so they work at design time. */}
{toolbarButton('Yeni', canInsert, 'plain', 'Insert için POST endpointi seçin.', () => {
setDraft({})
setMode('new')
})}
{toolbarButton(
'Kaydet',
canSave,
'primary',
mode === 'new'
? 'Insert için POST endpointi seçin.'
: 'Update için PUT endpointi seçin.',
)}
{toolbarButton(
'Sil',
canDelete && hasKey && mode === 'edit',
'danger',
canDelete ? `Silmek için ${keyField} alanı dolu olmalıdır.` : 'DELETE endpointi seçin.',
)}
{toolbarButton(
'Yenile',
Boolean(selectId),
'plain',
'Select için GET endpointi seçin.',
() => goToRow(rowIndex),
)}
</div>
)}
</div>
)
}
2026-08-05 20:51:43 +00:00
const renderElement = (
node: DesignerNode,
children: React.ReactNode,
dataValues: Record<string, unknown>,
currentItem: unknown,
interactive: boolean,
2026-08-06 13:17:59 +00:00
translate: (key: string) => string,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
2026-08-05 20:51:43 +00:00
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode,
formScope?: DesignerFormScope,
2026-08-05 20:51:43 +00:00
) => {
// Built in NodeView so the container can own the record state and expose it to
// its children through an augmented `dataValues` map.
if (isSqlDataSourceNode(node.type)) return <>{children}</>
2026-08-05 20:51:43 +00:00
if (node.type === 'Spacer') {
return (
<div
aria-hidden="true"
className={String(node.props.className || '')}
style={{ height: Number(node.props.height) || 24 }}
/>
)
2026-08-05 20:51:43 +00:00
}
if (node.type === 'FlexRow') {
return (
<div
className={String(node.props.className || '')}
style={{
display: 'flex',
gap: Number(node.props.gap) || 0,
flexWrap: node.props.wrap ? 'wrap' : 'nowrap',
alignItems: String(node.props.align || 'stretch'),
}}
>
{children}
</div>
)
}
if (node.type === 'PageContainer') {
return (
<div
className={String(node.props.className || '')}
style={{
display: 'flex',
flexDirection: 'column',
gap: Number(node.props.gap) || 0,
margin: '0 auto',
maxWidth: String(node.props.maxWidth || '1280px'),
padding: Number(node.props.padding) || 0,
width: '100%',
}}
>
{children}
</div>
)
}
if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
return (
<div
className={String(node.props.className || '')}
style={{
display: 'grid',
gap: Number(node.props.gap) || 0,
gridTemplateColumns:
node.type === 'SidebarContent'
? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)`
: 'repeat(2, minmax(0, 1fr))',
}}
>
{children}
</div>
)
}
if (node.type === 'HeaderContent') {
return (
<div
className={String(node.props.className || '')}
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
>
{children}
</div>
)
}
if (node.type === 'Grid') {
return (
<div
className={String(node.props.className || '')}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${Number(node.props.cols) || 3}, minmax(0, 1fr))`,
gap: (Number(node.props.gap) || 0) * 4,
}}
>
{children}
</div>
)
}
if (node.kind === 'platform') {
if (interactive) return <PlatformPlaceholder node={node} />
return (
<PlatformViewHost
height={String(node.props.height || '420px')}
listFormCode={String(node.props.listFormCode || '')}
view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
/>
)
}
const childrenBinding = node.bindings?.children
const boundChildren = childrenBinding?.sourceId
? getBindingValue(childrenBinding, dataValues, currentItem)
: undefined
const content: React.ReactNode =
React.Children.count(children) > 0
? children
: boundChildren === null || boundChildren === undefined
? (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) ||
undefined
2026-08-05 20:51:43 +00:00
: React.isValidElement(boundChildren)
? boundChildren
: typeof boundChildren === 'object'
? JSON.stringify(boundChildren)
: String(boundChildren)
const props = getPreviewProps(
node,
dataValues,
currentItem,
onNodePropChange,
translate,
formScope,
)
if (node.kind === 'custom') {
return renderCustomComponent?.(node.type, { ...props, children: content }) || null
2026-08-05 20:51:43 +00:00
}
if (node.type === 'Table') return <>{children}</>
2026-08-05 20:51:43 +00:00
if (node.type === 'Menu') {
const menuProps = { ...props }
const options = Array.isArray(menuProps.items)
? (menuProps.items as Array<Record<string, unknown>>)
: []
delete menuProps.items
delete menuProps.variant
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
return (
<UiKit.Menu {...(menuProps as React.ComponentProps<typeof UiKit.Menu>)}>
{options.map((option, index) => (
<UiKit.Menu.MenuItem
key={String(option.value ?? index)}
eventKey={String(option.value ?? index)}
>
{String(option.label ?? option.value ?? `Menü ${index + 1}`)}
</UiKit.Menu.MenuItem>
))}
{content}
</UiKit.Menu>
)
}
if (node.type === 'Dropdown') {
const dropdownProps = { ...props }
const options = Array.isArray(dropdownProps.items)
? (dropdownProps.items as Array<Record<string, unknown>>)
: []
delete dropdownProps.items
// Without this the toggle keeps showing the static title and the component
// looks untouched after a selection.
dropdownProps.title = resolveDesignerDropdownTitle(
options,
dropdownProps.activeKey,
dropdownProps.title,
)
return (
<UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}>
{options.map((option, index) => (
<UiKit.Dropdown.Item
key={String(option.value ?? index)}
disabled={Boolean(option.disabled)}
eventKey={String(option.value ?? index)}
>
{String(option.label ?? option.value ?? `Öğe ${index + 1}`)}
</UiKit.Dropdown.Item>
))}
{content}
</UiKit.Dropdown>
)
}
// Built in NodeView so each tab can own its own children and drop target.
if (node.type === 'Tabs') return <>{children}</>
if (node.type === 'Radio.Group') {
const groupProps = { ...props }
const options = Array.isArray(groupProps.items)
? (groupProps.items as Array<Record<string, unknown>>)
: []
delete groupProps.items
delete groupProps.checked
delete groupProps.defaultChecked
delete groupProps.readOnly
return (
<UiKit.Radio.Group {...(groupProps as React.ComponentProps<typeof UiKit.Radio.Group>)}>
{options.map((option, index) => (
<UiKit.Radio
key={String(option.value ?? index)}
disabled={Boolean(option.disabled)}
value={option.value ?? index}
>
{String(option.label ?? option.value ?? `Seçenek ${index + 1}`)}
</UiKit.Radio>
))}
{content}
</UiKit.Radio.Group>
)
}
2026-08-05 20:51:43 +00:00
if (node.type === 'checkbox') {
return <input {...(props as React.InputHTMLAttributes<HTMLInputElement>)} type="checkbox" />
}
if (node.kind === 'html') return React.createElement(node.type, props, content)
const Component = resolveUiComponent(node.type)
if (!Component) {
return (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
{node.type}
</div>
)
}
return React.createElement(Component, props, content)
}
const NodeView = ({
node,
index,
siblingCount = 1,
isRoot = false,
2026-08-05 20:51:43 +00:00
selectedId,
interactive,
onSelect,
onDropComponent,
onDropComponentBeside,
onMoveIntoContainer,
2026-08-05 20:51:43 +00:00
onMove,
2026-08-06 13:17:59 +00:00
onReorder,
2026-08-05 20:51:43 +00:00
onDuplicate,
onDelete,
onNodePropChange,
2026-08-05 20:51:43 +00:00
renderCustomComponent,
dataValues,
currentItem,
formScope,
2026-08-05 20:51:43 +00:00
}: {
node: DesignerNode
index: number
siblingCount?: number
isRoot?: boolean
2026-08-05 20:51:43 +00:00
selectedId: string | null
interactive: boolean
onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
onDropComponentBeside?: (
definitionName: string,
targetId: string,
placement: 'before' | 'after',
) => void
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
2026-08-05 20:51:43 +00:00
onMove?: (id: string, direction: -1 | 1) => void
2026-08-06 13:17:59 +00:00
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
2026-08-05 20:51:43 +00:00
onDuplicate?: (id: string) => void
onDelete?: (id: string) => void
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
2026-08-05 20:51:43 +00:00
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
dataValues: Record<string, unknown>
currentItem?: unknown
formScope?: DesignerFormScope
2026-08-05 20:51:43 +00:00
}) => {
2026-08-06 13:17:59 +00:00
const { translate } = useLocalization()
2026-08-05 20:51:43 +00:00
const selected = interactive && selectedId === node.id
// A nested node can always move: at the edge of its container it is lifted out.
// Only the first/last node at the root has nowhere left to go.
const canMoveUp = !isRoot || index > 0
const canMoveDown = !isRoot || index < siblingCount - 1
2026-08-05 20:51:43 +00:00
const acceptsDroppedChildren = [
'PageContainer',
'TwoColumns',
'SidebarContent',
'HeaderContent',
'FlexRow',
'div',
'Card',
'FormContainer',
'Timeline',
// Dropped components land in whichever tab is open.
'Tabs',
'SqlDataSource',
2026-08-05 20:51:43 +00:00
].includes(node.type)
2026-08-06 13:17:59 +00:00
const staticChildren = node.props.children
const hasStaticChildren =
staticChildren !== undefined &&
staticChildren !== null &&
(typeof staticChildren !== 'string' || staticChildren.length > 0)
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
2026-08-05 20:51:43 +00:00
const boundItems = itemsBinding?.sourceId
? getBindingValue(itemsBinding, dataValues, currentItem)
: node.props.items
const sourceRootItems = itemsBinding?.sourceId ? dataValues[itemsBinding.sourceId] : undefined
const effectiveBoundItems =
Array.isArray(boundItems) || currentItem !== undefined
? boundItems
: Array.isArray(sourceRootItems)
? sourceRootItems
: boundItems
const repeatedItems = Array.isArray(effectiveBoundItems)
? interactive && !(node.type === 'Grid' && node.children.length === 0)
? effectiveBoundItems.slice(0, 1)
: effectiveBoundItems
: []
const childContexts = repeatedItems.length ? repeatedItems : [currentItem]
const renderChildNodes = (
childDataValues: Record<string, unknown>,
childFormScope?: DesignerFormScope,
) =>
childContexts.flatMap((childItem, itemIndex) =>
node.children.map((child, childIndex) => (
<NodeView
key={`${child.id}_${itemIndex}`}
node={child}
index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={childDataValues}
currentItem={childItem}
formScope={childFormScope}
onSelect={onSelect}
onDropComponent={onDropComponent}
onDropComponentBeside={onDropComponentBeside}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
)),
)
const children = renderChildNodes(dataValues, formScope)
const sqlDataSourceContent = isSqlDataSourceNode(node.type) ? (
<SqlDataSourceView
dataValues={dataValues}
interactive={interactive}
node={node}
renderChildren={renderChildNodes}
/>
) : null
const tabsContent = (() => {
if (node.type !== 'Tabs') return null
const tabsProps = getPreviewProps(
node,
dataValues,
currentItem,
onNodePropChange,
translate,
formScope,
)
const options = getTabOptions(tabsProps)
delete tabsProps.items
const activeValue = resolveDesignerTabValue(options, tabsProps.value)
tabsProps.value = activeValue
// Children created before per-tab slots existed belong to the first tab.
const firstSlot = options.length ? getDesignerTabSlot(String(options[0]?.value ?? 0)) : ''
return (
<UiKit.Tabs {...(tabsProps as React.ComponentProps<typeof UiKit.Tabs>)}>
<UiKit.Tabs.TabList>
{options.map((option, index) => (
<UiKit.Tabs.TabNav
key={String(option.value ?? index)}
disabled={Boolean(option.disabled)}
value={String(option.value ?? index)}
>
{String(option.label ?? option.value ?? `Sekme ${index + 1}`)}
</UiKit.Tabs.TabNav>
))}
</UiKit.Tabs.TabList>
{options.map((option, index) => {
const tabValue = String(option.value ?? index)
const slot = getDesignerTabSlot(tabValue)
const tabNodes = node.children.filter((child) => (child.slot || firstSlot) === slot)
return (
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
<div
className="min-h-14 py-2"
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
if (!raw) return
try {
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
}
}}
>
{option.content ? <div>{String(option.content)}</div> : null}
{tabNodes.map((child, childIndex) => (
<NodeView
key={child.id}
node={child}
index={childIndex}
siblingCount={tabNodes.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={dataValues}
currentItem={currentItem}
formScope={formScope}
onSelect={onSelect}
onDropComponent={onDropComponent}
onDropComponentBeside={onDropComponentBeside}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
))}
{interactive && !tabNodes.length && !option.content && (
<div className="flex min-h-12 items-center justify-center rounded border border-dashed border-slate-300 px-2 text-center text-[10px] text-slate-400 dark:border-slate-700">
Bu sekme için komponent bırakın
</div>
)}
</div>
</UiKit.Tabs.TabContent>
)
})}
</UiKit.Tabs>
)
})()
const tableContent =
node.type === 'Table' ? (
<UiKit.Table
borderlessRow
className={`${String(node.props.className || '')} [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5`}
compact={Boolean(node.props.compact)}
hoverable={node.props.hoverable !== false}
overflow={node.props.overflow !== false}
style={{
minWidth: '100%',
tableLayout: 'fixed',
width: '100%',
}}
>
<tbody>
{Array.from({ length: getTableDimension(node.props.rowCount, 2) }, (_, rowIndex) => (
<tr key={`row_${rowIndex}`}>
{Array.from(
{ length: getTableDimension(node.props.columnCount, 3) },
(_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}`
const cellNodes = node.children.filter(
(child, childIndex) =>
(child.slot ||
`table:${Math.floor(
childIndex / getTableDimension(node.props.columnCount, 3),
)}:${childIndex % getTableDimension(node.props.columnCount, 3)}`) === slot,
)
return (
<td
key={slot}
className={
interactive
? 'border border-slate-200 align-top dark:border-slate-700'
: 'align-top'
}
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
// Must match the source's effectAllowed, otherwise the
// browser rejects the drop and never fires onDrop.
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'move' ? 'move' : 'copy'
}}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
if (!raw) return
try {
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
// Lets a component be dragged back into a cell, from
// another cell or from anywhere else on the canvas.
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
}
}}
>
<div className="min-h-14 min-w-0 p-1">
{cellNodes.map((child, childIndex) => (
<NodeView
key={child.id}
node={child}
index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={dataValues}
currentItem={currentItem}
formScope={formScope}
onSelect={onSelect}
onDropComponent={onDropComponent}
onDropComponentBeside={onDropComponentBeside}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
2026-08-06 13:17:59 +00:00
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
))}
{interactive && !cellNodes.length && (
<div className="flex min-h-12 items-center justify-center rounded border border-dashed border-slate-300 px-2 text-center text-[10px] text-slate-400 dark:border-slate-700">
Komponent bırakın
</div>
)}
</div>
</td>
)
},
)}
</tr>
))}
</tbody>
</UiKit.Table>
) : null
const renderedChildren = sqlDataSourceContent
? sqlDataSourceContent
: node.type === 'Tabs'
? tabsContent
: node.type === 'Table'
? tableContent
: node.type === 'Grid' && interactive && node.children.length === 0
? repeatedItems.length
2026-08-05 20:51:43 +00:00
? [
<GridDataTablePreview
key={`grid_data_${node.id}`}
borderlessRow={Boolean(node.props.borderlessRow)}
compact={Boolean(node.props.compact)}
hoverable={node.props.hoverable !== false}
items={repeatedItems}
overflow={node.props.overflow !== false}
selectedColumns={
Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: undefined
}
/>,
]
: [
<GridColumnHeaders
key={`grid_headers_${node.id}`}
columns={
Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: []
}
/>,
]
: node.type === 'Grid' &&
!interactive &&
itemsBinding?.sourceId &&
effectiveBoundItems === undefined
? [
<div
key={`grid_loading_${node.id}`}
className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
>
Veriler yükleniyor
</div>,
]
: node.type === 'Grid' &&
itemsBinding?.sourceId &&
effectiveBoundItems !== undefined &&
!Array.isArray(effectiveBoundItems)
? [
<div
key={`grid_binding_error_${node.id}`}
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. Seçili path:{' '}
<code>{itemsBinding.path || '(root)'}</code>
</div>,
]
: node.type === 'Grid' && node.children.length === 0 && repeatedItems.length
? [
<GridDataTablePreview
key={`grid_data_${node.id}`}
borderlessRow={Boolean(node.props.borderlessRow)}
compact={Boolean(node.props.compact)}
hoverable={node.props.hoverable !== false}
items={repeatedItems}
overflow={node.props.overflow !== false}
selectedColumns={
Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: undefined
}
/>,
]
: children
// An empty container's placeholder is an explicit "inside" target, so it never
// depends on where the pointer happens to sit within the node.
const containerDropZoneProps = {
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
if (interactive) acceptDesignerDrag(event)
},
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const payload = readDesignerDragPayload(event)
if (!payload) return
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
onMoveIntoContainer?.(payload.nodeId, node.id)
}
},
}
2026-08-06 13:17:59 +00:00
const contentChildren =
interactive && node.type === 'Card' && node.children.length === 0
? [
<div
key={`card_drop_${node.id}`}
className="rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700"
{...containerDropZoneProps}
2026-08-06 13:17:59 +00:00
>
Bileşeni buraya bırakın
</div>,
]
: renderedChildren
2026-08-05 20:51:43 +00:00
return (
<div
className={`group/node relative min-h-[28px] rounded-md ${
interactive
? selected
2026-08-06 13:17:59 +00:00
? 'z-40 outline outline-2 outline-sky-500 outline-offset-2'
2026-08-05 20:51:43 +00:00
: 'outline outline-1 outline-transparent hover:outline-sky-300'
: ''
}`}
data-designer-node={node.id}
draggable={interactive}
onClickCapture={() => {
if (interactive) onSelect?.(node.id)
}}
onClick={(event) => {
if (!interactive) return
event.stopPropagation()
onSelect?.(node.id)
}}
onDragStart={(event) => {
if (!interactive) return
event.stopPropagation()
2026-08-06 13:17:59 +00:00
const payload = JSON.stringify({ source: 'canvas', nodeId: node.id })
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
event.dataTransfer.setData('text/plain', payload)
2026-08-05 20:51:43 +00:00
}}
onDragOver={(event) => {
2026-08-06 13:17:59 +00:00
if (!interactive) return
2026-08-05 20:51:43 +00:00
event.preventDefault()
event.stopPropagation()
// Must match the source's effectAllowed, otherwise the browser rejects
// the drop and never fires onDrop.
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
2026-08-05 20:51:43 +00:00
}}
onDrop={(event) => {
2026-08-06 13:17:59 +00:00
if (!interactive) return
2026-08-05 20:51:43 +00:00
event.preventDefault()
event.stopPropagation()
const payload = readDesignerDragPayload(event)
if (!payload) return
const bounds = event.currentTarget.getBoundingClientRect()
const offset = event.clientY - bounds.top
// A container claims its middle band as "drop inside"; the outer quarters
// stay reserved for placing the node next to it, which is the only
// meaningful option on a component that cannot host children.
const inside =
acceptsDroppedChildren && offset > bounds.height * 0.25 && offset < bounds.height * 0.75
const placement = offset < bounds.height / 2 ? 'before' : 'after'
if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
if (inside) onMoveIntoContainer?.(payload.nodeId, node.id)
else onReorder?.(payload.nodeId, node.id, placement)
} else if (payload.source === 'library' && payload.name) {
if (inside) onDropComponent?.(payload.name, node.id)
else onDropComponentBeside?.(payload.name, node.id, placement)
2026-08-06 13:17:59 +00:00
}
2026-08-05 20:51:43 +00:00
}}
>
{interactive && (
<div
2026-08-06 13:17:59 +00:00
className={`absolute right-1 top-1 z-50 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-lg ${selected ? 'flex' : 'hidden'}`}
2026-08-05 20:51:43 +00:00
>
<span className="flex items-center gap-1 px-2 text-[10px] font-semibold">
<FaGripVertical /> {node.type}
</span>
<button
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canMoveUp}
title={canMoveUp ? 'Yukarı taşı' : 'Zaten en üstte'}
2026-08-05 20:51:43 +00:00
type="button"
onClick={(event) => {
event.stopPropagation()
onMove?.(node.id, -1)
}}
>
<FaArrowUp />
</button>
<button
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canMoveDown}
title={canMoveDown ? 'Aşağı taşı' : 'Zaten en altta'}
2026-08-05 20:51:43 +00:00
type="button"
onClick={(event) => {
event.stopPropagation()
onMove?.(node.id, 1)
}}
>
<FaArrowDown />
</button>
<button
className="p-1.5 hover:bg-sky-700"
title="Çoğalt"
type="button"
onClick={(event) => {
event.stopPropagation()
onDuplicate?.(node.id)
}}
>
<FaClone />
</button>
<button
className="p-1.5 hover:bg-red-600"
title="Sil"
type="button"
onClick={(event) => {
event.stopPropagation()
onDelete?.(node.id)
}}
>
<FaTrash />
</button>
</div>
)}
<PreviewBoundary
name={node.type}
resetKey={JSON.stringify([node.props, node.bindings, node.events])}
>
{renderElement(
node,
2026-08-06 13:17:59 +00:00
contentChildren,
2026-08-05 20:51:43 +00:00
dataValues,
currentItem,
interactive,
2026-08-06 13:17:59 +00:00
translate,
onNodePropChange,
2026-08-05 20:51:43 +00:00
renderCustomComponent,
formScope,
2026-08-05 20:51:43 +00:00
)}
</PreviewBoundary>
{/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */}
2026-08-05 20:51:43 +00:00
{interactive &&
2026-08-06 13:17:59 +00:00
!hasVisibleChildren &&
acceptsDroppedChildren &&
node.type !== 'Card' &&
node.type !== 'Tabs' &&
// SqlDataSource renders its own placeholder below the toolbar.
!isSqlDataSourceNode(node.type) && (
<div
className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400"
{...containerDropZoneProps}
>
2026-08-05 20:51:43 +00:00
Bileşeni buraya bırakın
</div>
)}
<span className="hidden">{index}</span>
</div>
)
}
const VisualCanvas = ({
nodes,
selectedId,
interactive = true,
onSelect,
onDropComponent,
onDropComponentBeside,
onMoveIntoContainer,
2026-08-05 20:51:43 +00:00
onMove,
2026-08-06 13:17:59 +00:00
onReorder,
2026-08-05 20:51:43 +00:00
onDuplicate,
onDelete,
onNodePropChange,
2026-08-05 20:51:43 +00:00
renderCustomComponent,
dataValues = {},
}: VisualCanvasProps) => {
const previewDataValues = dataValues
return (
<div
className="min-h-full p-8 text-slate-900 dark:text-slate-100"
onClick={() => interactive && onSelect?.('')}
onDragOver={(event) => interactive && event.preventDefault()}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
2026-08-05 20:51:43 +00:00
if (!raw) return
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, null)
} else if (payload.source === 'canvas' && payload.nodeId) {
// Dropping on empty canvas takes the node out of its container.
onMoveIntoContainer?.(payload.nodeId, null)
}
2026-08-05 20:51:43 +00:00
}}
>
{nodes.length ? (
<div className="space-y-4">
{nodes.map((node, index) => (
<NodeView
key={node.id}
isRoot
2026-08-05 20:51:43 +00:00
node={node}
index={index}
siblingCount={nodes.length}
2026-08-05 20:51:43 +00:00
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={previewDataValues}
onSelect={onSelect}
onDropComponent={onDropComponent}
onDropComponentBeside={onDropComponentBeside}
onMoveIntoContainer={onMoveIntoContainer}
2026-08-05 20:51:43 +00:00
onMove={onMove}
2026-08-06 13:17:59 +00:00
onReorder={onReorder}
2026-08-05 20:51:43 +00:00
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
2026-08-05 20:51:43 +00:00
/>
))}
</div>
) : (
2026-08-06 13:17:59 +00:00
<div className="flex min-h-[520px] items-center justify-center rounded-xl border-slate-300 bg-white/70 text-center dark:border-slate-700 dark:bg-slate-900/70">
2026-08-05 20:51:43 +00:00
<div className="w-full max-w-2xl px-6">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
+
</div>
<h3 className="font-semibold text-slate-700 dark:text-slate-200">
Sayfa layoutunu seçin
</h3>
<p className="mt-1 text-sm text-slate-500">
Hazır bir yerleşimle başlayın veya Toolboxtaki My Components grubundan kendi layout
komponentinizi kullanın.
</p>
{interactive && (
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['PageContainer', 'Sayfa alanı', '□'],
['TwoColumns', 'İki kolon', '▥'],
['SidebarContent', 'Sidebar + içerik', '◧'],
['HeaderContent', 'Üst alan + içerik', '⊟'],
].map(([name, label, icon]) => (
<button
key={name}
className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
type="button"
onClick={(event) => {
event.stopPropagation()
onDropComponent?.(name, null)
}}
>
<span className="mb-1 block text-xl text-sky-500">{icon}</span>
{label}
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
)
}
export default VisualCanvas