2026-08-05 20:51:43 +00:00
|
|
|
|
import React from 'react'
|
|
|
|
|
|
import * as UiKit from '@/components/ui'
|
|
|
|
|
|
import PlatformViewHost, {
|
|
|
|
|
|
type PlatformViewName,
|
|
|
|
|
|
} from '@/components/componentEditor/PlatformViewHost'
|
2026-08-08 21:38:31 +00:00
|
|
|
|
import apiService from '@/services/api.service'
|
2026-08-06 13:17:59 +00:00
|
|
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
2026-08-07 09:32:42 +00:00
|
|
|
|
import { formatLocaleValue } from '@/utils/localeFormat'
|
2026-08-05 20:51:43 +00:00
|
|
|
|
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
|
2026-08-10 09:08:08 +00:00
|
|
|
|
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
|
2026-08-05 20:51:43 +00:00
|
|
|
|
import {
|
2026-08-19 12:13:32 +00:00
|
|
|
|
buildDesignerFilterParams,
|
2026-08-18 11:48:16 +00:00
|
|
|
|
buildSqlDefaultRecord,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
fromDesignerDate,
|
2026-08-19 12:13:32 +00:00
|
|
|
|
getDesignerNodeFilters,
|
2026-08-10 09:08:08 +00:00
|
|
|
|
getDesignerBindingColumns,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
getDesignerCollectionProperty,
|
|
|
|
|
|
getDesignerTabSlot,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
getDesignerValueByPath,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
getSqlDataSourceEndpointId,
|
2026-08-20 05:57:55 +00:00
|
|
|
|
getSqlDataSourceColumnCount,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
getSqlDataSourceKeyField,
|
2026-08-18 11:48:16 +00:00
|
|
|
|
getSqlFormValueProperty,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
isDesignerDateComponent,
|
|
|
|
|
|
isDesignerDateProperty,
|
|
|
|
|
|
isDesignerOptionComponent,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
isSqlDataSourceNode,
|
2026-08-19 12:13:32 +00:00
|
|
|
|
LEGACY_SQL_DATA_SOURCE_TYPE,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
normalizeDesignerKeyList,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
readSqlDataSourceField,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
resolveDesignerDropdownTitle,
|
2026-08-19 12:13:32 +00:00
|
|
|
|
resolveDesignerPreviewFilterValue,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
resolveDesignerTabValue,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
resolveSqlDataSourceRows,
|
2026-08-18 11:48:16 +00:00
|
|
|
|
setDesignerRecordField,
|
2026-08-19 12:13:32 +00:00
|
|
|
|
SQL_DATA_SOURCE_TYPE,
|
2026-08-18 11:48:16 +00:00
|
|
|
|
SQL_DEFAULT_VALUE_PROP,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
toDesignerDate,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
type DesignerBinding,
|
2026-08-19 12:13:32 +00:00
|
|
|
|
type DesignerDataSourceFilter,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
type DesignerNode,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
type SqlDataSourceMode,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
} from './types'
|
|
|
|
|
|
|
2026-08-07 09:32:42 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Editing scope opened by a SqlDataSource: a descendant whose `value`/`checked`
|
|
|
|
|
|
* is bound to `sourceId` writes back into the record instead of into its own
|
|
|
|
|
|
* static prop, which is what makes the container behave like an ASP.NET FormView.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface DesignerFormScope {
|
|
|
|
|
|
sourceId: string
|
|
|
|
|
|
onFieldChange: (path: string, value: unknown) => void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 20:51:43 +00:00
|
|
|
|
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
|
2026-08-07 09:32:42 +00:00
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* "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()
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
const getTableDimension = (value: unknown, fallback: number) =>
|
|
|
|
|
|
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
|
2026-08-06 21:19:34 +00:00
|
|
|
|
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
|
2026-08-06 13:17:59 +00:00
|
|
|
|
if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value
|
|
|
|
|
|
if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate))
|
2026-08-06 21:19:34 +00:00
|
|
|
|
// Only plain objects are walked: `Object.entries` on a Date yields nothing and
|
|
|
|
|
|
// would silently turn it into `{}`.
|
|
|
|
|
|
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
2026-08-06 13:17:59 +00:00
|
|
|
|
return Object.fromEntries(
|
2026-08-06 21:19:34 +00:00
|
|
|
|
Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
|
2026-08-06 13:17:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
|
|
|
|
|
|
interface VisualCanvasProps {
|
|
|
|
|
|
nodes: DesignerNode[]
|
|
|
|
|
|
selectedId: string | null
|
|
|
|
|
|
interactive?: boolean
|
|
|
|
|
|
onSelect?: (id: string) => void
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
|
2026-08-07 09:32:42 +00:00
|
|
|
|
/** Adds a toolbox component next to an existing node instead of inside it. */
|
|
|
|
|
|
onDropComponentBeside?: (
|
|
|
|
|
|
definitionName: string,
|
|
|
|
|
|
targetId: string,
|
|
|
|
|
|
placement: 'before' | 'after',
|
|
|
|
|
|
) => void
|
2026-08-06 21:19:34 +00:00
|
|
|
|
/** Moves an existing node into a container/table cell, or to the root. */
|
|
|
|
|
|
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onMove?: (id: string, direction: -1 | 1) => void
|
2026-08-06 13:17:59 +00:00
|
|
|
|
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onDuplicate?: (id: string) => void
|
|
|
|
|
|
onDelete?: (id: string) => void
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
|
2026-08-05 20:51:43 +00:00
|
|
|
|
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
|
|
|
|
|
|
dataValues?: Record<string, unknown>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class PreviewBoundary extends React.Component<
|
|
|
|
|
|
{ name: string; resetKey: string; children: React.ReactNode },
|
|
|
|
|
|
{ failed: boolean }
|
|
|
|
|
|
> {
|
|
|
|
|
|
state = { failed: false }
|
|
|
|
|
|
|
|
|
|
|
|
static getDerivedStateFromError() {
|
|
|
|
|
|
return { failed: true }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
componentDidUpdate(previousProps: Readonly<{ resetKey: string }>) {
|
|
|
|
|
|
if (this.state.failed && previousProps.resetKey !== this.props.resetKey) {
|
|
|
|
|
|
this.setState({ failed: false })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
|
if (this.state.failed) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
|
|
|
|
|
|
{this.props.name} önizlemesi için ek veri veya alt bileşen gerekiyor.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.props.children
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resolveUiComponent = (name: string): React.ElementType | null => {
|
|
|
|
|
|
const parts = name.split('.')
|
|
|
|
|
|
let component: unknown = (UiKit as Record<string, unknown>)[parts[0]]
|
|
|
|
|
|
|
|
|
|
|
|
for (const part of parts.slice(1)) {
|
|
|
|
|
|
if ((typeof component !== 'object' && typeof component !== 'function') || !component) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
component = (component as Record<string, unknown>)[part]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return component ? (component as React.ElementType) : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 21:38:31 +00:00
|
|
|
|
/** 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: () => ({}),
|
2026-08-10 09:08:08 +00:00
|
|
|
|
setRecord: designTimeOnly('setRecord'),
|
|
|
|
|
|
getField: () => undefined,
|
2026-08-08 21:38:31 +00:00
|
|
|
|
setField: designTimeOnly('setField'),
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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'),
|
2026-08-08 21:38:31 +00:00
|
|
|
|
reload: designTimeOnly('reload'),
|
|
|
|
|
|
newRecord: designTimeOnly('newRecord'),
|
|
|
|
|
|
save: designTimeOnly('save'),
|
|
|
|
|
|
remove: designTimeOnly('remove'),
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
2026-08-10 09:08:08 +00:00
|
|
|
|
// 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(),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
getColumn: (path: string) =>
|
|
|
|
|
|
getDesignerValueByPath(currentOption(), String(path || '')),
|
2026-08-10 09:08:08 +00:00
|
|
|
|
getLabel: () => String(currentOption()?.label ?? ''),
|
|
|
|
|
|
getColumnNames: () =>
|
|
|
|
|
|
getDesignerBindingColumns(
|
|
|
|
|
|
node.bindings?.[getDesignerCollectionProperty(node.type)],
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
: {}),
|
2026-08-08 21:38:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
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 ||
|
2026-08-14 11:32:31 +00:00
|
|
|
|
'App.Platform.OperationCouldNotBeCompleted',
|
2026-08-08 21:38:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as FunctionConstructor
|
|
|
|
|
|
|
|
|
|
|
|
const executeEvent = (
|
|
|
|
|
|
script: string,
|
|
|
|
|
|
event: unknown,
|
|
|
|
|
|
node: DesignerNode,
|
|
|
|
|
|
refs: Record<string, unknown>,
|
|
|
|
|
|
) => {
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (!script.trim()) return
|
|
|
|
|
|
try {
|
2026-08-08 21:38:31 +00:00
|
|
|
|
// `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))
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
} 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]),
|
|
|
|
|
|
)
|
2026-08-06 09:16:10 +00:00
|
|
|
|
const labelKey = ['label', 'children', 'name', 'title', 'text', 'description'].find(
|
|
|
|
|
|
(key) => record[key] !== undefined,
|
|
|
|
|
|
)
|
|
|
|
|
|
const valueKey = ['value', 'eventKey', 'id', 'code', 'key'].find(
|
2026-08-05 20:51:43 +00:00
|
|
|
|
(key) => record[key] !== undefined,
|
|
|
|
|
|
)
|
|
|
|
|
|
const labelValue = labelPath
|
|
|
|
|
|
? getDesignerValueByPath(record, labelPath)
|
|
|
|
|
|
: labelKey
|
|
|
|
|
|
? record[labelKey]
|
|
|
|
|
|
: record[primitiveKeys[0]]
|
|
|
|
|
|
const optionValue = valuePath
|
|
|
|
|
|
? getDesignerValueByPath(record, valuePath)
|
|
|
|
|
|
: valueKey
|
|
|
|
|
|
? record[valueKey]
|
|
|
|
|
|
: record[primitiveKeys[1] || primitiveKeys[0]]
|
|
|
|
|
|
return {
|
|
|
|
|
|
...record,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
label: String(labelValue ?? `#${index + 1}`),
|
2026-08-05 20:51:43 +00:00
|
|
|
|
value: optionValue ?? index,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 21:19:34 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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>>) : []
|
|
|
|
|
|
|
2026-08-07 09:32:42 +00:00
|
|
|
|
/** Prop of a node that a SqlDataSource scope is allowed to write back to. */
|
|
|
|
|
|
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
|
2026-08-18 11:48:16 +00:00
|
|
|
|
getSqlFormValueProperty(node, formScope?.sourceId)
|
2026-08-07 09:32:42 +00:00
|
|
|
|
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const getPreviewProps = (
|
|
|
|
|
|
node: DesignerNode,
|
|
|
|
|
|
dataValues: Record<string, unknown>,
|
|
|
|
|
|
currentItem?: unknown,
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
|
2026-08-06 13:17:59 +00:00
|
|
|
|
translate: (key: string) => string = (key) => key,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
formScope?: DesignerFormScope,
|
2026-08-08 21:38:31 +00:00
|
|
|
|
refs: Record<string, unknown> = {},
|
|
|
|
|
|
refOverrides: Record<string, unknown> = {},
|
2026-08-05 20:51:43 +00:00
|
|
|
|
) => {
|
|
|
|
|
|
const props: Record<string, unknown> = {}
|
|
|
|
|
|
Object.entries(node.props).forEach(([key, value]) => {
|
2026-08-18 11:48:16 +00:00
|
|
|
|
// Design time metadata, not a component prop: it seeds a new record instead
|
|
|
|
|
|
// of being rendered.
|
|
|
|
|
|
if (key === SQL_DEFAULT_VALUE_PROP) return
|
2026-08-06 13:17:59 +00:00
|
|
|
|
if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (key.startsWith('on') && typeof value === 'string') return
|
2026-08-06 13:17:59 +00:00
|
|
|
|
props[key] = resolveStaticLanguageKeys(value, translate)
|
2026-08-05 20:51:43 +00:00
|
|
|
|
})
|
|
|
|
|
|
Object.entries(node.events).forEach(([name, script]) => {
|
|
|
|
|
|
if (!script.trim()) return
|
|
|
|
|
|
props[name] = (...args: unknown[]) => {
|
|
|
|
|
|
const event =
|
|
|
|
|
|
node.type === 'Checkbox' && name === 'onChange'
|
|
|
|
|
|
? {
|
|
|
|
|
|
checked: Boolean(args[0]),
|
|
|
|
|
|
originalEvent: args[1],
|
|
|
|
|
|
target:
|
|
|
|
|
|
args[1] && typeof args[1] === 'object' && 'target' in args[1]
|
|
|
|
|
|
? (args[1] as { target: unknown }).target
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
}
|
|
|
|
|
|
: args[0]
|
2026-08-08 21:38:31 +00:00
|
|
|
|
executeEvent(script, event, node, refs)
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
Object.entries(node.bindings || {}).forEach(([propertyName, binding]) => {
|
|
|
|
|
|
if (propertyName !== 'children' && binding.sourceId) {
|
|
|
|
|
|
props[propertyName] = getBindingValue(binding, dataValues, currentItem)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-08-06 21:19:34 +00:00
|
|
|
|
// Stored as ISO strings; pickers only accept real Date instances.
|
|
|
|
|
|
if (isDesignerDateComponent(node.type)) {
|
|
|
|
|
|
Object.keys(props).forEach((propertyName) => {
|
|
|
|
|
|
if (isDesignerDateProperty(node.type, propertyName)) {
|
2026-08-07 09:32:42 +00:00
|
|
|
|
props[propertyName] = toDesignerDate(props[propertyName], node.type)
|
2026-08-06 21:19:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isDesignerOptionComponent(node.type)) {
|
|
|
|
|
|
const collectionProperty = getDesignerCollectionProperty(node.type)
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (node.type === 'Select') {
|
|
|
|
|
|
const legacyAliases: Record<string, string> = {
|
|
|
|
|
|
clearable: 'isClearable',
|
|
|
|
|
|
disabled: 'isDisabled',
|
|
|
|
|
|
multiple: 'isMulti',
|
|
|
|
|
|
searchable: 'isSearchable',
|
|
|
|
|
|
}
|
|
|
|
|
|
Object.entries(legacyAliases).forEach(([legacyName, runtimeName]) => {
|
|
|
|
|
|
if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
|
|
|
|
|
|
props[runtimeName] = props[legacyName]
|
|
|
|
|
|
}
|
|
|
|
|
|
delete props[legacyName]
|
|
|
|
|
|
})
|
2026-08-06 21:19:34 +00:00
|
|
|
|
Object.assign(props, getSelectMenuProps(props))
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
const optionsBinding = node.bindings?.[collectionProperty]
|
|
|
|
|
|
props[collectionProperty] = toSelectOptions(
|
|
|
|
|
|
props[collectionProperty],
|
|
|
|
|
|
optionsBinding?.labelPath,
|
|
|
|
|
|
optionsBinding?.valuePath,
|
|
|
|
|
|
)
|
2026-08-06 13:17:59 +00:00
|
|
|
|
if (node.type === 'Pagination') {
|
|
|
|
|
|
const pages = props.items as Array<Record<string, unknown>>
|
|
|
|
|
|
delete props.items
|
|
|
|
|
|
if (pages.length) props.total = pages.length
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (node.type === 'Select' && 'value' in props) {
|
|
|
|
|
|
const options = props.options as Array<Record<string, unknown>>
|
2026-08-06 09:16:10 +00:00
|
|
|
|
const selectedValue = props.value
|
|
|
|
|
|
props.value = props.isMulti
|
|
|
|
|
|
? options.filter(
|
|
|
|
|
|
(option) => Array.isArray(selectedValue) && selectedValue.includes(option.value),
|
|
|
|
|
|
)
|
|
|
|
|
|
: options.find((option) => option.value === selectedValue) || null
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
|
2026-08-06 21:19:34 +00:00
|
|
|
|
const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => {
|
|
|
|
|
|
const storedHandler =
|
|
|
|
|
|
typeof props[eventName] === 'function'
|
|
|
|
|
|
? (props[eventName] as (...args: unknown[]) => void)
|
|
|
|
|
|
: undefined
|
2026-08-06 09:16:10 +00:00
|
|
|
|
props[eventName] = (...args: unknown[]) => {
|
|
|
|
|
|
update(...args)
|
|
|
|
|
|
storedHandler?.(...args)
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
const updateProp = (propertyName: string, value: unknown) =>
|
|
|
|
|
|
onNodePropChange?.(node.id, propertyName, value)
|
|
|
|
|
|
const eventValue = (value: unknown) =>
|
|
|
|
|
|
value && typeof value === 'object' && 'target' in value
|
|
|
|
|
|
? (value as { target?: { value?: unknown } }).target?.value
|
|
|
|
|
|
: value
|
|
|
|
|
|
|
2026-08-07 09:32:42 +00:00
|
|
|
|
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)))
|
2026-08-06 09:16:10 +00:00
|
|
|
|
} 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 ?? '')))
|
2026-08-06 21:19:34 +00:00
|
|
|
|
} else if (node.type === 'Tabs') {
|
|
|
|
|
|
chainHandler('onChange', (value) => updateProp('value', String(value ?? '')))
|
2026-08-06 09:16:10 +00:00
|
|
|
|
} else if (node.type === 'Radio.Group') {
|
|
|
|
|
|
chainHandler('onChange', (value) => updateProp('value', value))
|
|
|
|
|
|
} else if (node.type === 'Pagination') {
|
|
|
|
|
|
chainHandler('onChange', (value) => updateProp('currentPage', Number(value) || 1))
|
2026-08-06 13:17:59 +00:00
|
|
|
|
} else if (node.type === 'Steps') {
|
|
|
|
|
|
chainHandler('onChange', (value) => updateProp('current', Number(value) || 0))
|
|
|
|
|
|
} else if (node.type === 'ImageViewer') {
|
|
|
|
|
|
chainHandler('onIndexChange', (value) => updateProp('activeIndex', Number(value) || 0))
|
|
|
|
|
|
} else if (node.type === 'Upload') {
|
|
|
|
|
|
chainHandler('onChange', (_files, fileList) =>
|
|
|
|
|
|
updateProp('fileList', Array.isArray(fileList) ? fileList : []),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else if (node.type === 'MenuItem') {
|
|
|
|
|
|
chainHandler('onSelect', () => updateProp('isActive', true))
|
2026-08-06 21:19:34 +00:00
|
|
|
|
} 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.
|
2026-08-07 09:32:42 +00:00
|
|
|
|
chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value, node.type)))
|
2026-08-06 09:16:10 +00:00
|
|
|
|
} else if ('checked' in props) {
|
2026-08-07 09:32:42 +00:00
|
|
|
|
chainHandler('onChange', (value, originalEvent) =>
|
|
|
|
|
|
updateProp('checked', checkedValue(value, originalEvent)),
|
|
|
|
|
|
)
|
2026-08-06 09:16:10 +00:00
|
|
|
|
} else if ('value' in props) {
|
|
|
|
|
|
chainHandler('onChange', (value) => updateProp('value', eventValue(value)))
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
2026-08-08 21:38:31 +00:00
|
|
|
|
// 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)
|
2026-08-06 09:16:10 +00:00
|
|
|
|
props.key = node.id
|
2026-08-05 20:51:43 +00:00
|
|
|
|
return props
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 11:32:31 +00:00
|
|
|
|
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"
|
|
|
|
|
|
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(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
node.props.listFormCode || translate('::App.VisualDesignerCanvas.SelectListFormCode'),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-3 text-[11px] uppercase tracking-wider text-sky-700 dark:text-sky-300">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.PlatformView')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</div>
|
2026-08-05 20:51:43 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 11:32:31 +00:00
|
|
|
|
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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.SelectColumnsFromData')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
|
2026-08-07 09:32:42 +00:00
|
|
|
|
// Dates and decimals follow the selected language, like the DevExtreme grids do.
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const getGridCellText = (value: unknown) =>
|
2026-08-07 09:32:42 +00:00
|
|
|
|
typeof value === 'object' && value !== null && !(value instanceof Date)
|
|
|
|
|
|
? JSON.stringify(value)
|
|
|
|
|
|
: formatLocaleValue(value) || '—'
|
2026-08-05 20:51:43 +00:00
|
|
|
|
|
|
|
|
|
|
const GridDataTablePreview = ({
|
|
|
|
|
|
borderlessRow = false,
|
|
|
|
|
|
compact = false,
|
|
|
|
|
|
hoverable = true,
|
|
|
|
|
|
items,
|
|
|
|
|
|
overflow = true,
|
|
|
|
|
|
selectedColumns,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
borderlessRow?: boolean
|
|
|
|
|
|
compact?: boolean
|
|
|
|
|
|
hoverable?: boolean
|
|
|
|
|
|
items: unknown[]
|
|
|
|
|
|
overflow?: boolean
|
|
|
|
|
|
selectedColumns?: string[]
|
|
|
|
|
|
}) => {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
const { translate } = useLocalization()
|
2026-08-05 20:51:43 +00:00
|
|
|
|
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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.SelectAtLeastOneColumn')}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 11:48:16 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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()
|
|
|
|
|
|
})
|
2026-08-07 09:32:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const SqlDataSourceView = ({
|
|
|
|
|
|
node,
|
|
|
|
|
|
dataValues,
|
|
|
|
|
|
interactive,
|
|
|
|
|
|
renderChildren,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
node: DesignerNode
|
|
|
|
|
|
dataValues: Record<string, unknown>
|
|
|
|
|
|
interactive: boolean
|
|
|
|
|
|
renderChildren: (
|
|
|
|
|
|
childDataValues: Record<string, unknown>,
|
|
|
|
|
|
formScope: DesignerFormScope,
|
|
|
|
|
|
) => React.ReactNode
|
|
|
|
|
|
}) => {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
const { translate } = useLocalization()
|
2026-08-07 09:32:42 +00:00
|
|
|
|
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
|
|
|
|
|
|
const keyField = getSqlDataSourceKeyField(node)
|
|
|
|
|
|
const collectionPath = String(node.props.collectionPath ?? '')
|
2026-08-20 05:57:55 +00:00
|
|
|
|
const columnCount = getSqlDataSourceColumnCount(node)
|
2026-08-07 09:32:42 +00:00
|
|
|
|
const rows = React.useMemo(
|
|
|
|
|
|
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
|
|
|
|
|
|
[collectionPath, dataValues, selectId],
|
|
|
|
|
|
)
|
2026-08-18 11:48:16 +00:00
|
|
|
|
// Container element, so New can hand the cursor to the first field of the form.
|
|
|
|
|
|
const hostRef = React.useRef<HTMLDivElement | null>(null)
|
2026-08-07 09:32:42 +00:00
|
|
|
|
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) =>
|
2026-08-18 11:48:16 +00:00
|
|
|
|
setDraft((current) => setDesignerRecordField(current ?? activeRow, path, value)),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
}),
|
|
|
|
|
|
[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
|
2026-08-14 14:03:34 +00:00
|
|
|
|
const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall')
|
2026-08-19 12:13:32 +00:00
|
|
|
|
// 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')
|
2026-08-07 09:32:42 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-19 12:13:32 +00:00
|
|
|
|
const body = (
|
2026-08-07 09:32:42 +00:00
|
|
|
|
<div
|
2026-08-18 11:48:16 +00:00
|
|
|
|
ref={hostRef}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
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">
|
2026-08-19 12:13:32 +00:00
|
|
|
|
<span className="font-semibold uppercase tracking-wider">{SQL_DATA_SOURCE_TYPE}</span>
|
2026-08-07 09:32:42 +00:00
|
|
|
|
<span>
|
|
|
|
|
|
key: <code>{keyField}</code>
|
|
|
|
|
|
</span>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span>
|
|
|
|
|
|
{selectId
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? translate('::App.Platform.RecordCount', { count: rows.length })
|
|
|
|
|
|
: translate('::App.VisualDesignerCanvas.NoSelectEndpoint')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</span>
|
2026-08-07 09:32:42 +00:00
|
|
|
|
<span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate(
|
|
|
|
|
|
mode === 'new'
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? '::App.VisualDesignerCanvas.NewRecord'
|
|
|
|
|
|
: '::App.Platform.Editing',
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
{rows.length > 1 && (
|
|
|
|
|
|
<label className="flex items-center gap-1">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Row')}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-08-20 05:57:55 +00:00
|
|
|
|
{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)
|
|
|
|
|
|
)}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
{/* 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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.DropAndBind')}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
</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 && (
|
|
|
|
|
|
<>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{toolbarButton(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.Platform.Previous'),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
rowIndex > 0,
|
|
|
|
|
|
'plain',
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.VisualDesignerCanvas.AtFirstRecord'),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
() => goToRow(Math.max(0, rowIndex - 1)),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
)}
|
|
|
|
|
|
<span className="text-xs text-slate-500">
|
|
|
|
|
|
{rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{toolbarButton(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.Platform.Next2'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
rowIndex < rows.length - 1,
|
|
|
|
|
|
'plain',
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.VisualDesignerCanvas.AtLastRecord'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
() => 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(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.Platform.New'),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
canInsert,
|
|
|
|
|
|
'plain',
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.VisualDesignerCanvas.SelectInsertEndpoint'),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
() => {
|
2026-08-18 11:48:16 +00:00
|
|
|
|
// New mode starts from the defaults declared on the bound children,
|
|
|
|
|
|
// exactly like the generated runtime does.
|
|
|
|
|
|
setDraft(buildSqlDefaultRecord(node))
|
2026-08-14 11:32:31 +00:00
|
|
|
|
setMode('new')
|
2026-08-18 11:48:16 +00:00
|
|
|
|
focusFirstFormField(hostRef)
|
2026-08-14 11:32:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
)}
|
|
|
|
|
|
{toolbarButton(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.Platform.Save'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
canSave,
|
|
|
|
|
|
'primary',
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate(
|
|
|
|
|
|
mode === 'new'
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? '::App.VisualDesignerCanvas.SelectInsertEndpoint'
|
|
|
|
|
|
: '::App.VisualDesignerCanvas.SelectUpdateEndpoint',
|
2026-08-14 11:32:31 +00:00
|
|
|
|
),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{toolbarButton(
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate('::App.Platform.Delete'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
canDelete && hasKey && mode === 'edit',
|
|
|
|
|
|
'danger',
|
2026-08-14 11:32:31 +00:00
|
|
|
|
canDelete
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? translate('::App.VisualDesignerCanvas.KeyFieldRequired', { field: keyField })
|
|
|
|
|
|
: translate('::App.VisualDesignerCanvas.SelectDeleteEndpoint'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{toolbarButton(
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate('::App.Platform.Refresh'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
Boolean(selectId),
|
|
|
|
|
|
'plain',
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.VisualDesignerCanvas.SelectSelectEndpoint'),
|
2026-08-07 09:32:42 +00:00
|
|
|
|
() => goToRow(rowIndex),
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-08-19 12:13:32 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<UiKit.Card
|
|
|
|
|
|
bodyClass="p-4"
|
|
|
|
|
|
header={cardTitle || undefined}
|
|
|
|
|
|
headerExtra={cardTitle ? headerExtra : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{body}
|
|
|
|
|
|
</UiKit.Card>
|
|
|
|
|
|
)
|
2026-08-07 09:32:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 21:38:31 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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) {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
return (
|
|
|
|
|
|
(resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) || undefined
|
|
|
|
|
|
)
|
2026-08-08 21:38:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
if (React.isValidElement(boundChildren)) return boundChildren
|
|
|
|
|
|
return typeof boundChildren === 'object' ? JSON.stringify(boundChildren) : String(boundChildren)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 12:13:32 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
height={String(node.props.height || '420px')}
|
|
|
|
|
|
listFormCode={String(node.props.listFormCode || '')}
|
|
|
|
|
|
searchParams={params}
|
|
|
|
|
|
view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const renderElement = (
|
|
|
|
|
|
node: DesignerNode,
|
|
|
|
|
|
children: React.ReactNode,
|
|
|
|
|
|
dataValues: Record<string, unknown>,
|
|
|
|
|
|
currentItem: unknown,
|
|
|
|
|
|
interactive: boolean,
|
2026-08-06 13:17:59 +00:00
|
|
|
|
translate: (key: string) => string,
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
formScope?: DesignerFormScope,
|
2026-08-08 21:38:31 +00:00
|
|
|
|
refs: Record<string, unknown> = {},
|
|
|
|
|
|
refOverrides: Record<string, unknown> = {},
|
2026-08-05 20:51:43 +00:00
|
|
|
|
) => {
|
2026-08-07 09:32:42 +00:00
|
|
|
|
// Built in NodeView so the container can own the record state and expose it to
|
|
|
|
|
|
// its children through an augmented `dataValues` map.
|
|
|
|
|
|
if (isSqlDataSourceNode(node.type)) return <>{children}</>
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (node.type === 'Spacer') {
|
2026-08-06 09:16:10 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={String(node.props.className || '')}
|
|
|
|
|
|
style={{ height: Number(node.props.height) || 24 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
if (node.type === 'FlexRow') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={String(node.props.className || '')}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
gap: Number(node.props.gap) || 0,
|
|
|
|
|
|
flexWrap: node.props.wrap ? 'wrap' : 'nowrap',
|
|
|
|
|
|
alignItems: String(node.props.align || 'stretch'),
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.type === 'PageContainer') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={String(node.props.className || '')}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
gap: Number(node.props.gap) || 0,
|
|
|
|
|
|
margin: '0 auto',
|
|
|
|
|
|
maxWidth: String(node.props.maxWidth || '1280px'),
|
|
|
|
|
|
padding: Number(node.props.padding) || 0,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={String(node.props.className || '')}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gap: Number(node.props.gap) || 0,
|
|
|
|
|
|
gridTemplateColumns:
|
|
|
|
|
|
node.type === 'SidebarContent'
|
|
|
|
|
|
? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)`
|
|
|
|
|
|
: 'repeat(2, minmax(0, 1fr))',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.type === 'HeaderContent') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={String(node.props.className || '')}
|
|
|
|
|
|
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.type === 'Grid') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={String(node.props.className || '')}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gridTemplateColumns: `repeat(${Number(node.props.cols) || 3}, minmax(0, 1fr))`,
|
|
|
|
|
|
gap: (Number(node.props.gap) || 0) * 4,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.kind === 'platform') {
|
|
|
|
|
|
if (interactive) return <PlatformPlaceholder node={node} />
|
2026-08-19 12:13:32 +00:00
|
|
|
|
return <PlatformViewPreview node={node} />
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
2026-08-08 21:38:31 +00:00
|
|
|
|
// 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 !== ''
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const content: React.ReactNode =
|
2026-08-14 11:32:31 +00:00
|
|
|
|
React.Children.count(children) > 0 ? (
|
|
|
|
|
|
hasOwnContent ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{ownContent}
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
children
|
|
|
|
|
|
)
|
|
|
|
|
|
) : (
|
|
|
|
|
|
ownContent
|
|
|
|
|
|
)
|
2026-08-07 09:32:42 +00:00
|
|
|
|
const props = getPreviewProps(
|
|
|
|
|
|
node,
|
|
|
|
|
|
dataValues,
|
|
|
|
|
|
currentItem,
|
|
|
|
|
|
onNodePropChange,
|
|
|
|
|
|
translate,
|
|
|
|
|
|
formScope,
|
2026-08-08 21:38:31 +00:00
|
|
|
|
refs,
|
|
|
|
|
|
refOverrides,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
)
|
2026-08-06 09:16:10 +00:00
|
|
|
|
if (node.kind === 'custom') {
|
|
|
|
|
|
return renderCustomComponent?.(node.type, { ...props, children: content }) || null
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
if (node.type === 'Table') return <>{children}</>
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (node.type === 'Menu') {
|
|
|
|
|
|
const menuProps = { ...props }
|
|
|
|
|
|
const options = Array.isArray(menuProps.items)
|
|
|
|
|
|
? (menuProps.items as Array<Record<string, unknown>>)
|
|
|
|
|
|
: []
|
|
|
|
|
|
delete menuProps.items
|
|
|
|
|
|
delete menuProps.variant
|
|
|
|
|
|
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
|
|
|
|
|
|
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<UiKit.Menu {...(menuProps as React.ComponentProps<typeof UiKit.Menu>)}>
|
|
|
|
|
|
{options.map((option, index) => (
|
|
|
|
|
|
<UiKit.Menu.MenuItem
|
|
|
|
|
|
key={String(option.value ?? index)}
|
|
|
|
|
|
eventKey={String(option.value ?? index)}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{String(option.label ?? option.value ?? `#${index + 1}`)}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
</UiKit.Menu.MenuItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{content}
|
|
|
|
|
|
</UiKit.Menu>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
if (node.type === 'Dropdown') {
|
|
|
|
|
|
const dropdownProps = { ...props }
|
|
|
|
|
|
const options = Array.isArray(dropdownProps.items)
|
|
|
|
|
|
? (dropdownProps.items as Array<Record<string, unknown>>)
|
|
|
|
|
|
: []
|
|
|
|
|
|
delete dropdownProps.items
|
2026-08-06 21:19:34 +00:00
|
|
|
|
// 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,
|
|
|
|
|
|
)
|
2026-08-06 09:16:10 +00:00
|
|
|
|
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)}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{String(option.label ?? option.value ?? `#${index + 1}`)}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
</UiKit.Dropdown.Item>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{content}
|
|
|
|
|
|
</UiKit.Dropdown>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
// Built in NodeView so each tab can own its own children and drop target.
|
|
|
|
|
|
if (node.type === 'Tabs') return <>{children}</>
|
2026-08-06 09:16:10 +00:00
|
|
|
|
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}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{String(option.label ?? option.value ?? `#${index + 1}`)}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
</UiKit.Radio>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{content}
|
|
|
|
|
|
</UiKit.Radio.Group>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
if (node.type === 'checkbox') {
|
|
|
|
|
|
return <input {...(props as React.InputHTMLAttributes<HTMLInputElement>)} type="checkbox" />
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.kind === 'html') return React.createElement(node.type, props, content)
|
|
|
|
|
|
|
|
|
|
|
|
const Component = resolveUiComponent(node.type)
|
|
|
|
|
|
if (!Component) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
|
|
|
|
|
{node.type}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return React.createElement(Component, props, content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const NodeView = ({
|
|
|
|
|
|
node,
|
|
|
|
|
|
index,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
siblingCount = 1,
|
|
|
|
|
|
isRoot = false,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
selectedId,
|
|
|
|
|
|
interactive,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
onDropComponent,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
onDropComponentBeside,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
onMoveIntoContainer,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onMove,
|
2026-08-06 13:17:59 +00:00
|
|
|
|
onReorder,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onDuplicate,
|
|
|
|
|
|
onDelete,
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onNodePropChange,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
renderCustomComponent,
|
|
|
|
|
|
dataValues,
|
|
|
|
|
|
currentItem,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
formScope,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}: {
|
|
|
|
|
|
node: DesignerNode
|
|
|
|
|
|
index: number
|
2026-08-06 21:19:34 +00:00
|
|
|
|
siblingCount?: number
|
|
|
|
|
|
isRoot?: boolean
|
2026-08-05 20:51:43 +00:00
|
|
|
|
selectedId: string | null
|
|
|
|
|
|
interactive: boolean
|
|
|
|
|
|
onSelect?: (id: string) => void
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
|
2026-08-07 09:32:42 +00:00
|
|
|
|
onDropComponentBeside?: (
|
|
|
|
|
|
definitionName: string,
|
|
|
|
|
|
targetId: string,
|
|
|
|
|
|
placement: 'before' | 'after',
|
|
|
|
|
|
) => void
|
2026-08-06 21:19:34 +00:00
|
|
|
|
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onMove?: (id: string, direction: -1 | 1) => void
|
2026-08-06 13:17:59 +00:00
|
|
|
|
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onDuplicate?: (id: string) => void
|
|
|
|
|
|
onDelete?: (id: string) => void
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
|
2026-08-05 20:51:43 +00:00
|
|
|
|
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
|
|
|
|
|
|
dataValues: Record<string, unknown>
|
|
|
|
|
|
currentItem?: unknown
|
2026-08-07 09:32:42 +00:00
|
|
|
|
formScope?: DesignerFormScope
|
2026-08-05 20:51:43 +00:00
|
|
|
|
}) => {
|
2026-08-06 13:17:59 +00:00
|
|
|
|
const { translate } = useLocalization()
|
2026-08-08 21:38:31 +00:00
|
|
|
|
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)
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const selected = interactive && selectedId === node.id
|
2026-08-06 21:19:34 +00:00
|
|
|
|
// A nested node can always move: at the edge of its container it is lifted out.
|
|
|
|
|
|
// Only the first/last node at the root has nowhere left to go.
|
|
|
|
|
|
const canMoveUp = !isRoot || index > 0
|
|
|
|
|
|
const canMoveDown = !isRoot || index < siblingCount - 1
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const acceptsDroppedChildren = [
|
|
|
|
|
|
'PageContainer',
|
|
|
|
|
|
'TwoColumns',
|
|
|
|
|
|
'SidebarContent',
|
|
|
|
|
|
'HeaderContent',
|
|
|
|
|
|
'FlexRow',
|
|
|
|
|
|
'div',
|
|
|
|
|
|
'Card',
|
|
|
|
|
|
'FormContainer',
|
2026-08-07 09:32:42 +00:00
|
|
|
|
'Timeline',
|
2026-08-06 21:19:34 +00:00
|
|
|
|
// Dropped components land in whichever tab is open.
|
|
|
|
|
|
'Tabs',
|
2026-08-19 12:13:32 +00:00
|
|
|
|
SQL_DATA_SOURCE_TYPE,
|
|
|
|
|
|
LEGACY_SQL_DATA_SOURCE_TYPE,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
].includes(node.type)
|
2026-08-10 09:08:08 +00:00
|
|
|
|
|
|
|
|
|
|
/* ---- 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
|
2026-08-14 14:03:34 +00:00
|
|
|
|
const dragLabel = drag?.label || drag?.name || translate('::App.VisualDesignerCanvas.Component')
|
2026-08-10 09:08:08 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
2026-08-06 13:17:59 +00:00
|
|
|
|
const staticChildren = node.props.children
|
|
|
|
|
|
const hasStaticChildren =
|
|
|
|
|
|
staticChildren !== undefined &&
|
|
|
|
|
|
staticChildren !== null &&
|
|
|
|
|
|
(typeof staticChildren !== 'string' || staticChildren.length > 0)
|
|
|
|
|
|
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
|
2026-08-06 21:19:34 +00:00
|
|
|
|
const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
|
2026-08-06 09:16:10 +00:00
|
|
|
|
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const boundItems = itemsBinding?.sourceId
|
|
|
|
|
|
? getBindingValue(itemsBinding, dataValues, currentItem)
|
|
|
|
|
|
: node.props.items
|
|
|
|
|
|
const sourceRootItems = itemsBinding?.sourceId ? dataValues[itemsBinding.sourceId] : undefined
|
|
|
|
|
|
const effectiveBoundItems =
|
|
|
|
|
|
Array.isArray(boundItems) || currentItem !== undefined
|
|
|
|
|
|
? boundItems
|
|
|
|
|
|
: Array.isArray(sourceRootItems)
|
|
|
|
|
|
? sourceRootItems
|
|
|
|
|
|
: boundItems
|
|
|
|
|
|
const repeatedItems = Array.isArray(effectiveBoundItems)
|
|
|
|
|
|
? interactive && !(node.type === 'Grid' && node.children.length === 0)
|
|
|
|
|
|
? effectiveBoundItems.slice(0, 1)
|
|
|
|
|
|
: effectiveBoundItems
|
|
|
|
|
|
: []
|
|
|
|
|
|
const childContexts = repeatedItems.length ? repeatedItems : [currentItem]
|
2026-08-07 09:32:42 +00:00
|
|
|
|
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
|
2026-08-06 21:19:34 +00:00
|
|
|
|
const tabsContent = (() => {
|
|
|
|
|
|
if (node.type !== 'Tabs') return null
|
2026-08-07 09:32:42 +00:00
|
|
|
|
const tabsProps = getPreviewProps(
|
|
|
|
|
|
node,
|
|
|
|
|
|
dataValues,
|
|
|
|
|
|
currentItem,
|
|
|
|
|
|
onNodePropChange,
|
|
|
|
|
|
translate,
|
|
|
|
|
|
formScope,
|
2026-08-08 21:38:31 +00:00
|
|
|
|
refs,
|
|
|
|
|
|
refOverrides,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
)
|
2026-08-06 21:19:34 +00:00
|
|
|
|
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)}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{String(option.label ?? option.value ?? `#${index + 1}`)}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
</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
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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)}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
onDrop={(event) => {
|
|
|
|
|
|
if (!interactive) return
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
event.stopPropagation()
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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)
|
2026-08-06 21:19:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{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}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
formScope={formScope}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
|
onDropComponent={onDropComponent}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
onDropComponentBeside={onDropComponentBeside}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.DropComponentInTab')}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</UiKit.Tabs.TabContent>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</UiKit.Tabs>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()
|
|
|
|
|
|
|
2026-08-06 09:16:10 +00:00
|
|
|
|
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={
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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'
|
2026-08-06 09:16:10 +00:00
|
|
|
|
}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
{...slotDropProps(slot)}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onDrop={(event) => {
|
|
|
|
|
|
if (!interactive) return
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
event.stopPropagation()
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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)
|
2026-08-06 09:16:10 +00:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="min-h-14 min-w-0 p-1">
|
|
|
|
|
|
{cellNodes.map((child, childIndex) => (
|
|
|
|
|
|
<NodeView
|
|
|
|
|
|
key={child.id}
|
|
|
|
|
|
node={child}
|
|
|
|
|
|
index={childIndex}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
siblingCount={node.children.length}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
selectedId={selectedId}
|
|
|
|
|
|
interactive={interactive}
|
|
|
|
|
|
renderCustomComponent={renderCustomComponent}
|
|
|
|
|
|
dataValues={dataValues}
|
|
|
|
|
|
currentItem={currentItem}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
formScope={formScope}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
|
onDropComponent={onDropComponent}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
onDropComponentBeside={onDropComponentBeside}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
onMoveIntoContainer={onMoveIntoContainer}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onMove={onMove}
|
2026-08-06 13:17:59 +00:00
|
|
|
|
onReorder={onReorder}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.DropComponent')}
|
2026-08-06 09:16:10 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</UiKit.Table>
|
|
|
|
|
|
) : null
|
2026-08-07 09:32:42 +00:00
|
|
|
|
const renderedChildren = sqlDataSourceContent
|
|
|
|
|
|
? sqlDataSourceContent
|
|
|
|
|
|
: node.type === 'Tabs'
|
2026-08-06 21:19:34 +00:00
|
|
|
|
? tabsContent
|
|
|
|
|
|
: node.type === 'Table'
|
|
|
|
|
|
? tableContent
|
|
|
|
|
|
: node.type === 'Grid' && interactive && node.children.length === 0
|
|
|
|
|
|
? repeatedItems.length
|
2026-08-05 20:51:43 +00:00
|
|
|
|
? [
|
|
|
|
|
|
<GridDataTablePreview
|
|
|
|
|
|
key={`grid_data_${node.id}`}
|
|
|
|
|
|
borderlessRow={Boolean(node.props.borderlessRow)}
|
|
|
|
|
|
compact={Boolean(node.props.compact)}
|
|
|
|
|
|
hoverable={node.props.hoverable !== false}
|
|
|
|
|
|
items={repeatedItems}
|
|
|
|
|
|
overflow={node.props.overflow !== false}
|
|
|
|
|
|
selectedColumns={
|
|
|
|
|
|
Array.isArray(node.props.dataColumns)
|
|
|
|
|
|
? node.props.dataColumns.filter(
|
|
|
|
|
|
(column): column is string => typeof column === 'string',
|
|
|
|
|
|
)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
/>,
|
|
|
|
|
|
]
|
2026-08-06 21:19:34 +00:00
|
|
|
|
: [
|
|
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.LoadingData')}
|
2026-08-06 21:19:34 +00:00
|
|
|
|
</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
|
2026-08-07 09:32:42 +00:00
|
|
|
|
// An empty container's placeholder is an explicit "inside" target, so it never
|
|
|
|
|
|
// depends on where the pointer happens to sit within the node.
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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
|
2026-08-08 21:38:31 +00:00
|
|
|
|
// 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.
|
2026-08-06 13:17:59 +00:00
|
|
|
|
const contentChildren =
|
|
|
|
|
|
interactive && node.type === 'Card' && node.children.length === 0
|
|
|
|
|
|
? [
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`card_drop_${node.id}`}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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' : ''}`}
|
2026-08-07 09:32:42 +00:00
|
|
|
|
{...containerDropZoneProps}
|
2026-08-06 13:17:59 +00:00
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{containerDropZone.over
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? translate('::App.VisualDesignerCanvas.WillDropHere', { label: dragLabel })
|
|
|
|
|
|
: translate('::App.VisualDesignerCanvas.DropComponentHere')}
|
2026-08-06 13:17:59 +00:00
|
|
|
|
</div>,
|
|
|
|
|
|
]
|
|
|
|
|
|
: renderedChildren
|
2026-08-05 20:51:43 +00:00
|
|
|
|
|
2026-08-08 21:38:31 +00:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-08-05 20:51:43 +00:00
|
|
|
|
return (
|
2026-08-10 09:08:08 +00:00
|
|
|
|
<DesignerDraggedSubtreeContext.Provider value={insideDraggedSubtree}>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<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')} />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/*
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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ı.
|
|
|
|
|
|
*/}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{/* 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
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</span>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{/* 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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.CannotMoveHere')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</span>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{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'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/*
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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.
|
|
|
|
|
|
*/}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span
|
|
|
|
|
|
draggable
|
|
|
|
|
|
className="flex cursor-grab items-center gap-1 px-2 text-[10px] font-semibold active:cursor-grabbing"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.VisualDesignerCanvas.DragToMove', {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
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(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
canMoveUp ? '::App.Platform.MoveUp' : '::App.VisualDesignerCanvas.AlreadyAtTop',
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
onMove?.(node.id, -1)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FaArrowUp />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
|
|
|
|
disabled={!canMoveDown}
|
|
|
|
|
|
title={translate(
|
|
|
|
|
|
canMoveDown
|
|
|
|
|
|
? '::App.Platform.MoveDown'
|
2026-08-14 14:03:34 +00:00
|
|
|
|
: '::App.VisualDesignerCanvas.AlreadyAtBottom',
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
onMove?.(node.id, 1)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FaArrowDown />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="p-1.5 hover:bg-sky-700"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.Duplicate')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
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>
|
2026-08-05 20:51:43 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{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}
|
|
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.DropComponentHere')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="hidden">{index}</span>
|
|
|
|
|
|
</div>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</DesignerDraggedSubtreeContext.Provider>
|
2026-08-05 20:51:43 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const VisualCanvas = ({
|
|
|
|
|
|
nodes,
|
|
|
|
|
|
selectedId,
|
|
|
|
|
|
interactive = true,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
onDropComponent,
|
2026-08-07 09:32:42 +00:00
|
|
|
|
onDropComponentBeside,
|
2026-08-06 21:19:34 +00:00
|
|
|
|
onMoveIntoContainer,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onMove,
|
2026-08-06 13:17:59 +00:00
|
|
|
|
onReorder,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
onDuplicate,
|
|
|
|
|
|
onDelete,
|
2026-08-06 09:16:10 +00:00
|
|
|
|
onNodePropChange,
|
2026-08-05 20:51:43 +00:00
|
|
|
|
renderCustomComponent,
|
|
|
|
|
|
dataValues = {},
|
|
|
|
|
|
}: VisualCanvasProps) => {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
const { translate } = useLocalization()
|
2026-08-05 20:51:43 +00:00
|
|
|
|
const previewDataValues = dataValues
|
2026-08-08 21:38:31 +00:00
|
|
|
|
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])
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-08 21:38:31 +00:00
|
|
|
|
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],
|
|
|
|
|
|
)
|
2026-08-05 20:51:43 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-08 21:38:31 +00:00
|
|
|
|
<DesignerRefContext.Provider value={refStore}>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<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>{' '}
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.DraggingHint')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</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
|
2026-08-10 09:08:08 +00:00
|
|
|
|
konteynerin dışına çıkmanın görünür bir yolu yok. */}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{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}
|
|
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.DropAtPageEnd')}
|
2026-08-05 20:51:43 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.ChoosePageLayout')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="mt-1 text-sm text-slate-500">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
{interactive && (
|
|
|
|
|
|
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
|
|
|
|
{[
|
2026-08-14 14:03:34 +00:00
|
|
|
|
['PageContainer', translate('::App.VisualDesignerLayout.PageContainer'), '□'],
|
|
|
|
|
|
['TwoColumns', translate('::App.VisualDesignerLayout.TwoColumns'), '▥'],
|
2026-08-14 11:32:31 +00:00
|
|
|
|
[
|
|
|
|
|
|
'SidebarContent',
|
2026-08-14 14:03:34 +00:00
|
|
|
|
translate('::App.VisualDesignerLayout.SidebarContent'),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
'◧',
|
|
|
|
|
|
],
|
2026-08-14 14:03:34 +00:00
|
|
|
|
['HeaderContent', translate('::App.VisualDesignerLayout.HeaderContent'), '⊟'],
|
2026-08-14 11:32:31 +00:00
|
|
|
|
].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>
|
2026-08-08 21:38:31 +00:00
|
|
|
|
</DesignerRefContext.Provider>
|
2026-08-05 20:51:43 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default VisualCanvas
|