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

2354 lines
93 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 apiService from '@/services/api.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatLocaleValue } from '@/utils/localeFormat'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
import {
buildDesignerFilterParams,
buildSqlDefaultRecord,
fromDesignerDate,
getDesignerNodeFilters,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerTabSlot,
getDesignerValueByPath,
getSqlDataSourceEndpointId,
getSqlDataSourceColumnCount,
getDesignerColumnCaptions,
getUsableDesignerColumnLookups,
buildDesignerLookupIndex,
resolveDesignerLookupText,
getSqlDataSourceKeyField,
getSqlFormValueProperty,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
isSqlDataSourceNode,
LEGACY_SQL_DATA_SOURCE_TYPE,
normalizeDesignerKeyList,
readSqlDataSourceField,
resolveDesignerDropdownTitle,
resolveDesignerPreviewFilterValue,
resolveDesignerTabValue,
resolveSqlDataSourceRows,
setDesignerRecordField,
SQL_DATA_SOURCE_TYPE,
SQL_DEFAULT_VALUE_PROP,
toDesignerDate,
type DesignerBinding,
type DesignerDataSourceFilter,
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'
}
/**
* "Sürüklenen düğümün içindeyiz" bilgisi ağaçtan aşağı akar. Bir bileşen kendi
* alt ağacının içine taşınamaz; onun dışındaki her hedef — kardeşler, üst
* konteynerler, kök — geçerlidir ve toolbox'tan bırakmayla birebir aynı geri
* bildirimi alır.
*/
const DesignerDraggedSubtreeContext = React.createContext(false)
/**
* Açık bir bırakma bölgesi: üzerine gelindiğinde kendini vurgular. Boş konteyner
* yer tutucuları ve Tabs sekme gövdeleri bunu kullanır.
*/
const useDesignerDropZone = (
interactive: boolean,
onDropPayload: (payload: DesignerDragPayload) => void,
) => {
const [over, setOver] = React.useState(false)
const drag = useDesignerDrag()
React.useEffect(() => {
if (!drag) setOver(false)
}, [drag])
return {
over: over && Boolean(drag),
dragging: Boolean(drag) && interactive,
props: {
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
acceptDesignerDrag(event)
setOver(true)
},
onDragLeave: () => setOver(false),
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setOver(false)
const payload = readDesignerDragPayload(event)
if (payload) onDropPayload(payload)
endDesignerDrag()
},
},
}
}
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
}
/** Prop that turns a component off; react-select spells it differently. */
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')
export interface DesignerRefOverride {
props?: Record<string, unknown>
hidden?: boolean
}
/**
* Design time counterpart of the `refs` object the code generator emits. It lets
* an event script drive the other components on the canvas — value, visibility,
* enabled state, any prop — so the behaviour can be tried out before the
* component is ever saved and compiled.
*/
interface DesignerRefStore {
state: Record<string, DesignerRefOverride>
nodes: Record<string, DesignerNode>
patch: (ref: string, override: DesignerRefOverride) => void
}
const DesignerRefContext = React.createContext<DesignerRefStore | null>(null)
const buildDesignerRefs = (store: DesignerRefStore | null) => {
const refs: Record<string, unknown> = {}
if (!store) return refs
Object.entries(store.nodes).forEach(([ref, node]) => {
const overrideProps = () => store.state[ref]?.props || {}
const disabledProperty = getDisabledProperty(node.type)
const valueProperty =
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
const designTimeOnly = (action: string) => () =>
console.info(`refs.${ref}.${action}() yalnızca çalışma zamanında endpoint çağırır.`)
refs[ref] = {
name: ref,
type: node.type,
getValue: () => overrideProps()[valueProperty] ?? node.props[valueProperty],
setValue: (value: unknown) => store.patch(ref, { props: { [valueProperty]: value } }),
getProps: () => overrideProps(),
setProps: (patch: Record<string, unknown>) => store.patch(ref, { props: patch || {} }),
setProp: (property: string, value: unknown) =>
store.patch(ref, { props: { [property]: value } }),
isVisible: () => !store.state[ref]?.hidden,
setVisible: (visible: unknown) => store.patch(ref, { hidden: visible === false }),
show: () => store.patch(ref, { hidden: false }),
hide: () => store.patch(ref, { hidden: true }),
isEnabled: () =>
(overrideProps()[disabledProperty] ?? Boolean(node.props[disabledProperty])) !== true,
setEnabled: (enabled: unknown) =>
store.patch(ref, { props: { [disabledProperty]: enabled === false } }),
setReadOnly: (readOnly: unknown) =>
store.patch(ref, { props: { readOnly: readOnly !== false } }),
setText: (text: unknown) => store.patch(ref, { props: { children: text } }),
reset: () => store.patch(ref, { props: null as unknown as Record<string, unknown> }),
...(isSqlDataSourceNode(node.type)
? {
getRecord: () => ({}),
setRecord: designTimeOnly('setRecord'),
getField: () => undefined,
setField: designTimeOnly('setField'),
setFields: designTimeOnly('setFields'),
clearFields: designTimeOnly('clearFields'),
getOriginal: () => ({}),
getChanges: () => ({}),
hasChanges: () => false,
getKey: () => undefined,
getKeyField: () => getSqlDataSourceKeyField(node),
getRows: () => [],
getRowCount: () => 0,
getIndex: () => 0,
getMode: () => 'edit',
isNew: () => false,
isBusy: () => false,
getError: () => '',
setError: designTimeOnly('setError'),
goToRow: designTimeOnly('goToRow'),
nextRow: designTimeOnly('nextRow'),
prevRow: designTimeOnly('prevRow'),
firstRow: designTimeOnly('firstRow'),
lastRow: designTimeOnly('lastRow'),
reload: designTimeOnly('reload'),
newRecord: designTimeOnly('newRecord'),
save: designTimeOnly('save'),
remove: designTimeOnly('remove'),
}
: {}),
// Select/AutoComplete/Menu: seçilen kaydın görünmeyen sütunları. Tasarım
// ekranında yalnızca statik seçenekler ve önizleme değeri elde olduğu için
// erişimciler bunlarla çalışır; çalışma zamanında aynı isimler endpoint
// sonucundaki tüm sütunları okur.
...(isDesignerOptionComponent(node.type)
? (() => {
const currentOption = () => {
const value = overrideProps()[valueProperty] ?? node.props[valueProperty]
if (value && typeof value === 'object') return value as Record<string, unknown>
const options = node.props[getDesignerCollectionProperty(node.type)]
if (!Array.isArray(options)) return null
return (
(options.find(
(option) =>
option &&
typeof option === 'object' &&
(option as Record<string, unknown>).value === value,
) as Record<string, unknown>) ?? null
)
}
return {
getOptions: () => {
const options = node.props[getDesignerCollectionProperty(node.type)]
return Array.isArray(options) ? options : []
},
getSelectedOption: () => currentOption(),
getColumn: (path: string) =>
getDesignerValueByPath(currentOption(), String(path || '')),
getLabel: () => String(currentOption()?.label ?? ''),
getColumnNames: () =>
getDesignerBindingColumns(
node.bindings?.[getDesignerCollectionProperty(node.type)],
),
}
})()
: {}),
}
})
return refs
}
const notifyFromScript = (message: unknown, type: unknown = 'info') =>
UiKit.toast.push(
<UiKit.Notification
duration={2500}
type={(type as 'success' | 'warning' | 'danger' | 'info') || 'info'}
>
{typeof message === 'string' ? message : JSON.stringify(message)}
</UiKit.Notification>,
{ placement: 'bottom-end' },
)
/**
* Design time `api`. Reads go through, exactly like the endpoint test button in
* the data panel does, so a script can be tried out on the canvas. Writes are
* refused: the designer must never insert, update or delete real records while
* a component is only being laid out.
*/
const blockedDesignTimeCall = (method: string) => (url: string) => {
console.warn(`Tasarım modunda ${method} çağrısı yapılmaz: ${url}`)
return Promise.resolve(null)
}
const designTimeApi = {
get: (url: string, params?: Record<string, unknown>) =>
apiService.fetchData({ url, method: 'GET', params }).then((response) => response.data),
post: blockedDesignTimeCall('POST'),
put: blockedDesignTimeCall('PUT'),
patch: blockedDesignTimeCall('PATCH'),
delete: blockedDesignTimeCall('DELETE'),
remove: blockedDesignTimeCall('DELETE'),
request: (config: { url: string; method?: string }) =>
String(config?.method || 'GET').toUpperCase() === 'GET'
? apiService.fetchData(config).then((response) => response.data)
: blockedDesignTimeCall(String(config?.method))(config?.url),
errorMessage: (error: {
response?: { data?: { error?: { message?: string }; message?: string } }
message?: string
}) =>
error?.response?.data?.error?.message ||
error?.response?.data?.message ||
error?.message ||
'App.Platform.OperationCouldNotBeCompleted',
}
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as FunctionConstructor
const executeEvent = (
script: string,
event: unknown,
node: DesignerNode,
refs: Record<string, unknown>,
) => {
if (!script.trim()) return
try {
// `await` is only legal inside an async function, and scripts that call the
// API are expected to use it.
const construct = /\bawait\b/.test(script) ? AsyncFunction : Function
const run = construct('event', 'component', 'props', 'refs', 'api', 'notify', script)
const result = run(event, node, node.props, refs, designTimeApi, notifyFromScript)
if (result instanceof Promise) {
result.catch((error) => console.error(`Designer event error (${node.type}):`, error))
}
} 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 ?? `#${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) =>
getSqlFormValueProperty(node, formScope?.sourceId)
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,
refs: Record<string, unknown> = {},
refOverrides: Record<string, unknown> = {},
) => {
const props: Record<string, unknown> = {}
Object.entries(node.props).forEach(([key, value]) => {
// Design time metadata, not a component prop: it seeds a new record instead
// of being rendered.
if (key === SQL_DEFAULT_VALUE_PROP) return
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, refs)
}
})
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)))
}
// Applied last: a script that disabled or refilled this component has to win
// over both the toolbox defaults and the values set in the property panel.
Object.assign(props, refOverrides)
props.key = node.id
return props
}
const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => {
const { translate } = useLocalization()
return (
<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 ${String(node.props.className || '')}`}
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 || translate('::App.VisualDesignerCanvas.SelectListFormCode'),
)}
</div>
<div className="mt-3 text-[11px] uppercase tracking-wider text-sky-700 dark:text-sky-300">
{translate('::App.VisualDesignerCanvas.PlatformView')}
</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[] }) => {
const { translate } = useLocalization()
return (
<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">
{translate('::App.VisualDesignerCanvas.SelectColumnsFromData')}
</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,
captions,
compact = false,
hoverable = true,
items,
lookupIndexes,
overflow = true,
selectedColumns,
}: {
borderlessRow?: boolean
/** Header override per column; a `::` value is a localization key. */
captions?: Record<string, string>
compact?: boolean
hoverable?: boolean
items: unknown[]
/** Value → text map per column, for the columns configured as a lookup. */
lookupIndexes?: Record<string, Map<string, unknown>>
overflow?: boolean
selectedColumns?: string[]
}) => {
const { translate } = useLocalization()
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">
{translate('::App.VisualDesignerCanvas.SelectAtLeastOneColumn')}
</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'}`}
>
{captions?.[column]
? String(resolveStaticLanguageKeys(captions[column], translate))
: 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)
// A column with a lookup paints the matched option text; a value
// with no matching row keeps showing the raw id rather than an
// empty cell, so a broken mapping stays visible.
const lookupIndex = lookupIndexes?.[column]
const lookupText = lookupIndex
? resolveDesignerLookupText(lookupIndex, value)
: undefined
const text = getGridCellText(lookupText === undefined ? value : lookupText)
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>
)
}
/**
* A new record starts at the top of the form: the first enabled editor takes the
* cursor once React has painted the emptied fields.
*/
const focusFirstFormField = (host: React.RefObject<HTMLElement | null>) => {
window.requestAnimationFrame(() => {
const field = host.current?.querySelector<HTMLElement>(
"input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])",
)
if (!field) return
field.focus()
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) field.select()
})
}
const SqlDataSourceView = ({
node,
dataValues,
interactive,
renderChildren,
}: {
node: DesignerNode
dataValues: Record<string, unknown>
interactive: boolean
renderChildren: (
childDataValues: Record<string, unknown>,
formScope: DesignerFormScope,
) => React.ReactNode
}) => {
const { translate } = useLocalization()
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
const keyField = getSqlDataSourceKeyField(node)
const collectionPath = String(node.props.collectionPath ?? '')
const columnCount = getSqlDataSourceColumnCount(node)
const rows = React.useMemo(
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
[collectionPath, dataValues, selectId],
)
// Container element, so New can hand the cursor to the first field of the form.
const hostRef = React.useRef<HTMLDivElement | null>(null)
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) => setDesignerRecordField(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 = translate('::App.VisualDesignerCanvas.DesignTimeNoCall')
// A filled Title turns the container into a Card; the header extra carries the
// key of the record on screen, which is the only identity the user can check.
const cardTitle = String(
resolveStaticLanguageKeys(node.props.title ?? '', translate) ?? '',
).trim()
const headerExtra = hasKey
? `${keyField}: ${String(keyValue)}`
: translate('::App.VisualDesignerCanvas.NewRecord')
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>
)
const body = (
<div
ref={hostRef}
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">{SQL_DATA_SOURCE_TYPE}</span>
<span>
key: <code>{keyField}</code>
</span>
<span>
{selectId
? translate('::App.Platform.RecordCount', { count: rows.length })
: translate('::App.VisualDesignerCanvas.NoSelectEndpoint')}
</span>
<span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
{translate(
mode === 'new'
? '::App.VisualDesignerCanvas.NewRecord'
: '::App.Platform.Editing',
)}
</span>
{rows.length > 1 && (
<label className="flex items-center gap-1">
{translate('::App.Platform.Row')}
<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>
)}
{columnCount > 1 ? (
// Grid only once more than one column is asked for, so a single column
// form keeps the exact flex layout it had before the setting existed.
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
gap: Number(node.props.gap) || 0,
alignItems: 'start',
}}
>
{renderChildren(childDataValues, formScope)}
</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">
{translate('::App.VisualDesignerCanvas.DropAndBind')}
</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(
translate('::App.Platform.Previous'),
rowIndex > 0,
'plain',
translate('::App.VisualDesignerCanvas.AtFirstRecord'),
() => goToRow(Math.max(0, rowIndex - 1)),
)}
<span className="text-xs text-slate-500">
{rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'}
</span>
{toolbarButton(
translate('::App.Platform.Next2'),
rowIndex < rows.length - 1,
'plain',
translate('::App.VisualDesignerCanvas.AtLastRecord'),
() => 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(
translate('::App.Platform.New'),
canInsert,
'plain',
translate('::App.VisualDesignerCanvas.SelectInsertEndpoint'),
() => {
// New mode starts from the defaults declared on the bound children,
// exactly like the generated runtime does.
setDraft(buildSqlDefaultRecord(node))
setMode('new')
focusFirstFormField(hostRef)
},
)}
{toolbarButton(
translate('::App.Platform.Save'),
canSave,
'primary',
translate(
mode === 'new'
? '::App.VisualDesignerCanvas.SelectInsertEndpoint'
: '::App.VisualDesignerCanvas.SelectUpdateEndpoint',
),
)}
{toolbarButton(
translate('::App.Platform.Delete'),
canDelete && hasKey && mode === 'edit',
'danger',
canDelete
? translate('::App.VisualDesignerCanvas.KeyFieldRequired', { field: keyField })
: translate('::App.VisualDesignerCanvas.SelectDeleteEndpoint'),
)}
{toolbarButton(
translate('::App.Platform.Refresh'),
Boolean(selectId),
'plain',
translate('::App.VisualDesignerCanvas.SelectSelectEndpoint'),
() => goToRow(rowIndex),
)}
</div>
)}
</div>
)
return (
<UiKit.Card
bodyClass="p-4"
header={cardTitle || undefined}
headerExtra={cardTitle ? headerExtra : undefined}
>
{body}
</UiKit.Card>
)
}
/**
* A node's own content, resolved in the order the runtime uses: a `setText`
* override beats a data binding, which beats the static `children` prop. Child
* nodes are not considered here — the caller decides whether they take over.
*/
const resolveNodeContent = (
node: DesignerNode,
dataValues: Record<string, unknown>,
currentItem: unknown,
translate: (key: string) => string,
refOverrides: Record<string, unknown> = {},
): React.ReactNode => {
if (refOverrides.children !== undefined) return refOverrides.children as React.ReactNode
const childrenBinding = node.bindings?.children
const boundChildren = childrenBinding?.sourceId
? getBindingValue(childrenBinding, dataValues, currentItem)
: undefined
if (boundChildren === null || boundChildren === undefined) {
return (
(resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) || undefined
)
}
if (React.isValidElement(boundChildren)) return boundChildren
return typeof boundChildren === 'object' ? JSON.stringify(boundChildren) : String(boundChildren)
}
/**
* Platform view in preview mode. Its filters are resolved the same way the data
* source preview resolves them — a static value as typed, anything else from the
* sampling value — and handed to the view as the `filter` expression it already
* understands. A required filter without a sampling value renders nothing, which
* is what the generated page does too.
*/
const PlatformViewPreview = ({ node }: { node: DesignerNode }) => {
const filterKey = JSON.stringify(getDesignerNodeFilters(node))
const params = React.useMemo(() => {
const filters = JSON.parse(filterKey) as DesignerDataSourceFilter[]
if (!filters.length) return undefined
return buildDesignerFilterParams(filters, resolveDesignerPreviewFilterValue)
}, [filterKey])
if (params === null) return null
return (
<PlatformViewHost
className={String(node.props.className || '')}
height={String(node.props.height || '420px')}
listFormCode={String(node.props.listFormCode || '')}
searchParams={params}
view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
/>
)
}
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,
refs: Record<string, unknown> = {},
refOverrides: Record<string, unknown> = {},
) => {
// 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 <PlatformViewPreview node={node} />
}
// Own content and child nodes coexist, exactly as the generator emits them:
// the text first, then the nodes dropped underneath it.
const ownContent = resolveNodeContent(node, dataValues, currentItem, translate, refOverrides)
const hasOwnContent = ownContent !== undefined && ownContent !== null && ownContent !== ''
const content: React.ReactNode =
React.Children.count(children) > 0 ? (
hasOwnContent ? (
<>
{ownContent}
{children}
</>
) : (
children
)
) : (
ownContent
)
const props = getPreviewProps(
node,
dataValues,
currentItem,
onNodePropChange,
translate,
formScope,
refs,
refOverrides,
)
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 ?? `#${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 ?? `#${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 ?? `#${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 refStore = React.useContext(DesignerRefContext)
// Rebuilt on every render so the accessors read the current override state;
// memoising it would hand the scripts a stale snapshot.
const refs = buildDesignerRefs(refStore)
const refOverride = node.ref ? refStore?.state[node.ref] : undefined
const refOverrides = refOverride?.props || {}
const refHidden = Boolean(refOverride?.hidden)
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',
SQL_DATA_SOURCE_TYPE,
LEGACY_SQL_DATA_SOURCE_TYPE,
].includes(node.type)
/* ---- Sürükleme geri bildirimi ---------------------------------- */
const drag = useDesignerDrag()
const [dropHint, setDropHint] = React.useState<'before' | 'inside' | 'after' | null>(null)
/** Tabs sekmesi / tablo hücresi gibi slot hedeflerinden hangisinin üzerindeyiz. */
const [overSlot, setOverSlot] = React.useState<string | null>(null)
React.useEffect(() => {
if (drag) return
setDropHint(null)
setOverSlot(null)
}, [drag])
/** Slot hedeflerinin ortak sürükleme davranışı: kabul et ve kendini işaretle. */
const slotDropProps = (slot: string) => ({
onDragOver: (event: React.DragEvent<HTMLElement>) => {
if (!interactive) return
acceptDesignerDrag(event)
setOverSlot(slot)
},
onDragLeave: () => setOverSlot((current) => (current === slot ? null : current)),
})
const dragging = interactive && Boolean(drag)
const isDragSource = drag?.nodeId === node.id
// Kendisi ve alt ağacı dışında her hedef geçerli: kardeşinin yanına, bir üst
// konteynerin içine ya da köke taşımak hep anlamlı işlemler.
const insideDraggedSubtree = React.useContext(DesignerDraggedSubtreeContext) || isDragSource
const canDropInside = acceptsDroppedChildren && !insideDraggedSubtree
const canDropBeside = !insideDraggedSubtree
const dragLabel = drag?.label || drag?.name || translate('::App.VisualDesignerCanvas.Component')
/**
* Taşımayı başlatan ortak kod. Hem düğüm gövdesi hem de tutamak bunu kullanır:
* Input/Select/Textarea gibi canlı önizleme kontrolleri Chrome'da üst öğenin
* sürüklenmesini başlatmadığı için, gövdesi tamamen bir kontrolle kaplı bir
* bileşenin tutulabileceği tek yer tutamaktır.
*/
const startNodeDrag = (event: React.DragEvent<HTMLElement>) => {
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)
// `dragstart` içinde senkron bir state güncellemesi sürüklenen düğümü hemen
// yeniden render eder ve tarayıcı sürüklemeyi iptal eder. Vurguları bir tık
// sonraya bırakınca sürükleme sağlam başlar, geri bildirim de kaybolmaz.
beginDesignerDragSoon({ source: 'canvas', nodeId: node.id, label: node.ref || node.type })
}
/** Pointer'ın dikey konumu bırakmanın anlamını belirler. */
const resolveDropHint = (event: React.DragEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect()
const offset = event.clientY - bounds.top
// Konteynerlerde orta bant "içine", dış çeyrekler "yanına" demektir.
if (canDropInside && offset > bounds.height * 0.25 && offset < bounds.height * 0.75) {
return 'inside' as const
}
return offset < bounds.height / 2 ? ('before' as const) : ('after' as const)
}
/** Bırakmanın karşılığı olan ağaç işlemi. Kenar şeritleri de bunu kullanır. */
const applyDrop = (payload: DesignerDragPayload, hint: 'before' | 'inside' | 'after') => {
const placement = hint === 'before' ? 'before' : 'after'
if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
if (hint === 'inside') onMoveIntoContainer?.(payload.nodeId, node.id)
else onReorder?.(payload.nodeId, node.id, placement)
} else if (payload.source === 'library' && payload.name) {
if (hint === 'inside') onDropComponent?.(payload.name, node.id)
else onDropComponentBeside?.(payload.name, node.id, placement)
}
}
/**
* Düğümler arasındaki boşluk hiçbir düğüme ait değildi; oraya bırakılan bileşen
* canvas'ın köküne, yani sayfanın en altına gidiyordu. Sürükleme sürerken her
* düğüm kendi üst ve alt boşluğunu kaplayan görünmez birer şerit açar, böylece
* "araya bırak" tam olarak nişan alınan yere düşer.
*/
const edgeStripProps = (placement: 'before' | 'after') => ({
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
acceptDesignerDrag(event)
setDropHint(canDropBeside ? placement : null)
},
onDragLeave: () => setDropHint(null),
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setDropHint(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (payload) applyDrop(payload, placement)
},
})
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
// Option rows of every column configured as a lookup. They come from the same
// sampled data the rest of the canvas paints from, so a mapping can be checked
// while designing instead of only after the component is generated.
const gridLookupIndexes = React.useMemo(
() =>
Object.fromEntries(
Object.entries(getUsableDesignerColumnLookups(node)).map(([column, lookup]) => [
column,
buildDesignerLookupIndex(dataValues[lookup.sourceId], lookup),
]),
),
[dataValues, node],
)
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,
refs,
refOverrides,
)
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 ?? `#${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 rounded py-2 transition ${
overSlot === slot
? 'bg-emerald-50 outline outline-2 outline-emerald-500 dark:bg-emerald-950/40'
: dragging
? 'outline-dashed outline-1 outline-sky-400/70'
: ''
}`}
{...slotDropProps(slot)}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setOverSlot(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (!payload) return
if (payload.source === 'library' && payload.name) {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
}}
>
{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">
{translate('::App.VisualDesignerCanvas.DropComponentInTab')}
</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={
overSlot === slot
? 'border-2 border-emerald-500 bg-emerald-50 align-top dark:bg-emerald-950/40'
: dragging
? 'border border-dashed border-sky-400 align-top'
: interactive
? 'border border-slate-200 align-top dark:border-slate-700'
: 'align-top'
}
{...slotDropProps(slot)}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setOverSlot(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (!payload) return
if (payload.source === 'library' && payload.name) {
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)
}
}}
>
<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">
{translate('::App.VisualDesignerCanvas.DropComponent')}
</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)}
captions={getDesignerColumnCaptions(node)}
hoverable={node.props.hoverable !== false}
items={repeatedItems}
lookupIndexes={gridLookupIndexes}
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"
>
{translate('::App.VisualDesignerCanvas.LoadingData')}
</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)}
captions={getDesignerColumnCaptions(node)}
hoverable={node.props.hoverable !== false}
items={repeatedItems}
lookupIndexes={gridLookupIndexes}
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 containerDropZone = useDesignerDropZone(interactive, (payload) => {
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 containerDropZoneProps = containerDropZone.props
// While a Card holds no child node the drop target stays visible below its own
// content — otherwise a Card filled by its `children` text looks like it takes
// no components. renderElement keeps that text above whatever is passed here.
const contentChildren =
interactive && node.type === 'Card' && node.children.length === 0
? [
<div
key={`card_drop_${node.id}`}
className={`rounded border border-dashed px-3 py-4 text-center text-xs transition ${
containerDropZone.over
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
: containerDropZone.dragging
? 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
: 'border-slate-300 text-slate-400 dark:border-slate-700'
} ${hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''}`}
{...containerDropZoneProps}
>
{containerDropZone.over
? translate('::App.VisualDesignerCanvas.WillDropHere', { label: dragLabel })
: translate('::App.VisualDesignerCanvas.DropComponentHere')}
</div>,
]
: renderedChildren
// A script hid this node. In preview it is gone, exactly like at runtime; while
// designing it stays selectable so the property panel can still reach it.
if (refHidden && !interactive) return null
return (
<DesignerDraggedSubtreeContext.Provider value={insideDraggedSubtree}>
<div
className={`group/node relative min-h-[28px] rounded-md ${refHidden ? 'opacity-40' : ''} ${
// Sürüklenen düğümün kendisi soluklaşır; nereye gittiği hedeften okunur.
isDragSource ? 'opacity-40' : ''
} ${
dropHint === 'inside'
? 'z-10 bg-emerald-500/10 outline outline-2 outline-emerald-500 outline-offset-2'
: // Sürükleme sürerken içine bırakılabilen her konteyner işaretlenir, böylece
// kullanıcı hedefe varmadan önce nereye bırakabileceğini görür.
dragging && canDropInside
? 'outline-dashed outline-1 outline-sky-400/70 outline-offset-2'
: interactive
? selected
? 'z-10 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
startNodeDrag(event)
}}
onDragEnd={() => endDesignerDrag()}
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'
setDropHint(canDropBeside || canDropInside ? resolveDropHint(event) : null)
}}
onDragLeave={() => setDropHint(null)}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setDropHint(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (!payload) return
// Bırakma noktası vurguyla birebir aynı kuraldan hesaplanır; kullanıcı
// ekranda ne gördüyse o olur.
applyDrop(payload, resolveDropHint(event))
}}
>
{/* Üst ve alt boşluğu kaplayan görünmez "araya bırak" şeritleri. */}
{dragging && canDropBeside && (
<>
<div className="absolute inset-x-0 -top-2 z-20 h-2" {...edgeStripProps('before')} />
<div className="absolute inset-x-0 -bottom-2 z-20 h-2" {...edgeStripProps('after')} />
</>
)}
{/*
Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen kontrolle
kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini başlatmaz; tutamak
her bileşen için garanti bir tutma noktası verir. Sürükleme sürerken
gizlenir, aksi halde komşu düğümün bırakma alanını kapatırdı.
*/}
{/* Yanına bırakma çizgisi: bileşen tam olarak buraya girer. */}
{dropHint === 'before' && (
<div className="pointer-events-none absolute inset-x-0 -top-1 z-30 flex items-center gap-1">
<span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{dragLabel} üstüne
</span>
<span className="h-[3px] flex-1 rounded bg-emerald-500" />
</div>
)}
{dropHint === 'after' && (
<div className="pointer-events-none absolute inset-x-0 -bottom-1 z-30 flex items-center gap-1">
<span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{dragLabel} altına
</span>
<span className="h-[3px] flex-1 rounded bg-emerald-500" />
</div>
)}
{dropHint === 'inside' && (
<span className="pointer-events-none absolute left-1 top-1 z-30 rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{node.type} içine
</span>
)}
{/* Sürükleme sürerken bırakılamayan hedef açıkça söylenir. */}
{dragging && insideDraggedSubtree && !isDragSource && (
<span className="pointer-events-none absolute right-1 top-1 z-30 rounded bg-slate-500 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{translate('::App.VisualDesignerCanvas.CannotMoveHere')}
</span>
)}
{interactive && (
<div
className={`absolute right-1 top-1 z-20 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-lg ${selected ? 'flex' : 'hidden'}`}
>
{/*
Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen canlı
kontrolle kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini
başlatmaz; bu rozet her bileşen için garanti bir tutma noktası verir.
*/}
<span
draggable
className="flex cursor-grab items-center gap-1 px-2 text-[10px] font-semibold active:cursor-grabbing"
title={translate('::App.VisualDesignerCanvas.DragToMove', {
name: node.ref || node.type,
})}
onDragStart={startNodeDrag}
onDragEnd={() => endDesignerDrag()}
>
<FaGripVertical /> {node.type}
</span>
<button
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canMoveUp}
title={translate(
canMoveUp ? '::App.Platform.MoveUp' : '::App.VisualDesignerCanvas.AlreadyAtTop',
)}
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={translate(
canMoveDown
? '::App.Platform.MoveDown'
: '::App.VisualDesignerCanvas.AlreadyAtBottom',
)}
type="button"
onClick={(event) => {
event.stopPropagation()
onMove?.(node.id, 1)
}}
>
<FaArrowDown />
</button>
<button
className="p-1.5 hover:bg-sky-700"
title={translate('::App.Platform.Duplicate')}
type="button"
onClick={(event) => {
event.stopPropagation()
onDuplicate?.(node.id)
}}
>
<FaClone />
</button>
<button
className="p-1.5 hover:bg-red-600"
title={translate('::App.Platform.Delete')}
type="button"
onClick={(event) => {
event.stopPropagation()
onDelete?.(node.id)
}}
>
<FaTrash />
</button>
</div>
)}
{interactive && refHidden && (
<span className="absolute left-1 top-1 z-10 rounded bg-slate-700 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
Gizli
</span>
)}
<PreviewBoundary
name={node.type}
resetKey={JSON.stringify([node.props, node.bindings, node.events])}
>
{renderElement(
node,
contentChildren,
dataValues,
currentItem,
interactive,
translate,
onNodePropChange,
renderCustomComponent,
formScope,
refs,
refOverrides,
)}
</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}
>
{translate('::App.VisualDesignerCanvas.DropComponentHere')}
</div>
)}
<span className="hidden">{index}</span>
</div>
</DesignerDraggedSubtreeContext.Provider>
)
}
const VisualCanvas = ({
nodes,
selectedId,
interactive = true,
onSelect,
onDropComponent,
onDropComponentBeside,
onMoveIntoContainer,
onMove,
onReorder,
onDuplicate,
onDelete,
onNodePropChange,
renderCustomComponent,
dataValues = {},
}: VisualCanvasProps) => {
const { translate } = useLocalization()
const previewDataValues = dataValues
const [refState, setRefState] = React.useState<Record<string, DesignerRefOverride>>({})
const nodesByRef = React.useMemo(() => {
const map: Record<string, DesignerNode> = {}
const visit = (list: DesignerNode[]) =>
list.forEach((node) => {
if (node.ref) map[node.ref] = node
visit(node.children || [])
})
visit(nodes)
return map
}, [nodes])
// Overrides are preview state, not document state: adding, removing or
// renaming a component starts from a clean sheet, while editing a property
// leaves whatever the last event script set in place.
const refFingerprint = Object.keys(nodesByRef).sort().join('|')
React.useEffect(() => setRefState({}), [refFingerprint])
const drag = useDesignerDrag()
// Sürükleme canvas dışında bırakılırsa da vurgular temizlenmeli.
React.useEffect(() => {
if (typeof window === 'undefined') return
const clear = () => endDesignerDrag()
window.addEventListener('dragend', clear)
window.addEventListener('drop', clear)
return () => {
window.removeEventListener('dragend', clear)
window.removeEventListener('drop', clear)
}
}, [])
const rootDropZone = useDesignerDropZone(interactive, (payload) => {
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, null)
// Kök alana bırakmak düğümü içinde bulunduğu konteynerden çıkarır.
else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, null)
}
})
const refStore = React.useMemo<DesignerRefStore>(
() => ({
state: refState,
nodes: nodesByRef,
patch: (ref, override) =>
setRefState((current) => ({
...current,
[ref]: {
...current[ref],
...override,
// `props: null` resets; anything else is merged on top.
props:
override.props === null
? {}
: { ...(current[ref]?.props || {}), ...(override.props || {}) },
},
})),
}),
[nodesByRef, refState],
)
return (
<DesignerRefContext.Provider value={refStore}>
<div
className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${
drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : ''
}`}
onClick={() => interactive && onSelect?.('')}
onDragOver={(event) => interactive && event.preventDefault()}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
endDesignerDrag()
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)
}
}}
>
{drag && interactive && (
<div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
<b>{drag.label || drag.name}</b>{' '}
{translate('::App.VisualDesignerCanvas.DraggingHint')}
</span>
</div>
)}
{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}
/>
))}
{/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
konteynerin dışına çıkmanın görünür bir yolu yok. */}
{drag && interactive && (
<div
className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
rootDropZone.over
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
: 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
}`}
{...rootDropZone.props}
>
{translate('::App.VisualDesignerCanvas.DropAtPageEnd')}
</div>
)}
</div>
) : (
<div
className={`flex min-h-[520px] items-center justify-center rounded-xl border bg-white/70 text-center transition dark:bg-slate-900/70 ${
rootDropZone.over
? 'border-2 border-dashed border-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40'
: drag && interactive
? 'border-2 border-dashed border-sky-400'
: 'border-slate-300 dark:border-slate-700'
}`}
{...(interactive ? rootDropZone.props : {})}
>
<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">
{translate('::App.VisualDesignerCanvas.ChoosePageLayout')}
</h3>
<p className="mt-1 text-sm text-slate-500">
{translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}
</p>
{interactive && (
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['PageContainer', translate('::App.VisualDesignerLayout.PageContainer'), '□'],
['TwoColumns', translate('::App.VisualDesignerLayout.TwoColumns'), '▥'],
[
'SidebarContent',
translate('::App.VisualDesignerLayout.SidebarContent'),
'◧',
],
['HeaderContent', translate('::App.VisualDesignerLayout.HeaderContent'), '⊟'],
].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>
</DesignerRefContext.Provider>
)
}
export default VisualCanvas