893 lines
30 KiB
TypeScript
893 lines
30 KiB
TypeScript
|
|
import React from 'react'
|
|||
|
|
import * as UiKit from '@/components/ui'
|
|||
|
|
import PlatformViewHost, {
|
|||
|
|
type PlatformViewName,
|
|||
|
|
} from '@/components/componentEditor/PlatformViewHost'
|
|||
|
|
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
|
|||
|
|
import {
|
|||
|
|
getDesignerValueByPath,
|
|||
|
|
normalizeDesignerKeyList,
|
|||
|
|
type DesignerBinding,
|
|||
|
|
type DesignerNode,
|
|||
|
|
} from './types'
|
|||
|
|
|
|||
|
|
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
|
|||
|
|
|
|||
|
|
interface VisualCanvasProps {
|
|||
|
|
nodes: DesignerNode[]
|
|||
|
|
selectedId: string | null
|
|||
|
|
background: string
|
|||
|
|
interactive?: boolean
|
|||
|
|
onSelect?: (id: string) => void
|
|||
|
|
onDropComponent?: (definitionName: string, parentId: string | null) => void
|
|||
|
|
onMove?: (id: string, direction: -1 | 1) => void
|
|||
|
|
onDuplicate?: (id: string) => void
|
|||
|
|
onDelete?: (id: string) => void
|
|||
|
|
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
|
|||
|
|
dataValues?: Record<string, unknown>
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class PreviewBoundary extends React.Component<
|
|||
|
|
{ name: string; resetKey: string; children: React.ReactNode },
|
|||
|
|
{ failed: boolean }
|
|||
|
|
> {
|
|||
|
|
state = { failed: false }
|
|||
|
|
|
|||
|
|
static getDerivedStateFromError() {
|
|||
|
|
return { failed: true }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
componentDidUpdate(previousProps: Readonly<{ resetKey: string }>) {
|
|||
|
|
if (this.state.failed && previousProps.resetKey !== this.props.resetKey) {
|
|||
|
|
this.setState({ failed: false })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
render() {
|
|||
|
|
if (this.state.failed) {
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
|
|||
|
|
{this.props.name} önizlemesi için ek veri veya alt bileşen gerekiyor.
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return this.props.children
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolveUiComponent = (name: string): React.ElementType | null => {
|
|||
|
|
const parts = name.split('.')
|
|||
|
|
let component: unknown = (UiKit as Record<string, unknown>)[parts[0]]
|
|||
|
|
|
|||
|
|
for (const part of parts.slice(1)) {
|
|||
|
|
if ((typeof component !== 'object' && typeof component !== 'function') || !component) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
component = (component as Record<string, unknown>)[part]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return component ? (component as React.ElementType) : null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const executeEvent = (script: string, event: unknown, node: DesignerNode) => {
|
|||
|
|
if (!script.trim()) return
|
|||
|
|
try {
|
|||
|
|
const run = new Function('event', 'component', 'props', script)
|
|||
|
|
run(event, node, node.props)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`Designer event error (${node.type}):`, error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getBindingValue = (
|
|||
|
|
binding: DesignerBinding,
|
|||
|
|
dataValues: Record<string, unknown>,
|
|||
|
|
currentItem?: unknown,
|
|||
|
|
) => {
|
|||
|
|
const path = binding.path.trim()
|
|||
|
|
if (currentItem !== undefined && (path === '$item' || path.startsWith('$item.'))) {
|
|||
|
|
return getDesignerValueByPath(currentItem, path === '$item' ? '' : path.slice(6))
|
|||
|
|
}
|
|||
|
|
return getDesignerValueByPath(dataValues[binding.sourceId], path)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const toSelectOptions = (
|
|||
|
|
value: unknown,
|
|||
|
|
labelPath = '',
|
|||
|
|
valuePath = '',
|
|||
|
|
): Array<Record<string, unknown>> => {
|
|||
|
|
if (!Array.isArray(value)) return []
|
|||
|
|
return value.map((item, index) => {
|
|||
|
|
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|||
|
|
return { label: String(item ?? ''), value: item ?? index }
|
|||
|
|
}
|
|||
|
|
const record = item as Record<string, unknown>
|
|||
|
|
const primitiveKeys = Object.keys(record).filter(
|
|||
|
|
(key) => record[key] === null || ['string', 'number', 'boolean'].includes(typeof record[key]),
|
|||
|
|
)
|
|||
|
|
const labelKey = ['label', 'name', 'title', 'text', 'description'].find(
|
|||
|
|
(key) => record[key] !== undefined,
|
|||
|
|
)
|
|||
|
|
const valueKey = ['value', 'id', 'code', 'key'].find((key) => record[key] !== undefined)
|
|||
|
|
const labelValue = labelPath
|
|||
|
|
? getDesignerValueByPath(record, labelPath)
|
|||
|
|
: labelKey
|
|||
|
|
? record[labelKey]
|
|||
|
|
: record[primitiveKeys[0]]
|
|||
|
|
const optionValue = valuePath
|
|||
|
|
? getDesignerValueByPath(record, valuePath)
|
|||
|
|
: valueKey
|
|||
|
|
? record[valueKey]
|
|||
|
|
: record[primitiveKeys[1] || primitiveKeys[0]]
|
|||
|
|
return {
|
|||
|
|
...record,
|
|||
|
|
label: String(labelValue ?? `Seçenek ${index + 1}`),
|
|||
|
|
value: optionValue ?? index,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getPreviewProps = (
|
|||
|
|
node: DesignerNode,
|
|||
|
|
dataValues: Record<string, unknown>,
|
|||
|
|
currentItem?: unknown,
|
|||
|
|
) => {
|
|||
|
|
const props: Record<string, unknown> = {}
|
|||
|
|
Object.entries(node.props).forEach(([key, value]) => {
|
|||
|
|
if (key === 'children' || key === 'html' || value === '') return
|
|||
|
|
if (key.startsWith('on') && typeof value === 'string') return
|
|||
|
|
props[key] = value
|
|||
|
|
})
|
|||
|
|
Object.entries(node.events).forEach(([name, script]) => {
|
|||
|
|
if (!script.trim()) return
|
|||
|
|
props[name] = (...args: unknown[]) => {
|
|||
|
|
const event =
|
|||
|
|
node.type === 'Checkbox' && name === 'onChange'
|
|||
|
|
? {
|
|||
|
|
checked: Boolean(args[0]),
|
|||
|
|
originalEvent: args[1],
|
|||
|
|
target:
|
|||
|
|
args[1] && typeof args[1] === 'object' && 'target' in args[1]
|
|||
|
|
? (args[1] as { target: unknown }).target
|
|||
|
|
: undefined,
|
|||
|
|
}
|
|||
|
|
: args[0]
|
|||
|
|
executeEvent(script, event, node)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
Object.entries(node.bindings || {}).forEach(([propertyName, binding]) => {
|
|||
|
|
if (propertyName !== 'children' && binding.sourceId) {
|
|||
|
|
props[propertyName] = getBindingValue(binding, dataValues, currentItem)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
if (node.type === 'Select' || node.type === 'AutoComplete' || node.type === 'Menu') {
|
|||
|
|
const collectionProperty = node.type === 'Menu' ? 'items' : 'options'
|
|||
|
|
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]
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
const optionsBinding = node.bindings?.[collectionProperty]
|
|||
|
|
props[collectionProperty] = toSelectOptions(
|
|||
|
|
props[collectionProperty],
|
|||
|
|
optionsBinding?.labelPath,
|
|||
|
|
optionsBinding?.valuePath,
|
|||
|
|
)
|
|||
|
|
if (node.type === 'Select' && 'value' in props) {
|
|||
|
|
const options = props.options as Array<Record<string, unknown>>
|
|||
|
|
props.defaultValue = options.find((option) => option.value === props.value) || null
|
|||
|
|
delete props.value
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (node.type === 'Input' && 'value' in props) {
|
|||
|
|
props.defaultValue = props.value
|
|||
|
|
delete props.value
|
|||
|
|
}
|
|||
|
|
if (node.type === 'Checkbox' && 'checked' in props) {
|
|||
|
|
props.defaultChecked = Boolean(props.checked)
|
|||
|
|
delete props.checked
|
|||
|
|
}
|
|||
|
|
if ('value' in props && !props.onChange) props.onChange = () => undefined
|
|||
|
|
if ('checked' in props && !props.onChange) props.onChange = () => undefined
|
|||
|
|
props.key = JSON.stringify([node.props, node.bindings])
|
|||
|
|
return props
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => (
|
|||
|
|
<div
|
|||
|
|
className="flex min-h-40 items-center justify-center rounded-lg border border-dashed border-sky-300 bg-gradient-to-br from-sky-50 to-indigo-50 p-6 text-center dark:border-sky-800 dark:from-slate-900 dark:to-sky-950"
|
|||
|
|
style={{ minHeight: String(node.props.height || '320px') }}
|
|||
|
|
>
|
|||
|
|
<div>
|
|||
|
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600 font-bold text-white">
|
|||
|
|
S
|
|||
|
|
</div>
|
|||
|
|
<div className="font-semibold text-slate-800 dark:text-slate-100">
|
|||
|
|
{node.type.replace(/View$/, '')}
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-1 text-xs text-slate-500">
|
|||
|
|
{String(node.props.listFormCode || 'Property panelinden List Form Code seçin')}
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-3 text-[11px] uppercase tracking-wider text-sky-700 dark:text-sky-300">
|
|||
|
|
Platform görünümü
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const PLATFORM_VIEW_NAMES: Record<string, PlatformViewName> = {
|
|||
|
|
ListView: 'List',
|
|||
|
|
DataGridView: 'Grid',
|
|||
|
|
TreeView: 'Tree',
|
|||
|
|
GanttView: 'GanttView',
|
|||
|
|
TodoBoard: 'TodoBoard',
|
|||
|
|
CardView: 'CardView',
|
|||
|
|
SchedulerView: 'SchedulerView',
|
|||
|
|
PivotView: 'Pivot',
|
|||
|
|
ChartView: 'Chart',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const GridColumnHeaders = ({ columns }: { columns: string[] }) => (
|
|||
|
|
<div className="min-w-0 overflow-auto rounded-md border border-slate-200 bg-slate-100 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-800">
|
|||
|
|
{columns.length ? (
|
|||
|
|
<div className="flex min-w-max divide-x divide-slate-200 dark:divide-slate-700">
|
|||
|
|
{columns.map((column) => (
|
|||
|
|
<div
|
|||
|
|
key={column}
|
|||
|
|
className="min-w-32 px-3 py-2 text-xs font-semibold text-slate-600 dark:text-slate-200"
|
|||
|
|
>
|
|||
|
|
{column}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="px-3 py-2 text-center text-xs text-slate-400">
|
|||
|
|
Data panelinden gösterilecek sütunları seçin.
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const getGridCellText = (value: unknown) =>
|
|||
|
|
typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '') || '—'
|
|||
|
|
|
|||
|
|
const GridDataTablePreview = ({
|
|||
|
|
borderlessRow = false,
|
|||
|
|
compact = false,
|
|||
|
|
hoverable = true,
|
|||
|
|
items,
|
|||
|
|
overflow = true,
|
|||
|
|
selectedColumns,
|
|||
|
|
}: {
|
|||
|
|
borderlessRow?: boolean
|
|||
|
|
compact?: boolean
|
|||
|
|
hoverable?: boolean
|
|||
|
|
items: unknown[]
|
|||
|
|
overflow?: boolean
|
|||
|
|
selectedColumns?: string[]
|
|||
|
|
}) => {
|
|||
|
|
const firstObject = items.find(
|
|||
|
|
(item): item is Record<string, unknown> =>
|
|||
|
|
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
|
|||
|
|
)
|
|||
|
|
const columns = selectedColumns ?? (firstObject ? Object.keys(firstObject) : ['value'])
|
|||
|
|
|
|||
|
|
if (!columns.length) {
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
|||
|
|
Preview için en az bir sütun seçin.
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={`min-w-0 rounded-lg border border-slate-200 bg-white shadow-sm [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 ${overflow ? 'overflow-auto' : 'overflow-visible'}`}
|
|||
|
|
>
|
|||
|
|
<table className={`w-full border-collapse text-left ${compact ? 'text-[11px]' : 'text-xs'}`}>
|
|||
|
|
<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200">
|
|||
|
|
<tr>
|
|||
|
|
{columns.map((column) => (
|
|||
|
|
<th
|
|||
|
|
key={column}
|
|||
|
|
className={`whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
|
|||
|
|
>
|
|||
|
|
{column === 'value' ? 'Value' : column}
|
|||
|
|
</th>
|
|||
|
|
))}
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{items.slice(0, 100).map((item, rowIndex) => (
|
|||
|
|
<tr
|
|||
|
|
key={rowIndex}
|
|||
|
|
className={`${borderlessRow ? '' : 'border-b border-slate-100 last:border-b-0 dark:border-slate-800'} ${hoverable ? 'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70' : ''}`}
|
|||
|
|
>
|
|||
|
|
{columns.map((column) => {
|
|||
|
|
const value = column === 'value' ? item : getDesignerValueByPath(item, column)
|
|||
|
|
const text = getGridCellText(value)
|
|||
|
|
return (
|
|||
|
|
<td
|
|||
|
|
key={column}
|
|||
|
|
className={`max-w-64 truncate text-slate-800 dark:text-slate-100 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
|
|||
|
|
title={text}
|
|||
|
|
>
|
|||
|
|
{text}
|
|||
|
|
</td>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const renderElement = (
|
|||
|
|
node: DesignerNode,
|
|||
|
|
children: React.ReactNode,
|
|||
|
|
dataValues: Record<string, unknown>,
|
|||
|
|
currentItem: unknown,
|
|||
|
|
interactive: boolean,
|
|||
|
|
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode,
|
|||
|
|
) => {
|
|||
|
|
if (node.type === 'Spacer') {
|
|||
|
|
return <div aria-hidden="true" style={{ height: Number(node.props.height) || 24 }} />
|
|||
|
|
}
|
|||
|
|
if (node.type === 'FlexRow') {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={String(node.props.className || '')}
|
|||
|
|
style={{
|
|||
|
|
display: 'flex',
|
|||
|
|
gap: Number(node.props.gap) || 0,
|
|||
|
|
flexWrap: node.props.wrap ? 'wrap' : 'nowrap',
|
|||
|
|
alignItems: String(node.props.align || 'stretch'),
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.type === 'PageContainer') {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={String(node.props.className || '')}
|
|||
|
|
style={{
|
|||
|
|
display: 'flex',
|
|||
|
|
flexDirection: 'column',
|
|||
|
|
gap: Number(node.props.gap) || 0,
|
|||
|
|
margin: '0 auto',
|
|||
|
|
maxWidth: String(node.props.maxWidth || '1280px'),
|
|||
|
|
padding: Number(node.props.padding) || 0,
|
|||
|
|
width: '100%',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={String(node.props.className || '')}
|
|||
|
|
style={{
|
|||
|
|
display: 'grid',
|
|||
|
|
gap: Number(node.props.gap) || 0,
|
|||
|
|
gridTemplateColumns:
|
|||
|
|
node.type === 'SidebarContent'
|
|||
|
|
? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)`
|
|||
|
|
: 'repeat(2, minmax(0, 1fr))',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.type === 'HeaderContent') {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={String(node.props.className || '')}
|
|||
|
|
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.type === 'Grid') {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={String(node.props.className || '')}
|
|||
|
|
style={{
|
|||
|
|
display: 'grid',
|
|||
|
|
gridTemplateColumns: `repeat(${Number(node.props.cols) || 3}, minmax(0, 1fr))`,
|
|||
|
|
gap: (Number(node.props.gap) || 0) * 4,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.kind === 'platform') {
|
|||
|
|
if (interactive) return <PlatformPlaceholder node={node} />
|
|||
|
|
return (
|
|||
|
|
<PlatformViewHost
|
|||
|
|
height={String(node.props.height || '420px')}
|
|||
|
|
listFormCode={String(node.props.listFormCode || '')}
|
|||
|
|
view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
|
|||
|
|
/>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.kind === 'custom') {
|
|||
|
|
return renderCustomComponent?.(node.type, { ...node.props, children }) || null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const childrenBinding = node.bindings?.children
|
|||
|
|
const boundChildren = childrenBinding?.sourceId
|
|||
|
|
? getBindingValue(childrenBinding, dataValues, currentItem)
|
|||
|
|
: undefined
|
|||
|
|
const content: React.ReactNode =
|
|||
|
|
React.Children.count(children) > 0
|
|||
|
|
? children
|
|||
|
|
: boundChildren === null || boundChildren === undefined
|
|||
|
|
? String(node.props.children ?? '') || undefined
|
|||
|
|
: React.isValidElement(boundChildren)
|
|||
|
|
? boundChildren
|
|||
|
|
: typeof boundChildren === 'object'
|
|||
|
|
? JSON.stringify(boundChildren)
|
|||
|
|
: String(boundChildren)
|
|||
|
|
const props = getPreviewProps(node, dataValues, currentItem)
|
|||
|
|
if (node.type === 'Table') {
|
|||
|
|
const legacyData = Array.isArray(props.data) ? props.data : []
|
|||
|
|
const items =
|
|||
|
|
Array.isArray(props.items) && (props.items.length > 0 || !legacyData.length)
|
|||
|
|
? props.items
|
|||
|
|
: legacyData
|
|||
|
|
const legacyColumns = Array.isArray(node.props.columns)
|
|||
|
|
? node.props.columns
|
|||
|
|
.map((column) => {
|
|||
|
|
if (typeof column === 'string') return column
|
|||
|
|
if (!column || typeof column !== 'object' || Array.isArray(column)) return ''
|
|||
|
|
const record = column as Record<string, unknown>
|
|||
|
|
return String(record.dataIndex ?? record.key ?? '')
|
|||
|
|
})
|
|||
|
|
.filter(Boolean)
|
|||
|
|
: undefined
|
|||
|
|
return (
|
|||
|
|
<GridDataTablePreview
|
|||
|
|
borderlessRow={Boolean(node.props.borderlessRow)}
|
|||
|
|
compact={Boolean(node.props.compact)}
|
|||
|
|
hoverable={node.props.hoverable !== false}
|
|||
|
|
items={items}
|
|||
|
|
overflow={node.props.overflow !== false}
|
|||
|
|
selectedColumns={
|
|||
|
|
Array.isArray(node.props.dataColumns)
|
|||
|
|
? node.props.dataColumns.filter(
|
|||
|
|
(column): column is string => typeof column === 'string',
|
|||
|
|
)
|
|||
|
|
: legacyColumns
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.type === 'Menu') {
|
|||
|
|
const menuProps = { ...props }
|
|||
|
|
const options = Array.isArray(menuProps.items)
|
|||
|
|
? (menuProps.items as Array<Record<string, unknown>>)
|
|||
|
|
: []
|
|||
|
|
delete menuProps.items
|
|||
|
|
delete menuProps.variant
|
|||
|
|
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
|
|||
|
|
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
|
|||
|
|
return (
|
|||
|
|
<UiKit.Menu {...(menuProps as React.ComponentProps<typeof UiKit.Menu>)}>
|
|||
|
|
{options.map((option, index) => (
|
|||
|
|
<UiKit.Menu.MenuItem
|
|||
|
|
key={String(option.value ?? index)}
|
|||
|
|
eventKey={String(option.value ?? index)}
|
|||
|
|
>
|
|||
|
|
{String(option.label ?? option.value ?? `Menü ${index + 1}`)}
|
|||
|
|
</UiKit.Menu.MenuItem>
|
|||
|
|
))}
|
|||
|
|
{content}
|
|||
|
|
</UiKit.Menu>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (node.type === '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,
|
|||
|
|
selectedId,
|
|||
|
|
interactive,
|
|||
|
|
onSelect,
|
|||
|
|
onDropComponent,
|
|||
|
|
onMove,
|
|||
|
|
onDuplicate,
|
|||
|
|
onDelete,
|
|||
|
|
renderCustomComponent,
|
|||
|
|
dataValues,
|
|||
|
|
currentItem,
|
|||
|
|
}: {
|
|||
|
|
node: DesignerNode
|
|||
|
|
index: number
|
|||
|
|
selectedId: string | null
|
|||
|
|
interactive: boolean
|
|||
|
|
onSelect?: (id: string) => void
|
|||
|
|
onDropComponent?: (definitionName: string, parentId: string | null) => void
|
|||
|
|
onMove?: (id: string, direction: -1 | 1) => void
|
|||
|
|
onDuplicate?: (id: string) => void
|
|||
|
|
onDelete?: (id: string) => void
|
|||
|
|
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
|
|||
|
|
dataValues: Record<string, unknown>
|
|||
|
|
currentItem?: unknown
|
|||
|
|
}) => {
|
|||
|
|
const selected = interactive && selectedId === node.id
|
|||
|
|
const acceptsDroppedChildren = [
|
|||
|
|
'PageContainer',
|
|||
|
|
'TwoColumns',
|
|||
|
|
'SidebarContent',
|
|||
|
|
'HeaderContent',
|
|||
|
|
'FlexRow',
|
|||
|
|
'div',
|
|||
|
|
'Card',
|
|||
|
|
'FormContainer',
|
|||
|
|
].includes(node.type)
|
|||
|
|
const itemsBinding = ['Grid', 'Table'].includes(node.type) ? node.bindings?.items : undefined
|
|||
|
|
const boundItems = itemsBinding?.sourceId
|
|||
|
|
? getBindingValue(itemsBinding, dataValues, currentItem)
|
|||
|
|
: node.props.items
|
|||
|
|
const sourceRootItems = itemsBinding?.sourceId ? dataValues[itemsBinding.sourceId] : undefined
|
|||
|
|
const effectiveBoundItems =
|
|||
|
|
Array.isArray(boundItems) || currentItem !== undefined
|
|||
|
|
? boundItems
|
|||
|
|
: Array.isArray(sourceRootItems)
|
|||
|
|
? sourceRootItems
|
|||
|
|
: boundItems
|
|||
|
|
const repeatedItems = Array.isArray(effectiveBoundItems)
|
|||
|
|
? interactive && !(node.type === 'Grid' && node.children.length === 0)
|
|||
|
|
? effectiveBoundItems.slice(0, 1)
|
|||
|
|
: effectiveBoundItems
|
|||
|
|
: []
|
|||
|
|
const childContexts = repeatedItems.length ? repeatedItems : [currentItem]
|
|||
|
|
const children = childContexts.flatMap((childItem, itemIndex) =>
|
|||
|
|
node.children.map((child, childIndex) => (
|
|||
|
|
<NodeView
|
|||
|
|
key={`${child.id}_${itemIndex}`}
|
|||
|
|
node={child}
|
|||
|
|
index={childIndex}
|
|||
|
|
selectedId={selectedId}
|
|||
|
|
interactive={interactive}
|
|||
|
|
renderCustomComponent={renderCustomComponent}
|
|||
|
|
dataValues={dataValues}
|
|||
|
|
currentItem={childItem}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
onDropComponent={onDropComponent}
|
|||
|
|
onMove={onMove}
|
|||
|
|
onDuplicate={onDuplicate}
|
|||
|
|
onDelete={onDelete}
|
|||
|
|
/>
|
|||
|
|
)),
|
|||
|
|
)
|
|||
|
|
const renderedChildren =
|
|||
|
|
node.type === 'Grid' && interactive && node.children.length === 0
|
|||
|
|
? repeatedItems.length
|
|||
|
|
? [
|
|||
|
|
<GridDataTablePreview
|
|||
|
|
key={`grid_data_${node.id}`}
|
|||
|
|
borderlessRow={Boolean(node.props.borderlessRow)}
|
|||
|
|
compact={Boolean(node.props.compact)}
|
|||
|
|
hoverable={node.props.hoverable !== false}
|
|||
|
|
items={repeatedItems}
|
|||
|
|
overflow={node.props.overflow !== false}
|
|||
|
|
selectedColumns={
|
|||
|
|
Array.isArray(node.props.dataColumns)
|
|||
|
|
? node.props.dataColumns.filter(
|
|||
|
|
(column): column is string => typeof column === 'string',
|
|||
|
|
)
|
|||
|
|
: undefined
|
|||
|
|
}
|
|||
|
|
/>,
|
|||
|
|
]
|
|||
|
|
: [
|
|||
|
|
<GridColumnHeaders
|
|||
|
|
key={`grid_headers_${node.id}`}
|
|||
|
|
columns={
|
|||
|
|
Array.isArray(node.props.dataColumns)
|
|||
|
|
? node.props.dataColumns.filter(
|
|||
|
|
(column): column is string => typeof column === 'string',
|
|||
|
|
)
|
|||
|
|
: []
|
|||
|
|
}
|
|||
|
|
/>,
|
|||
|
|
]
|
|||
|
|
: node.type === 'Grid' &&
|
|||
|
|
!interactive &&
|
|||
|
|
itemsBinding?.sourceId &&
|
|||
|
|
effectiveBoundItems === undefined
|
|||
|
|
? [
|
|||
|
|
<div
|
|||
|
|
key={`grid_loading_${node.id}`}
|
|||
|
|
className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
|
|||
|
|
>
|
|||
|
|
Veriler yükleniyor…
|
|||
|
|
</div>,
|
|||
|
|
]
|
|||
|
|
: node.type === 'Grid' &&
|
|||
|
|
itemsBinding?.sourceId &&
|
|||
|
|
effectiveBoundItems !== undefined &&
|
|||
|
|
!Array.isArray(effectiveBoundItems)
|
|||
|
|
? [
|
|||
|
|
<div
|
|||
|
|
key={`grid_binding_error_${node.id}`}
|
|||
|
|
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
|
|||
|
|
>
|
|||
|
|
Grid items bağlantısı bir koleksiyon döndürmelidir. Seçili path:{' '}
|
|||
|
|
<code>{itemsBinding.path || '(root)'}</code>
|
|||
|
|
</div>,
|
|||
|
|
]
|
|||
|
|
: node.type === 'Grid' && node.children.length === 0 && repeatedItems.length
|
|||
|
|
? [
|
|||
|
|
<GridDataTablePreview
|
|||
|
|
key={`grid_data_${node.id}`}
|
|||
|
|
borderlessRow={Boolean(node.props.borderlessRow)}
|
|||
|
|
compact={Boolean(node.props.compact)}
|
|||
|
|
hoverable={node.props.hoverable !== false}
|
|||
|
|
items={repeatedItems}
|
|||
|
|
overflow={node.props.overflow !== false}
|
|||
|
|
selectedColumns={
|
|||
|
|
Array.isArray(node.props.dataColumns)
|
|||
|
|
? node.props.dataColumns.filter(
|
|||
|
|
(column): column is string => typeof column === 'string',
|
|||
|
|
)
|
|||
|
|
: undefined
|
|||
|
|
}
|
|||
|
|
/>,
|
|||
|
|
]
|
|||
|
|
: children
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={`group/node relative min-h-[28px] rounded-md ${
|
|||
|
|
interactive
|
|||
|
|
? selected
|
|||
|
|
? 'outline outline-2 outline-sky-500 outline-offset-2'
|
|||
|
|
: 'outline outline-1 outline-transparent hover:outline-sky-300'
|
|||
|
|
: ''
|
|||
|
|
}`}
|
|||
|
|
data-designer-node={node.id}
|
|||
|
|
draggable={interactive}
|
|||
|
|
onClickCapture={() => {
|
|||
|
|
if (interactive) onSelect?.(node.id)
|
|||
|
|
}}
|
|||
|
|
onClick={(event) => {
|
|||
|
|
if (!interactive) return
|
|||
|
|
event.stopPropagation()
|
|||
|
|
onSelect?.(node.id)
|
|||
|
|
}}
|
|||
|
|
onDragStart={(event) => {
|
|||
|
|
if (!interactive) return
|
|||
|
|
event.stopPropagation()
|
|||
|
|
event.dataTransfer.setData(
|
|||
|
|
DESIGNER_DRAG_TYPE,
|
|||
|
|
JSON.stringify({ source: 'canvas', nodeId: node.id }),
|
|||
|
|
)
|
|||
|
|
}}
|
|||
|
|
onDragOver={(event) => {
|
|||
|
|
if (!interactive || !acceptsDroppedChildren) return
|
|||
|
|
event.preventDefault()
|
|||
|
|
event.stopPropagation()
|
|||
|
|
event.dataTransfer.dropEffect = 'copy'
|
|||
|
|
}}
|
|||
|
|
onDrop={(event) => {
|
|||
|
|
if (!interactive || !acceptsDroppedChildren) return
|
|||
|
|
event.preventDefault()
|
|||
|
|
event.stopPropagation()
|
|||
|
|
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, node.id)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{interactive && (
|
|||
|
|
<div
|
|||
|
|
className={`absolute -top-7 right-0 z-20 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-md ${
|
|||
|
|
selected ? 'flex' : 'hidden group-hover/node:flex'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span className="flex items-center gap-1 px-2 text-[10px] font-semibold">
|
|||
|
|
<FaGripVertical /> {node.type}
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
className="p-1.5 hover:bg-sky-700"
|
|||
|
|
title="Yukarı taşı"
|
|||
|
|
type="button"
|
|||
|
|
onClick={(event) => {
|
|||
|
|
event.stopPropagation()
|
|||
|
|
onMove?.(node.id, -1)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<FaArrowUp />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
className="p-1.5 hover:bg-sky-700"
|
|||
|
|
title="Aşağı taşı"
|
|||
|
|
type="button"
|
|||
|
|
onClick={(event) => {
|
|||
|
|
event.stopPropagation()
|
|||
|
|
onMove?.(node.id, 1)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<FaArrowDown />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
className="p-1.5 hover:bg-sky-700"
|
|||
|
|
title="Çoğalt"
|
|||
|
|
type="button"
|
|||
|
|
onClick={(event) => {
|
|||
|
|
event.stopPropagation()
|
|||
|
|
onDuplicate?.(node.id)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<FaClone />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
className="p-1.5 hover:bg-red-600"
|
|||
|
|
title="Sil"
|
|||
|
|
type="button"
|
|||
|
|
onClick={(event) => {
|
|||
|
|
event.stopPropagation()
|
|||
|
|
onDelete?.(node.id)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<FaTrash />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<PreviewBoundary
|
|||
|
|
name={node.type}
|
|||
|
|
resetKey={JSON.stringify([node.props, node.bindings, node.events])}
|
|||
|
|
>
|
|||
|
|
{renderElement(
|
|||
|
|
node,
|
|||
|
|
renderedChildren,
|
|||
|
|
dataValues,
|
|||
|
|
currentItem,
|
|||
|
|
interactive,
|
|||
|
|
renderCustomComponent,
|
|||
|
|
)}
|
|||
|
|
</PreviewBoundary>
|
|||
|
|
{interactive &&
|
|||
|
|
node.children.length === 0 &&
|
|||
|
|
acceptsDroppedChildren && (
|
|||
|
|
<div className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400">
|
|||
|
|
Bileşeni buraya bırakın
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<span className="hidden">{index}</span>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const VisualCanvas = ({
|
|||
|
|
nodes,
|
|||
|
|
selectedId,
|
|||
|
|
background,
|
|||
|
|
interactive = true,
|
|||
|
|
onSelect,
|
|||
|
|
onDropComponent,
|
|||
|
|
onMove,
|
|||
|
|
onDuplicate,
|
|||
|
|
onDelete,
|
|||
|
|
renderCustomComponent,
|
|||
|
|
dataValues = {},
|
|||
|
|
}: VisualCanvasProps) => {
|
|||
|
|
const previewDataValues = dataValues
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className="min-h-full p-8 text-slate-900 dark:text-slate-100"
|
|||
|
|
style={{ background }}
|
|||
|
|
onClick={() => interactive && onSelect?.('')}
|
|||
|
|
onDragOver={(event) => interactive && event.preventDefault()}
|
|||
|
|
onDrop={(event) => {
|
|||
|
|
if (!interactive) return
|
|||
|
|
event.preventDefault()
|
|||
|
|
const raw =
|
|||
|
|
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
|||
|
|
event.dataTransfer.getData('text/plain')
|
|||
|
|
if (!raw) return
|
|||
|
|
const payload = JSON.parse(raw)
|
|||
|
|
if (payload.source === 'library') onDropComponent?.(payload.name, null)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{nodes.length ? (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{nodes.map((node, index) => (
|
|||
|
|
<NodeView
|
|||
|
|
key={node.id}
|
|||
|
|
node={node}
|
|||
|
|
index={index}
|
|||
|
|
selectedId={selectedId}
|
|||
|
|
interactive={interactive}
|
|||
|
|
renderCustomComponent={renderCustomComponent}
|
|||
|
|
dataValues={previewDataValues}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
onDropComponent={onDropComponent}
|
|||
|
|
onMove={onMove}
|
|||
|
|
onDuplicate={onDuplicate}
|
|||
|
|
onDelete={onDelete}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex min-h-[520px] items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-white/70 text-center dark:border-slate-700 dark:bg-slate-900/70">
|
|||
|
|
<div className="w-full max-w-2xl px-6">
|
|||
|
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
|
|||
|
|
+
|
|||
|
|
</div>
|
|||
|
|
<h3 className="font-semibold text-slate-700 dark:text-slate-200">
|
|||
|
|
Sayfa layout’unu seçin
|
|||
|
|
</h3>
|
|||
|
|
<p className="mt-1 text-sm text-slate-500">
|
|||
|
|
Hazır bir yerleşimle başlayın veya Toolbox’taki My Components grubundan kendi layout
|
|||
|
|
komponentinizi kullanın.
|
|||
|
|
</p>
|
|||
|
|
{interactive && (
|
|||
|
|
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|||
|
|
{[
|
|||
|
|
['PageContainer', 'Sayfa alanı', '□'],
|
|||
|
|
['TwoColumns', 'İki kolon', '▥'],
|
|||
|
|
['SidebarContent', 'Sidebar + içerik', '◧'],
|
|||
|
|
['HeaderContent', 'Üst alan + içerik', '⊟'],
|
|||
|
|
].map(([name, label, icon]) => (
|
|||
|
|
<button
|
|||
|
|
key={name}
|
|||
|
|
className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
|
|||
|
|
type="button"
|
|||
|
|
onClick={(event) => {
|
|||
|
|
event.stopPropagation()
|
|||
|
|
onDropComponent?.(name, null)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<span className="mb-1 block text-xl text-sky-500">{icon}</span>
|
|||
|
|
{label}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default VisualCanvas
|