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

1695 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react'
import * as UiKit from '@/components/ui'
import PlatformViewHost, {
type PlatformViewName,
} from '@/components/componentEditor/PlatformViewHost'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatLocaleValue } from '@/utils/localeFormat'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import {
fromDesignerDate,
getDesignerCollectionProperty,
getDesignerTabSlot,
getDesignerValueByPath,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
isSqlDataSourceNode,
normalizeDesignerKeyList,
readSqlDataSourceField,
resolveDesignerDropdownTitle,
resolveDesignerTabValue,
resolveSqlDataSourceRows,
toDesignerDate,
type DesignerBinding,
type DesignerNode,
type SqlDataSourceMode,
} 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
}
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 => {
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) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
)
}
return value
}
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
onMove?: (id: string, direction: -1 | 1) => void
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
onDuplicate?: (id: string) => void
onDelete?: (id: string) => void
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
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(
(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
const getPreviewProps = (
node: DesignerNode,
dataValues: Record<string, unknown>,
currentItem?: unknown,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
translate: (key: string) => string = (key) => key,
formScope?: DesignerFormScope,
) => {
const props: Record<string, unknown> = {}
Object.entries(node.props).forEach(([key, value]) => {
if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
if (key.startsWith('on') && typeof value === 'string') return
props[key] = resolveStaticLanguageKeys(value, translate)
})
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)
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))
}
const optionsBinding = node.bindings?.[collectionProperty]
props[collectionProperty] = toSelectOptions(
props[collectionProperty],
optionsBinding?.labelPath,
optionsBinding?.valuePath,
)
if (node.type === 'Pagination') {
const pages = props.items as Array<Record<string, unknown>>
delete props.items
if (pages.length) props.total = pages.length
}
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
}
}
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)
}
}
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))
} 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)))
}
props.key = node.id
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.
const getGridCellText = (value: unknown) =>
typeof value === 'object' && value !== null && !(value instanceof Date)
? JSON.stringify(value)
: formatLocaleValue(value) || '—'
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>
)
}
const renderElement = (
node: DesignerNode,
children: React.ReactNode,
dataValues: Record<string, unknown>,
currentItem: unknown,
interactive: boolean,
translate: (key: string) => string,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode,
formScope?: DesignerFormScope,
) => {
// 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}</>
if (node.type === 'Spacer') {
return (
<div
aria-hidden="true"
className={String(node.props.className || '')}
style={{ height: Number(node.props.height) || 24 }}
/>
)
}
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
: 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
}
if (node.type === 'Table') return <>{children}</>
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>
)
}
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,
selectedId,
interactive,
onSelect,
onDropComponent,
onDropComponentBeside,
onMoveIntoContainer,
onMove,
onReorder,
onDuplicate,
onDelete,
onNodePropChange,
renderCustomComponent,
dataValues,
currentItem,
formScope,
}: {
node: DesignerNode
index: number
siblingCount?: number
isRoot?: boolean
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
onMove?: (id: string, direction: -1 | 1) => void
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
onDuplicate?: (id: string) => void
onDelete?: (id: string) => void
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
dataValues: Record<string, unknown>
currentItem?: unknown
formScope?: DesignerFormScope
}) => {
const { translate } = useLocalization()
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
const acceptsDroppedChildren = [
'PageContainer',
'TwoColumns',
'SidebarContent',
'HeaderContent',
'FlexRow',
'div',
'Card',
'FormContainer',
'Timeline',
// Dropped components land in whichever tab is open.
'Tabs',
'SqlDataSource',
].includes(node.type)
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
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}
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
? [
<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)
}
},
}
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}
>
Bileşeni buraya bırakın
</div>,
]
: renderedChildren
return (
<div
className={`group/node relative min-h-[28px] rounded-md ${
interactive
? selected
? 'z-40 outline outline-2 outline-sky-500 outline-offset-2'
: '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()
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)
}}
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 === 'copy' ? 'copy' : 'move'
}}
onDrop={(event) => {
if (!interactive) return
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)
}
}}
>
{interactive && (
<div
className={`absolute right-1 top-1 z-50 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-lg ${selected ? 'flex' : 'hidden'}`}
>
<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'}
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'}
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,
contentChildren,
dataValues,
currentItem,
interactive,
translate,
onNodePropChange,
renderCustomComponent,
formScope,
)}
</PreviewBoundary>
{/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */}
{interactive &&
!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}
>
Bileşeni buraya bırakın
</div>
)}
<span className="hidden">{index}</span>
</div>
)
}
const VisualCanvas = ({
nodes,
selectedId,
interactive = true,
onSelect,
onDropComponent,
onDropComponentBeside,
onMoveIntoContainer,
onMove,
onReorder,
onDuplicate,
onDelete,
onNodePropChange,
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')
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)
}
}}
>
{nodes.length ? (
<div className="space-y-4">
{nodes.map((node, index) => (
<NodeView
key={node.id}
isRoot
node={node}
index={index}
siblingCount={nodes.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={previewDataValues}
onSelect={onSelect}
onDropComponent={onDropComponent}
onDropComponentBeside={onDropComponentBeside}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
))}
</div>
) : (
<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">
<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