Claude güncellemesi ile Custom Component

This commit is contained in:
Sedat ÖZTÜRK 2026-08-07 00:19:34 +03:00
parent 24eb249f9f
commit ac4ccd76d6
10 changed files with 1286 additions and 876 deletions

View file

@ -1,6 +1,7 @@
import React from 'react'
import React, { useMemo } from 'react'
import DynamicRenderer from './DynamicRenderer'
import { useComponents } from '@/contexts/ComponentContext'
import { parseComponentDependencies } from '@/contexts/componentRuntime'
import { Loading } from '../shared'
export interface ComponentPreviewProps {
@ -11,12 +12,17 @@ export interface ComponentPreviewProps {
const ComponentPreview: React.FC<ComponentPreviewProps> = ({ componentName, className = '' }) => {
const { components, loading } = useComponents()
// Referans olarak sabit tutulmalı: DynamicRenderer bunu effect bağımlılığı olarak kullanıyor.
const dependencies = useMemo(() => {
const component = components?.find((item) => item.name === componentName && item.isActive)
return parseComponentDependencies(component?.dependencies)
}, [components, componentName])
if (!componentName) {
return <div className="text-sm text-gray-500 dark:text-gray-400">Bileşen ismi yok.</div>
}
// components dizisinin varlığını kontrol et
if (loading || !components || !Array.isArray(components)) {
if (loading || !Array.isArray(components)) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
@ -26,26 +32,6 @@ const ComponentPreview: React.FC<ComponentPreviewProps> = ({ componentName, clas
)
}
// Belirtilen bileşeni bul
const component = components.find((c) => c.name === componentName && c.isActive)
let dependencies: string[] = []
if (component?.dependencies) {
try {
// JSON string mi?
if (component.dependencies.startsWith('[')) {
dependencies = JSON.parse(component.dependencies)
} else {
// Virgülle ayrılmış düz metin
dependencies = component.dependencies.split(',').map((d) => d.trim())
}
} catch (err) {
console.error('Dependency parse hatası:', err)
dependencies = []
}
}
return (
<div className={`bg-white dark:bg-gray-900 ${className}`}>
<DynamicRenderer componentName={componentName} dependencies={dependencies} />

View file

@ -1,25 +1,36 @@
import React, { useEffect, useState } from 'react'
import * as Babel from '@babel/standalone'
import axios from 'axios'
import DOMPurify from 'dompurify'
import apiService from '@/services/api.service'
import * as UiKit from '@/components/ui'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import type { DesignerDocument } from '@/components/visualDesigner/types'
import type { CustomComponent } from '@/proxy/developerKit/models'
import {
getComponentRuntimeCode,
parseComponentDependencies,
toErrorMessage,
type BabelLike,
} from '@/contexts/componentRuntime'
import { useComponents } from '../../contexts/ComponentContext'
import ErrorBoundary from './ErrorBoundary'
import PlatformViewHost from './PlatformViewHost'
import { toast } from '../ui'
import { Loading } from '../shared'
const compileComponent = (code: string, scope: Record<string, any> = {}) => {
type RuntimeModule = { exports: { default?: React.ComponentType<any> } }
const compileComponent = (
code: string,
scope: Record<string, any>,
Babel: BabelLike,
): React.ComponentType<any> | undefined => {
const transpiled = Babel.transform(code, {
filename: 'component.tsx',
presets: ['typescript', 'react'],
plugins: ['transform-modules-commonjs'],
}).code
const module = { exports: {} }
if (!transpiled) throw new Error('Boş derleme çıktısı')
const module: RuntimeModule = { exports: {} }
const require = (moduleName: string) => {
if (moduleName === 'react') return React
if (moduleName === 'axios') return axios
@ -28,18 +39,21 @@ const compileComponent = (code: string, scope: Record<string, any> = {}) => {
throw new Error(`Modül bulunamadı: ${moduleName}`)
}
const scopedEval = new Function(
'module',
'exports',
'require',
...Object.keys(scope),
transpiled!,
)
const scopedEval = new Function('module', 'exports', 'require', ...Object.keys(scope), transpiled)
scopedEval(module, module.exports, require, ...Object.values(scope))
const compiledModule = module.exports as any
return compiledModule.default
return module.exports.default
}
// The UI kit is spread as-is: every exported component is available to runtime
// code without an extra per-component entry here.
const staticComponents: Record<string, any> = {
...UiKit,
PlatformViewHost,
toast,
apiService,
DOMPurify,
UiKit,
}
interface DynamicRendererProps {
@ -47,147 +61,102 @@ interface DynamicRendererProps {
dependencies?: string[]
}
const staticComponents: Record<string, any> = {
...UiKit,
Alert: React.lazy(() => import('../ui/Alert')),
Avatar: React.lazy(() => import('../ui/Avatar')),
Badge: React.lazy(() => import('../ui/Badge')),
Button: React.lazy(() => import('../ui/Button')),
Calendar: React.lazy(() => import('../ui/Calendar')),
Card: React.lazy(() => import('../ui/Card')),
Checkbox: React.lazy(() => import('../ui/Checkbox')),
ConfigProvider: React.lazy(() => import('../ui/ConfigProvider')),
DatePicker: React.lazy(() => import('../ui/DatePicker')),
Dialog: React.lazy(() => import('../ui/Dialog')),
Drawer: React.lazy(() => import('../ui/Drawer')),
Dropdown: React.lazy(() => import('../ui/Dropdown')),
FormItem: React.lazy(() => import('../ui/Form/FormItem')),
FormContainer: React.lazy(() => import('../ui/Form/FormContainer')),
Input: React.lazy(() => import('../ui/Input')),
InputGroup: React.lazy(() => import('../ui/InputGroup')),
Menu: React.lazy(() => import('../ui/Menu')),
MenuItem: React.lazy(() => import('../ui/MenuItem')),
Notification: React.lazy(() => import('../ui/Notification')),
Pagination: React.lazy(() => import('../ui/Pagination')),
Progress: React.lazy(() => import('../ui/Progress')),
Radio: React.lazy(() => import('../ui/Radio')),
RangeCalendar: React.lazy(() => import('../ui/RangeCalendar')),
ScrollBar: React.lazy(() => import('../ui/ScrollBar')),
Segment: React.lazy(() => import('../ui/Segment')),
Select: React.lazy(() => import('../ui/Select')),
Skeleton: React.lazy(() => import('../ui/Skeleton')),
Spinner: React.lazy(() => import('../ui/Spinner')),
Steps: React.lazy(() => import('../ui/Steps')),
Switcher: React.lazy(() => import('../ui/Switcher')),
Table: React.lazy(() => import('../ui/Table')),
Tabs: React.lazy(() => import('../ui/Tabs')),
Tag: React.lazy(() => import('../ui/Tag')),
TimeInput: React.lazy(() => import('../ui/TimeInput')),
Timeline: React.lazy(() => import('../ui/Timeline')),
Tooltip: React.lazy(() => import('../ui/Tooltip')),
Upload: React.lazy(() => import('../ui/Upload')),
PlatformViewHost: React.lazy(() => import('./PlatformViewHost')),
toast,
apiService,
DOMPurify,
UiKit,
}
const getRuntimeCode = (component: CustomComponent) => {
if (!component) return ''
try {
const props = component.props ? JSON.parse(component.props) : null
const document = props?.visualDesigner as DesignerDocument | undefined
if (
document?.version === 1 &&
document.sourceMode === 'visual' &&
Array.isArray(document.nodes)
) {
return generateDesignerCode(component.name, document)
}
} catch (error) {
console.warn(`Visual designer schema could not be read for ${component.name}:`, error)
}
return component.code
}
const DynamicRenderer: React.FC<DynamicRendererProps> = ({
componentName,
dependencies: externalDeps,
}) => {
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null)
const [error, setError] = useState<string | null>(null)
const { getComponentByName, components } = useComponents()
useEffect(() => {
let cancelled = false
setComponent(null)
setError(null)
const storedComponent = getComponentByName(componentName)
if (!storedComponent) return
if (!storedComponent) {
setError(`Component ${componentName} not found`)
return
}
const map = new Map(
components.map((c) => {
// Parse dependencies from JSON string
let componentDeps: string[] = []
try {
componentDeps = c.dependencies ? JSON.parse(c.dependencies) : []
} catch {
componentDeps = []
const build = async () => {
// Babel is several megabytes; load it only when something must be compiled.
const Babel = (await import('@babel/standalone')) as unknown as BabelLike
if (cancelled) return
const map = new Map(
components.map((component) => [
component.name,
{
code: getComponentRuntimeCode(component),
dependencies: parseComponentDependencies(component.dependencies),
},
]),
)
const compiled: Record<string, any> = {}
const compileWithDependencies = (name: string): any => {
if (compiled[name]) return compiled[name]
const entry = map.get(name)
if (!entry) {
if (staticComponents[name]) {
compiled[name] = staticComponents[name]
return staticComponents[name]
}
throw new Error(`Component ${name} not found`)
}
return [
c.name,
{
name: c.name,
jsx_code: getRuntimeCode(c),
dependencies: Array.isArray(componentDeps) ? componentDeps : [],
},
]
}),
)
const depNames = name === componentName && externalDeps ? externalDeps : entry.dependencies
const compiled: Record<string, any> = {}
const deps: Record<string, any> = {}
for (const dep of depNames) {
deps[dep] = compileWithDependencies(dep)
}
const compileWithDependencies = (name: string): any => {
if (compiled[name]) return compiled[name]
const component = compileComponent(
entry.code,
{ React, ...staticComponents, ...deps },
Babel,
)
if (!component) throw new Error(`Component ${name} bir default export döndürmüyor`)
const entry = map.get(name)
if (!entry && staticComponents[name]) {
compiled[name] = staticComponents[name]
return staticComponents[name]
}
if (!entry) throw new Error(`Component ${name} not found`)
const depNames =
name === componentName && externalDeps ? externalDeps : entry.dependencies || []
const deps: Record<string, any> = {}
for (const dep of depNames) {
deps[dep] = compileWithDependencies(dep)
compiled[name] = component
return component
}
const comp = compileComponent(entry.jsx_code, {
React,
...staticComponents,
...deps,
})
compiled[name] = comp
return comp
try {
const RootComponent = compileWithDependencies(componentName)
if (!cancelled) setComponent(() => RootComponent)
} catch (err) {
console.error('Compilation error:', err)
if (!cancelled) setError(toErrorMessage(err))
}
}
try {
const RootComponent = compileWithDependencies(componentName)
setComponent(() => RootComponent)
} catch (err: any) {
console.error('Compilation error:', err)
void build()
return () => {
cancelled = true
}
}, [componentName, externalDeps, components, getComponentByName])
if (error) {
return (
<div className="p-4 m-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300">
<div className="font-semibold text-sm">{componentName}</div>
<div className="text-sm whitespace-pre-wrap">{error}</div>
</div>
)
}
if (!Component)
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<Loading loading={!Component} />
<Loading loading={true} />
</div>
</div>
)

View file

@ -6,8 +6,17 @@ import PlatformViewHost, {
import { useLocalization } from '@/utils/hooks/useLocalization'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import {
fromDesignerDate,
getDesignerCollectionProperty,
getDesignerTabSlot,
getDesignerValueByPath,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
normalizeDesignerKeyList,
resolveDesignerDropdownTitle,
resolveDesignerTabValue,
toDesignerDate,
type DesignerBinding,
type DesignerNode,
} from './types'
@ -15,18 +24,14 @@ import {
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
const getTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const resolveStaticLanguageKeys = (
value: unknown,
translate: (key: string) => string,
): unknown => {
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value
if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate))
if (value && typeof value === 'object') {
// Only plain objects are walked: `Object.entries` on a Date yields nothing and
// would silently turn it into `{}`.
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
resolveStaticLanguageKeys(item, translate),
]),
Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
)
}
return value
@ -38,6 +43,8 @@ interface VisualCanvasProps {
interactive?: boolean
onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
/** Moves an existing node into a container/table cell, or to the root. */
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
onMove?: (id: string, direction: -1 | 1) => void
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
onDuplicate?: (id: string) => void
@ -149,6 +156,30 @@ const toSelectOptions = (
})
}
/**
* 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>>) : []
const getPreviewProps = (
node: DesignerNode,
dataValues: Record<string, unknown>,
@ -184,12 +215,16 @@ const getPreviewProps = (
props[propertyName] = getBindingValue(binding, dataValues, currentItem)
}
})
if (
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type)
) {
const collectionProperty = ['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type)
? 'items'
: 'options'
// Stored as ISO strings; pickers only accept real Date instances.
if (isDesignerDateComponent(node.type)) {
Object.keys(props).forEach((propertyName) => {
if (isDesignerDateProperty(node.type, propertyName)) {
props[propertyName] = toDesignerDate(props[propertyName])
}
})
}
if (isDesignerOptionComponent(node.type)) {
const collectionProperty = getDesignerCollectionProperty(node.type)
if (node.type === 'Select') {
const legacyAliases: Record<string, string> = {
clearable: 'isClearable',
@ -203,6 +238,7 @@ const getPreviewProps = (
}
delete props[legacyName]
})
Object.assign(props, getSelectMenuProps(props))
}
const optionsBinding = node.bindings?.[collectionProperty]
props[collectionProperty] = toSelectOptions(
@ -226,13 +262,11 @@ const getPreviewProps = (
}
}
const chainHandler = (
eventName: string,
update: (...args: unknown[]) => void,
) => {
const storedHandler = typeof props[eventName] === 'function'
? (props[eventName] as (...args: unknown[]) => void)
: undefined
const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => {
const storedHandler =
typeof props[eventName] === 'function'
? (props[eventName] as (...args: unknown[]) => void)
: undefined
props[eventName] = (...args: unknown[]) => {
update(...args)
storedHandler?.(...args)
@ -266,6 +300,8 @@ const getPreviewProps = (
chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')]))
} else if (node.type === 'Dropdown') {
chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? '')))
} else if (node.type === 'Tabs') {
chainHandler('onChange', (value) => updateProp('value', String(value ?? '')))
} else if (node.type === 'Radio.Group') {
chainHandler('onChange', (value) => updateProp('value', value))
} else if (node.type === 'Pagination') {
@ -280,16 +316,18 @@ const getPreviewProps = (
)
} else if (node.type === 'MenuItem') {
chainHandler('onSelect', () => updateProp('isActive', true))
} else if (isDesignerDateComponent(node.type)) {
// A Date is not JSON serialisable, so the picked value is kept as an ISO
// string — otherwise it is lost on the next render/save.
chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value)))
} else if ('checked' in props) {
chainHandler('onChange', (value, originalEvent) => {
const checked =
originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent
? Boolean(
(originalEvent as { target?: { checked?: unknown } }).target?.checked,
)
? 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)
? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
: Boolean(value)
updateProp('checked', checked)
})
} else if ('value' in props) {
@ -541,7 +579,8 @@ const renderElement = (
React.Children.count(children) > 0
? children
: boundChildren === null || boundChildren === undefined
? (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) || undefined
? (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) ||
undefined
: React.isValidElement(boundChildren)
? boundChildren
: typeof boundChildren === 'object'
@ -581,6 +620,13 @@ const renderElement = (
? (dropdownProps.items as Array<Record<string, unknown>>)
: []
delete dropdownProps.items
// Without this the toggle keeps showing the static title and the component
// looks untouched after a selection.
dropdownProps.title = resolveDesignerDropdownTitle(
options,
dropdownProps.activeKey,
dropdownProps.title,
)
return (
<UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}>
{options.map((option, index) => (
@ -596,6 +642,8 @@ const renderElement = (
</UiKit.Dropdown>
)
}
// Built in NodeView so each tab can own its own children and drop target.
if (node.type === 'Tabs') return <>{children}</>
if (node.type === 'Radio.Group') {
const groupProps = { ...props }
const options = Array.isArray(groupProps.items)
@ -639,10 +687,13 @@ const renderElement = (
const NodeView = ({
node,
index,
siblingCount = 1,
isRoot = false,
selectedId,
interactive,
onSelect,
onDropComponent,
onMoveIntoContainer,
onMove,
onReorder,
onDuplicate,
@ -654,10 +705,13 @@ const NodeView = ({
}: {
node: DesignerNode
index: number
siblingCount?: number
isRoot?: boolean
selectedId: string | null
interactive: boolean
onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
onMove?: (id: string, direction: -1 | 1) => void
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
onDuplicate?: (id: string) => void
@ -669,6 +723,10 @@ const NodeView = ({
}) => {
const { translate } = useLocalization()
const selected = interactive && selectedId === node.id
// A nested node can always move: at the edge of its container it is lifted out.
// Only the first/last node at the root has nowhere left to go.
const canMoveUp = !isRoot || index > 0
const canMoveDown = !isRoot || index < siblingCount - 1
const acceptsDroppedChildren = [
'PageContainer',
'TwoColumns',
@ -678,6 +736,8 @@ const NodeView = ({
'div',
'Card',
'FormContainer',
// Dropped components land in whichever tab is open.
'Tabs',
].includes(node.type)
const staticChildren = node.props.children
const hasStaticChildren =
@ -685,8 +745,7 @@ const NodeView = ({
staticChildren !== null &&
(typeof staticChildren !== 'string' || staticChildren.length > 0)
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
const hasVisibleChildren =
node.children.length > 0 || hasStaticChildren || hasChildrenBinding
const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
const boundItems = itemsBinding?.sourceId
? getBindingValue(itemsBinding, dataValues, currentItem)
@ -710,6 +769,7 @@ const NodeView = ({
key={`${child.id}_${itemIndex}`}
node={child}
index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
@ -717,6 +777,7 @@ const NodeView = ({
currentItem={childItem}
onSelect={onSelect}
onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
@ -725,6 +786,99 @@ const NodeView = ({
/>
)),
)
const tabsContent = (() => {
if (node.type !== 'Tabs') return null
const tabsProps = getPreviewProps(node, dataValues, currentItem, onNodePropChange, translate)
const options = getTabOptions(tabsProps)
delete tabsProps.items
const activeValue = resolveDesignerTabValue(options, tabsProps.value)
tabsProps.value = activeValue
// Children created before per-tab slots existed belong to the first tab.
const firstSlot = options.length ? getDesignerTabSlot(String(options[0]?.value ?? 0)) : ''
return (
<UiKit.Tabs {...(tabsProps as React.ComponentProps<typeof UiKit.Tabs>)}>
<UiKit.Tabs.TabList>
{options.map((option, index) => (
<UiKit.Tabs.TabNav
key={String(option.value ?? index)}
disabled={Boolean(option.disabled)}
value={String(option.value ?? index)}
>
{String(option.label ?? option.value ?? `Sekme ${index + 1}`)}
</UiKit.Tabs.TabNav>
))}
</UiKit.Tabs.TabList>
{options.map((option, index) => {
const tabValue = String(option.value ?? index)
const slot = getDesignerTabSlot(tabValue)
const tabNodes = node.children.filter((child) => (child.slot || firstSlot) === slot)
return (
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
<div
className="min-h-14 py-2"
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
if (!raw) return
try {
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
}
}}
>
{option.content ? <div>{String(option.content)}</div> : null}
{tabNodes.map((child, childIndex) => (
<NodeView
key={child.id}
node={child}
index={childIndex}
siblingCount={tabNodes.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={dataValues}
currentItem={currentItem}
onSelect={onSelect}
onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
))}
{interactive && !tabNodes.length && !option.content && (
<div className="flex min-h-12 items-center justify-center rounded border border-dashed border-slate-300 px-2 text-center text-[10px] text-slate-400 dark:border-slate-700">
Bu sekme için komponent bırakın
</div>
)}
</div>
</UiKit.Tabs.TabContent>
)
})}
</UiKit.Tabs>
)
})()
const tableContent =
node.type === 'Table' ? (
<UiKit.Table
@ -765,7 +919,10 @@ const NodeView = ({
if (!interactive) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
// Must match the source's effectAllowed, otherwise the
// browser rejects the drop and never fires onDrop.
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'move' ? 'move' : 'copy'
}}
onDrop={(event) => {
if (!interactive) return
@ -779,6 +936,10 @@ const NodeView = ({
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
// Lets a component be dragged back into a cell, from
// another cell or from anywhere else on the canvas.
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
@ -791,6 +952,7 @@ const NodeView = ({
key={child.id}
node={child}
index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
@ -798,6 +960,7 @@ const NodeView = ({
currentItem={currentItem}
onSelect={onSelect}
onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
@ -821,65 +984,12 @@ const NodeView = ({
</UiKit.Table>
) : null
const renderedChildren =
node.type === 'Table'
? tableContent
: node.type === 'Grid' && interactive && node.children.length === 0
? repeatedItems.length
? [
<GridDataTablePreview
key={`grid_data_${node.id}`}
borderlessRow={Boolean(node.props.borderlessRow)}
compact={Boolean(node.props.compact)}
hoverable={node.props.hoverable !== false}
items={repeatedItems}
overflow={node.props.overflow !== false}
selectedColumns={
Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: undefined
}
/>,
]
: [
<GridColumnHeaders
key={`grid_headers_${node.id}`}
columns={
Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: []
}
/>,
]
: node.type === 'Grid' &&
!interactive &&
itemsBinding?.sourceId &&
effectiveBoundItems === undefined
? [
<div
key={`grid_loading_${node.id}`}
className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
>
Veriler yükleniyor
</div>,
]
: node.type === 'Grid' &&
itemsBinding?.sourceId &&
effectiveBoundItems !== undefined &&
!Array.isArray(effectiveBoundItems)
? [
<div
key={`grid_binding_error_${node.id}`}
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
>
Grid items bağlantısı bir koleksiyon döndürmelidir. Seçili path:{' '}
<code>{itemsBinding.path || '(root)'}</code>
</div>,
]
: node.type === 'Grid' && node.children.length === 0 && repeatedItems.length
node.type === 'Tabs'
? tabsContent
: node.type === 'Table'
? tableContent
: node.type === 'Grid' && interactive && node.children.length === 0
? repeatedItems.length
? [
<GridDataTablePreview
key={`grid_data_${node.id}`}
@ -897,7 +1007,62 @@ const NodeView = ({
}
/>,
]
: children
: [
<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
const contentChildren =
interactive && node.type === 'Card' && node.children.length === 0
? [
@ -941,15 +1106,17 @@ const NodeView = ({
if (!interactive) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'move'
// Must match the source's effectAllowed, otherwise the browser rejects
// the drop and never fires onDrop.
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
if (!raw) return
const payload = JSON.parse(raw)
if (payload.source === 'canvas' && payload.nodeId !== node.id) {
@ -969,8 +1136,9 @@ const NodeView = ({
<FaGripVertical /> {node.type}
</span>
<button
className="p-1.5 hover:bg-sky-700"
title="Yukarı taşı"
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canMoveUp}
title={canMoveUp ? 'Yukarı taşı' : 'Zaten en üstte'}
type="button"
onClick={(event) => {
event.stopPropagation()
@ -980,8 +1148,9 @@ const NodeView = ({
<FaArrowUp />
</button>
<button
className="p-1.5 hover:bg-sky-700"
title="Aşağı taşı"
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canMoveDown}
title={canMoveDown ? 'Aşağı taşı' : 'Zaten en altta'}
type="button"
onClick={(event) => {
event.stopPropagation()
@ -1029,10 +1198,12 @@ const NodeView = ({
renderCustomComponent,
)}
</PreviewBoundary>
{/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */}
{interactive &&
!hasVisibleChildren &&
acceptsDroppedChildren &&
node.type !== 'Card' && (
node.type !== 'Card' &&
node.type !== 'Tabs' && (
<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>
@ -1048,6 +1219,7 @@ const VisualCanvas = ({
interactive = true,
onSelect,
onDropComponent,
onMoveIntoContainer,
onMove,
onReorder,
onDuplicate,
@ -1067,11 +1239,15 @@ const VisualCanvas = ({
if (!interactive) return
event.preventDefault()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
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)
if (payload.source === 'library') {
onDropComponent?.(payload.name, null)
} else if (payload.source === 'canvas' && payload.nodeId) {
// Dropping on empty canvas takes the node out of its container.
onMoveIntoContainer?.(payload.nodeId, null)
}
}}
>
{nodes.length ? (
@ -1079,14 +1255,17 @@ const VisualCanvas = ({
{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}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}

View file

@ -1,6 +1,15 @@
import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/componentDefinitions'
import generatedComponentProps from './generated/componentProps.json'
import type { DesignerComponentDefinition, DesignerNodeKind } from './types'
import {
DESIGNER_DATA_COMPONENT_NAMES,
getDesignerCollectionProperty,
isDesignerOptionComponent,
isDesignerTabularComponent,
type DesignerComponentDefinition,
type DesignerNodeKind,
} from './types'
export { DESIGNER_DATA_COMPONENT_NAMES }
interface GeneratedProperty {
name: string
@ -19,15 +28,46 @@ interface GeneratedComponentMetadata {
const generatedMetadata = generatedComponentProps as Record<string, GeneratedComponentMetadata>
const CONTAINER_NAMES = new Set(['div', 'Card', 'FormContainer', 'Table', 'Tabs', 'Timeline'])
export const DESIGNER_DATA_COMPONENT_NAMES = new Set([
'AutoComplete',
'Dropdown',
'Grid',
'Menu',
'Pagination',
'Radio.Group',
'Select',
const getToolboxGroup = (name: string): DesignerComponentDefinition['toolboxGroup'] =>
name === 'Table' ? 'layout' : DESIGNER_DATA_COMPONENT_NAMES.has(name) ? 'data' : 'ui'
/** Components intentionally kept out of the toolbox. */
const EXCLUDED_COMPONENTS = new Set([
'Alert',
'Breadcrumb',
'Dialog',
'Drawer',
'FormContainer',
'FormItem',
'InputGroup',
'MenuItem',
'Segment',
'Steps',
])
interface PropertyOverride {
/** Seed value that makes a freshly dropped component render something useful. */
value?: unknown
/** Corrects an editor type the metadata generator could not infer. */
type?: GeneratedProperty['type']
options?: string[]
}
const PROPERTY_OVERRIDES: Record<string, Record<string, PropertyOverride>> = {
// `Exclude<Shape, 'none'> | 'square'` collapses to a single option.
Avatar: { shape: { options: ['round', 'circle', 'square'], value: 'circle' } },
Chips: { value: { value: ['Deneme', 'Deneme2'] } },
// ImageViewer renders nothing without images, and its clickable gallery only
// appears when `children` is set — so a dropped component would be invisible.
ImageViewer: {
images: {
value: [
{ src: 'https://picsum.photos/id/1015/1200/800', alt: 'Görsel 1', caption: 'Görsel 1' },
{ src: 'https://picsum.photos/id/1025/1200/800', alt: 'Görsel 2', caption: 'Görsel 2' },
],
},
children: { value: 'Galeri' },
},
Marquee: { gradient: { value: false }, speed: { value: 100 } },
}
const HTML_UI_DUPLICATES = new Set(['input', 'button', 'textarea', 'select', 'checkbox'])
const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
Select: {
@ -48,6 +88,7 @@ export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
Pagination: ['onChange'],
'Radio.Group': ['onChange'],
Select: ['onChange'],
Tabs: ['onChange'],
}
const DESIGNER_EVENT_SNIPPETS: Record<string, Record<string, string>> = {
@ -152,6 +193,7 @@ const TABLE_DIMENSION_PROPERTIES = [
category: 'properties' as const,
description: 'Table içindeki statik satır sayısı',
tsType: 'number',
options: undefined,
required: false,
},
{
@ -161,6 +203,7 @@ const TABLE_DIMENSION_PROPERTIES = [
category: 'properties' as const,
description: 'Her satırdaki statik hücre sayısı',
tsType: 'number',
options: undefined,
required: false,
},
]
@ -182,6 +225,24 @@ const MENU_ITEMS_PROPERTY = {
name: 'items',
description: 'Menu items; statik olarak veya endpoint koleksiyonundan beslenebilir',
}
const TABS_ITEMS_PROPERTY = {
...SELECT_OPTIONS_PROPERTY,
name: 'items',
value: [
{ label: 'Sekme 1', value: '1', content: 'Sekme 1 içeriği' },
{ label: 'Sekme 2', value: '2', content: 'Sekme 2 içeriği' },
],
description:
'Sekmeler; statik olarak veya endpoint koleksiyonundan beslenebilir. content alanı sekme içeriğini yazar.',
tsType: 'Array<{ label: string; value: unknown; content?: string }>',
}
/** Seed definition for a data component's collection property. */
const getCollectionPropertyDefinition = (name: string, collectionProperty: string) => {
if (isDesignerTabularComponent(name)) return COLLECTION_ITEMS_PROPERTY
if (name === 'Tabs') return TABS_ITEMS_PROPERTY
return collectionProperty === 'items' ? MENU_ITEMS_PROPERTY : SELECT_OPTIONS_PROPERTY
}
const normalizeDefinition = (
definition: (typeof CUSTOM_COMPONENTS)[number],
@ -193,8 +254,7 @@ const normalizeDefinition = (
? definition.properties.filter((property) => property.name !== 'children')
: definition.properties
let properties =
definition.name === 'Grid' &&
!sourceProperties.some((property) => property.name === 'items')
definition.name === 'Grid' && !sourceProperties.some((property) => property.name === 'items')
? [COLLECTION_ITEMS_PROPERTY, ...sourceProperties]
: sourceProperties
if (definition.name === 'Grid' && !properties.some((property) => property.name === 'compact')) {
@ -264,19 +324,26 @@ const inferUiCategory = (name: string) => {
const generatedPropertiesToDesigner = (
generated: GeneratedComponentMetadata,
existingProperties = new Map<string, (typeof CUSTOM_COMPONENTS)[number]['properties'][number]>(),
componentName = '',
) =>
generated.properties.map((property) => {
const existing = existingProperties.get(property.name)
const override = PROPERTY_OVERRIDES[componentName]?.[property.name]
const type = override?.type || property.type
return {
name: property.name,
type: property.type,
type,
value:
property.defaultValue !== undefined
? property.defaultValue
: existing?.value !== undefined
? existing.value
: fallbackValue(property.type),
options: property.options || existing?.options,
override && 'value' in override
? override.value
: property.defaultValue !== undefined
? property.defaultValue
: existing?.value !== undefined
? existing.value
: fallbackValue(type),
options: override?.type
? override.options
: override?.options || property.options || existing?.options,
description: property.tsType,
category:
property.type === 'function'
@ -295,15 +362,7 @@ const normalizeUiDefinition = (
): DesignerComponentDefinition => {
const generated = generatedMetadata[definition.name]
if (!generated) {
return normalizeDefinition(
definition,
'ui',
definition.name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
? 'data'
: 'ui',
)
return normalizeDefinition(definition, 'ui', getToolboxGroup(definition.name))
}
const propertyAliases = UI_PROPERTY_ALIASES[definition.name] || {}
@ -319,28 +378,25 @@ const normalizeUiDefinition = (
name: propertyAliases[property.name] || property.name,
}))
const existingProperties = new Map(curatedProperties.map((property) => [property.name, property]))
const generatedProperties = generatedPropertiesToDesigner(generated, existingProperties)
const generatedProperties = generatedPropertiesToDesigner(
generated,
existingProperties,
definition.name,
)
// Every data component must expose its collection property so the data panel
// can drive it from static values or an endpoint.
const collectionProperty = getDesignerCollectionProperty(definition.name)
if (
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(
definition.name,
) &&
!generatedProperties.some(
(property) =>
property.name ===
(['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(definition.name)
? 'items'
: 'options'),
)
isDesignerOptionComponent(definition.name) &&
!generatedProperties.some((property) => property.name === collectionProperty)
) {
generatedProperties.unshift(
['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(definition.name)
? MENU_ITEMS_PROPERTY
: SELECT_OPTIONS_PROPERTY,
getCollectionPropertyDefinition(definition.name, collectionProperty),
)
}
if (
definition.name === 'Grid' &&
!generatedProperties.some((property) => property.name === 'items')
isDesignerTabularComponent(definition.name) &&
!generatedProperties.some((property) => property.name === collectionProperty)
) {
generatedProperties.unshift(COLLECTION_ITEMS_PROPERTY)
}
@ -365,12 +421,7 @@ const normalizeUiDefinition = (
return {
...definition,
kind: 'ui',
toolboxGroup:
definition.name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
? 'data'
: 'ui',
toolboxGroup: getToolboxGroup(definition.name),
acceptsChildren: CONTAINER_NAMES.has(definition.name),
sourceFile: generated.sourceFile,
interfaceName: generated.interfaceName,
@ -518,28 +569,35 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
const html = HTML_ELEMENTS.filter((definition) => !HTML_UI_DUPLICATES.has(definition.name)).map(
(definition) => normalizeDefinition(definition, 'html', 'html'),
)
const ui = CUSTOM_COMPONENTS.map(normalizeUiDefinition)
const ui = CUSTOM_COMPONENTS.filter(
(definition) => !EXCLUDED_COMPONENTS.has(definition.name),
).map(normalizeUiDefinition)
const definedUiNames = new Set(CUSTOM_COMPONENTS.map((definition) => definition.name))
const generatedOnlyUi: DesignerComponentDefinition[] = Object.entries(generatedMetadata)
.filter(([name]) => !definedUiNames.has(name))
.map(([name, metadata]) => ({
name,
icon: 'Blocks',
category: inferUiCategory(name),
kind: 'ui',
toolboxGroup:
name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(name)
? 'data'
: 'ui',
description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI componenti`,
sourceFile: metadata.sourceFile,
interfaceName: metadata.interfaceName,
acceptsChildren: CONTAINER_NAMES.has(name),
properties: generatedPropertiesToDesigner(metadata),
hooks: [],
}))
.filter(([name]) => !definedUiNames.has(name) && !EXCLUDED_COMPONENTS.has(name))
.map(([name, metadata]) => {
const properties = generatedPropertiesToDesigner(metadata, undefined, name)
const collectionProperty = getDesignerCollectionProperty(name)
if (
DESIGNER_DATA_COMPONENT_NAMES.has(name) &&
!properties.some((property) => property.name === collectionProperty)
) {
properties.unshift(getCollectionPropertyDefinition(name, collectionProperty))
}
return {
name,
icon: 'Blocks',
category: inferUiCategory(name),
kind: 'ui' as const,
toolboxGroup: getToolboxGroup(name),
description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI componenti`,
sourceFile: metadata.sourceFile,
interfaceName: metadata.interfaceName,
acceptsChildren: CONTAINER_NAMES.has(name),
properties,
hooks: [],
}
})
const custom: DesignerComponentDefinition[] = customNames.map((name) => ({
name,
icon: 'Blocks',

View file

@ -1,4 +1,14 @@
import { normalizeDesignerKeyList, type DesignerDocument, type DesignerNode } from './types'
import {
getDesignerCollectionProperty,
getDesignerTabSlotValue,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER,
type DesignerDocument,
type DesignerNode,
} from './types'
const safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
@ -7,9 +17,7 @@ const safeIdentifier = (value: string) => {
const staticValueExpression = (value: unknown): string => {
if (typeof value === 'string') {
return value.startsWith('::')
? `translate(${JSON.stringify(value)})`
: JSON.stringify(value)
return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
}
if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]`
if (value && typeof value === 'object') {
@ -52,6 +60,7 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
Select: { propertyName: 'value', eventName: 'onChange' },
Steps: { propertyName: 'current', eventName: 'onChange' },
Tabs: { propertyName: 'value', eventName: 'onChange' },
Upload: { propertyName: 'fileList', eventName: 'onChange' },
}
const fallback =
@ -84,6 +93,8 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
) || null
} else if (node.type === 'Menu') {
initialValue = normalizeDesignerKeyList(initialValue)
} else if (node.type === 'Tabs') {
initialValue = initialValue === undefined || initialValue === null ? '' : String(initialValue)
} else if (node.type === 'Pagination') {
initialValue = Number(initialValue) || 1
} else if (initialValue === undefined) {
@ -100,11 +111,38 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
}
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
(['Select', 'AutoComplete'].includes(node.type) && propertyName === 'options') ||
(['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type) &&
propertyName === 'items')
isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type)
const propsToCode = (node: DesignerNode, itemVariable?: string) => {
/**
* Options expression for a data component: the endpoint binding mapped through
* `toSelectOptions` (honouring the configured label/value columns), otherwise the
* static collection maintained in the designer.
*/
const optionCollectionExpression = (node: DesignerNode, itemVariable?: string) => {
const collectionProperty = getDesignerCollectionProperty(node.type)
const binding = node.bindings?.[collectionProperty]
const boundExpression = binding?.sourceId
? bindingExpression(node, collectionProperty, itemVariable)
: ''
if (boundExpression) {
return `toSelectOptions(${boundExpression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
}
const staticOptions = node.props[collectionProperty]
return staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
}
/** Node stripped of its collection prop/binding; those are rendered as children. */
const withoutCollection = (node: DesignerNode) => {
const collectionProperty = getDesignerCollectionProperty(node.type)
const props = { ...node.props }
const bindings = { ...(node.bindings || {}) }
delete props[collectionProperty]
delete bindings[collectionProperty]
return { node: { ...node, props, bindings }, props, bindings }
}
const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: string[] = []) => {
const omitted = new Set(omitProperties)
const runtimeState = getRuntimeStateSpec(node)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim())
@ -113,31 +151,39 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
([key, value]) =>
key !== 'children' &&
key !== runtimeState?.propertyName &&
!omitted.has(key) &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
!node.bindings?.[key]?.sourceId &&
value !== '' &&
value !== undefined,
)
.map(([key, value]) => {
return `${key}=${serializeValue(value)}`
})
.map(([key, value]) =>
// Dates live in the document as ISO strings; pickers need Date instances.
isDesignerDateProperty(node.type, key)
? `${key}={toDesignerDate(${staticValueExpression(value)})}`
: `${key}=${serializeValue(value)}`,
)
Object.keys(node.bindings || {}).forEach((propertyName) => {
if (propertyName === 'children') return
const expression = bindingExpression(node, propertyName, itemVariable)
if (expression) {
const propertyExpression =
isOptionCollectionProperty(node, propertyName)
? `toSelectOptions(${expression}, ${JSON.stringify(node.bindings.options?.labelPath || '')}, ${JSON.stringify(node.bindings.options?.valuePath || '')})`
: expression
// Label/value columns come from the bound property itself; the collection
// is named `items` on some data components and `options` on others.
const binding = node.bindings?.[propertyName]
const propertyExpression = isOptionCollectionProperty(node, propertyName)
? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
: expression
props.push(`${propertyName}={${propertyExpression}}`)
}
})
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
if (runtimeState) props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
if (runtimeState && !omitted.has(runtimeState.propertyName)) {
props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
}
const handlerNames = new Set(
Object.entries(node.events)
@ -149,7 +195,10 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`),
)
return props.length ? ` ${props.join(' ')}` : ''
// Spread first so anything configured on the node overrides the defaults.
const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : []
const allProps = [...leading, ...props]
return allProps.length ? ` ${allProps.join(' ')}` : ''
}
const indent = (text: string, level: number) =>
@ -199,10 +248,7 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin
if (node.type === 'Table') {
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
const columnCount = Math.min(
20,
Math.max(1, Math.floor(Number(node.props.columnCount) || 3)),
)
const columnCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.columnCount) || 3)))
const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}`
@ -337,75 +383,118 @@ ${indent(')}', level + 1)}`
}
if (node.type === 'Menu') {
const { items: staticOptions = [], variant: _variant, ...menuProps } = node.props
const { node: menuNode, props: menuProps } = withoutCollection(node)
delete menuProps.variant
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const { items: optionsBinding, ...menuBindings } = node.bindings || {}
const menuNode = { ...node, props: menuProps, bindings: menuBindings }
const menuPropsCode = propsToCode(menuNode, itemVariable)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
const optionsExpression = optionCollectionExpression(node, itemVariable)
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
}
if (node.type === 'Dropdown') {
const { items: staticOptions = [], ...dropdownProps } = node.props
const { items: optionsBinding, ...dropdownBindings } = node.bindings || {}
const dropdownPropsCode = propsToCode(
{ ...node, props: dropdownProps, bindings: dropdownBindings },
itemVariable,
)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
const { node: dropdownNode, props: dropdownProps, bindings } = withoutCollection(node)
// Rendered from the active option below, so `title` must not also be emitted
// by propsToCode — whether it is static or bound to an endpoint field.
const boundTitle = bindings.title?.sourceId
? bindingExpression(node, 'title', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
return `${indent(`<UiKit.Dropdown${dropdownPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '')
delete dropdownProps.title
delete bindings.title
const dropdownPropsCode = propsToCode(dropdownNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
const activeKeyExpression =
getRuntimeStateSpec(node)?.stateName ||
bindingExpression(node, 'activeKey', itemVariable) ||
staticValueExpression(node.props.activeKey ?? '')
const titleExpression = `toDropdownTitle(${optionsExpression}, ${activeKeyExpression}, ${fallbackTitleExpression})`
return `${indent(`<UiKit.Dropdown${dropdownPropsCode} title={${titleExpression}}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
}
if (node.type === 'Pagination') {
const { items: staticOptions = [], ...paginationProps } = node.props
const { items: optionsBinding, ...paginationBindings } = node.bindings || {}
const paginationPropsCode = propsToCode(
{ ...node, props: paginationProps, bindings: paginationBindings },
itemVariable,
)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
const { node: paginationNode } = withoutCollection(node)
const paginationPropsCode = propsToCode(paginationNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
return indent(
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
level,
)
}
if (node.type === 'Tabs') {
const { node: tabsNode } = withoutCollection(node)
// `value` is emitted below, resolved against the (possibly async) tab list.
const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'])
const identifier = safeIdentifier(node.id)
const optionsVariable = `tabItems_${identifier}`
const activeVariable = `tabValue_${identifier}`
const optionVariable = `tabItem_${identifier}`
const activeStateExpression =
getRuntimeStateSpec(node)?.stateName ||
bindingExpression(node, 'value', itemVariable) ||
staticValueExpression(node.props.value ?? '')
const childrenVariable = `tabChildren_${identifier}`
const fallbackVariable = `tabFallback_${identifier}`
// Children are assigned to a tab through a `tab:<value>` slot. Building a
// lookup keyed by tab value keeps this working when the tab list itself is
// only known at runtime (endpoint driven tabs).
const slotGroups = new Map<string, DesignerNode[]>()
node.children.forEach((child) => {
const key = getDesignerTabSlotValue(child.slot)
const group = slotGroups.get(key)
if (group) group.push(child)
else slotGroups.set(key, [child])
})
const fragmentFor = (children: DesignerNode[], fragmentLevel: number) =>
`${indent('<>', fragmentLevel)}\n${children
.map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable))
.join('\n')}\n${indent('</>', fragmentLevel)}`
const slottedEntries = [...slotGroups]
.filter(([key]) => key !== '')
.map(
([key, children]) =>
`${indent(`${JSON.stringify(key)}: (`, level + 2)}\n${fragmentFor(children, level + 3)}\n${indent('),', level + 2)}`,
)
.join('\n')
// Children saved before per-tab slots existed belong to the first tab.
const fallbackChildren = slotGroups.get('') || []
return `${indent('{(() => {', level)}
${indent(`const ${optionsVariable} = ${optionCollectionExpression(node, itemVariable)}`, level + 1)}
${indent(`const ${activeVariable} = toTabValue(${optionsVariable}, ${activeStateExpression})`, level + 1)}
${indent(`const ${childrenVariable} = {`, level + 1)}
${slottedEntries}
${indent('}', level + 1)}
${indent(`const ${fallbackVariable} = (`, level + 1)}
${fallbackChildren.length ? fragmentFor(fallbackChildren, level + 2) : indent('null', level + 2)}
${indent(')', level + 1)}
${indent('return (', level + 1)}
${indent(`<UiKit.Tabs${tabsPropsCode} value={${activeVariable}}>`, level + 2)}
${indent('<UiKit.Tabs.TabList>', level + 3)}
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => <UiKit.Tabs.TabNav key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)} disabled={Boolean(${optionVariable}.disabled)}>{String(${optionVariable}.label ?? ${optionVariable}.value ?? \`Sekme \${tabIndex + 1}\`)}</UiKit.Tabs.TabNav>)}`, level + 4)}
${indent('</UiKit.Tabs.TabList>', level + 3)}
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => (`, level + 3)}
${indent(`<UiKit.Tabs.TabContent key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)}>`, level + 4)}
${indent(`{${optionVariable}.content ? <div>{String(${optionVariable}.content)}</div> : null}`, level + 5)}
${indent(`{${childrenVariable}[String(${optionVariable}.value ?? tabIndex)] ?? null}`, level + 5)}
${indent(`{tabIndex === 0 ? ${fallbackVariable} : null}`, level + 5)}
${indent('</UiKit.Tabs.TabContent>', level + 4)}
${indent('))}', level + 3)}
${indent('</UiKit.Tabs>', level + 2)}
${indent(')', level + 1)}
${indent('})()}', level)}`
}
if (node.type === 'Radio.Group') {
const {
items: staticOptions = [],
checked: _checked,
defaultChecked: _defaultChecked,
readOnly: _readOnly,
...groupProps
} = node.props
const { items: optionsBinding, ...groupBindings } = node.bindings || {}
const groupPropsCode = propsToCode(
{ ...node, props: groupProps, bindings: groupBindings },
itemVariable,
)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
const { node: groupNode, props: groupProps } = withoutCollection(node)
delete groupProps.checked
delete groupProps.defaultChecked
delete groupProps.readOnly
const groupPropsCode = propsToCode(groupNode, itemVariable)
const optionsExpression = optionCollectionExpression(node, itemVariable)
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
}
@ -445,21 +534,27 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const runtimeStateHooks: string[] = []
let hasSelect = false
let hasDataTable = false
let hasDropdown = false
let hasSelectComponent = false
let hasDatePicker = false
let hasTabs = false
const visit = (nodes: DesignerNode[]) => {
nodes.forEach((node) => {
if (
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(
node.type,
)
) {
hasSelect = true
}
if (isDesignerOptionComponent(node.type)) hasSelect = true
if (node.type === 'Select') hasSelectComponent = true
if (node.type === 'Dropdown') hasDropdown = true
if (node.type === 'Grid') hasDataTable = true
if (node.type === 'Tabs') hasTabs = true
if (isDesignerDateComponent(node.type)) hasDatePicker = true
const runtimeState = getRuntimeStateSpec(node)
if (runtimeState) {
// Date pickers hold a Date in state but an ISO string in the document.
const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName)
? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)})`
: JSON.stringify(runtimeState.initialValue)
runtimeStateHooks.push(
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${JSON.stringify(runtimeState.initialValue)})`,
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
)
}
const eventNames = new Set(
@ -478,19 +573,21 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
? `${runtimeState.setterName}(valueOrEvent ?? "")`
: node.type === 'Menu'
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
: node.type === 'Dropdown'
: node.type === 'Tabs'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Pagination'
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
: node.type === 'Steps' || node.type === 'ImageViewer'
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
: node.type === 'Upload'
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
: runtimeState.propertyName === 'checked'
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
: node.type === 'Input' || node.kind === 'html'
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
: `${runtimeState.setterName}(valueOrEvent)`
: node.type === 'Dropdown'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Pagination'
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
: node.type === 'Steps' || node.type === 'ImageViewer'
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
: node.type === 'Upload'
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
: runtimeState.propertyName === 'checked'
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
: node.type === 'Input' || node.kind === 'html'
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
: `${runtimeState.setterName}(valueOrEvent)`
: ''
const eventDeclaration =
node.type === 'Checkbox' && eventName === 'onChange'
@ -520,6 +617,44 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
})
}`
: ''
// Dropdown paints its toggle from `title`; `activeKey` only marks the item
// inside the closed menu, so the label of the active option is resolved here.
const dropdownHelpers = hasDropdown
? ` const toDropdownTitle = (options, activeKey, fallbackTitle) => {
const key = activeKey === undefined || activeKey === null ? "" : String(activeKey)
const selected = key ? options.find((option) => String(option?.value ?? "") === key) : undefined
if (selected) return String(selected.label ?? selected.value ?? key)
return String(fallbackTitle ?? "") || ${JSON.stringify(DESIGNER_DROPDOWN_PLACEHOLDER)}
}`
: ''
// Falls back to the first tab while an endpoint driven tab list is still empty
// or when the stored value no longer exists.
const tabHelpers = hasTabs
? ` const toTabValue = (options, value) => {
const key = value === undefined || value === null ? "" : String(value)
if (key && options.some((option) => String(option?.value ?? "") === key)) return key
return options.length ? String(options[0]?.value ?? "") : ""
}`
: ''
// Pickers check `instanceof Date`, but the document only stores ISO strings.
const dateHelpers = hasDatePicker
? ` const toDesignerDate = (value) => {
if (Array.isArray(value)) return value.map(toDesignerDate)
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value !== "string" || !value.trim()) return null
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}`
: ''
// Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table,
// Card) would cut it off. A body portal keeps the menu fully visible.
const selectMenuHelpers = hasSelectComponent
? ` const selectMenuProps = {
menuPosition: "fixed",
menuPortalTarget: typeof window === "undefined" ? null : window.document.body,
styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) },
}`
: ''
const dataHelpers =
dataSources.length || hasDataTable
? ` const getByPath = (value, path) => {
@ -555,5 +690,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const designerBackup = encodeURIComponent(JSON.stringify(document))
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dropdownHelpers, selectMenuHelpers, dateHelpers, tabHelpers, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
}

View file

@ -42,6 +42,135 @@ export interface DesignerDocument {
dataSources: DesignerDataSource[]
}
/**
* Single source of truth for the toolbox `data` category. Every component listed
* here is managed by the designer's static/endpoint data panel, so adding an
* entry is all that is needed to make a new data component fully configurable.
*
* - `option` label/value pair collections (Select, Dropdown, )
* - `tabular` row collections rendered as selectable columns (Grid)
*/
export type DesignerDataKind = 'option' | 'tabular'
export interface DesignerDataComponentMeta {
dataKind: DesignerDataKind
/** Node prop holding the collection, both for static values and bindings. */
collectionProperty: 'items' | 'options'
}
export const DESIGNER_DATA_COMPONENTS: Record<string, DesignerDataComponentMeta> = {
AutoComplete: { dataKind: 'option', collectionProperty: 'options' },
Dropdown: { dataKind: 'option', collectionProperty: 'items' },
Grid: { dataKind: 'tabular', collectionProperty: 'items' },
Menu: { dataKind: 'option', collectionProperty: 'items' },
Pagination: { dataKind: 'option', collectionProperty: 'items' },
'Radio.Group': { dataKind: 'option', collectionProperty: 'items' },
Select: { dataKind: 'option', collectionProperty: 'options' },
Tabs: { dataKind: 'option', collectionProperty: 'items' },
}
export const DESIGNER_DATA_COMPONENT_NAMES = new Set(Object.keys(DESIGNER_DATA_COMPONENTS))
export const getDesignerDataComponent = (type?: string) =>
type ? DESIGNER_DATA_COMPONENTS[type] : undefined
export const isDesignerDataComponent = (type?: string) => Boolean(getDesignerDataComponent(type))
export const isDesignerOptionComponent = (type?: string) =>
getDesignerDataComponent(type)?.dataKind === 'option'
export const isDesignerTabularComponent = (type?: string) =>
getDesignerDataComponent(type)?.dataKind === 'tabular'
/** Collection prop name; `items` is the fallback for non-data components. */
export const getDesignerCollectionProperty = (type?: string) =>
getDesignerDataComponent(type)?.collectionProperty ?? 'items'
/**
* Date pickers expect real `Date` instances (they check `instanceof Date`), but a
* designer document has to stay JSON serialisable. Dates are therefore stored as
* ISO strings and revived right before rendering.
*/
export const DESIGNER_DATE_COMPONENTS = new Set([
'Calendar',
'DatePicker',
'DatePickerRange',
'DateTimepicker',
'RangeCalendar',
])
const DESIGNER_DATE_PROPERTIES = new Set([
'value',
'defaultValue',
'defaultMonth',
'minDate',
'maxDate',
])
export const isDesignerDateComponent = (type?: string) =>
Boolean(type && DESIGNER_DATE_COMPONENTS.has(type))
export const isDesignerDateProperty = (type?: string, propertyName?: string) =>
isDesignerDateComponent(type) && !!propertyName && DESIGNER_DATE_PROPERTIES.has(propertyName)
/** Stored value (ISO string) → `Date` for rendering. */
export const toDesignerDate = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(toDesignerDate)
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value !== 'string' || !value.trim()) return null
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
/** `Date` from a picker → ISO string for storage. */
export const fromDesignerDate = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(fromDesignerDate)
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString()
return typeof value === 'string' && value.trim() ? value : null
}
/** Tabs children are assigned to a tab through this slot, like Table cells. */
export const DESIGNER_TAB_SLOT_PREFIX = 'tab:'
export const getDesignerTabSlot = (tabValue: string) => `${DESIGNER_TAB_SLOT_PREFIX}${tabValue}`
/** Tab key a child belongs to; empty when it predates per-tab slots. */
export const getDesignerTabSlotValue = (slot?: string) =>
slot?.startsWith(DESIGNER_TAB_SLOT_PREFIX) ? slot.slice(DESIGNER_TAB_SLOT_PREFIX.length) : ''
/**
* Active tab key. Falls back to the first tab whenever nothing is selected yet or
* the stored value is not in the list which is the normal case while endpoint
* driven tabs are still loading.
*/
export const resolveDesignerTabValue = (
options: Array<Record<string, unknown>>,
value: unknown,
) => {
const key = value === undefined || value === null ? '' : String(value)
if (key && options.some((option) => String(option?.value ?? '') === key)) return key
return options.length ? String(options[0]?.value ?? '') : ''
}
export const DESIGNER_DROPDOWN_PLACEHOLDER = 'Seçiniz'
/**
* Dropdown renders its toggle from `title` only; `activeKey` merely highlights an
* item inside the closed menu. Without this the component looks untouched after a
* selection, so the toggle label is resolved from the selected option.
*/
export const resolveDesignerDropdownTitle = (
options: Array<Record<string, unknown>>,
activeKey: unknown,
fallbackTitle: unknown,
) => {
const key = activeKey === undefined || activeKey === null ? '' : String(activeKey)
const selected = key ? options.find((option) => String(option?.value ?? '') === key) : undefined
if (selected) return String(selected.label ?? selected.value ?? key)
const title = fallbackTitle === undefined || fallbackTitle === null ? '' : String(fallbackTitle)
return title || DESIGNER_DROPDOWN_PLACEHOLDER
}
export interface DesignerPropertyInfo extends PropertyInfo {
tsType?: string
required?: boolean
@ -130,9 +259,8 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
if (typeof candidate === 'string') {
const rawValue = candidate.trim()
const jsonValue = rawValue.startsWith('{') && rawValue.endsWith('}')
? rawValue.slice(1, -1).trim()
: rawValue
const jsonValue =
rawValue.startsWith('{') && rawValue.endsWith('}') ? rawValue.slice(1, -1).trim() : rawValue
try {
candidate = JSON.parse(jsonValue)
@ -144,9 +272,7 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
if (!Array.isArray(candidate)) return []
return candidate
.filter((item): item is string | number =>
typeof item === 'string' || typeof item === 'number',
)
.filter((item): item is string | number => typeof item === 'string' || typeof item === 'number')
.map(String)
.filter(Boolean)
}

View file

@ -5,8 +5,7 @@ import type {
} from '@/proxy/developerKit/models'
import { developerKitService } from '@/services/developerKit.service'
import apiService from '@/services/api.service'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import type { DesignerDocument } from '@/components/visualDesigner/types'
import ErrorBoundary from '@/components/componentEditor/ErrorBoundary'
import DOMPurify from 'dompurify'
import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization'
@ -17,7 +16,16 @@ import React, {
useEffect,
useCallback,
useMemo,
useRef,
} from 'react'
import {
buildRuntimeScope,
compileComponentBundle,
getCompilationSignature,
toErrorMessage,
type BabelLike,
type RuntimeComponent,
} from './componentRuntime'
interface ComponentProps {
[key: string]: unknown
@ -27,6 +35,10 @@ interface ComponentContextType {
components: CustomComponent[]
loading: boolean
error: string | null
/** True while the runtime bundle is being (re)compiled. */
compiling: boolean
/** Compilation/initialization errors keyed by component name. */
componentErrors: Record<string, string>
addComponent: (component: CreateUpdateCustomComponentDto) => Promise<void>
updateComponent: (id: string, component: CreateUpdateCustomComponentDto) => Promise<void>
deleteComponent: (id: string) => Promise<void>
@ -34,18 +46,15 @@ interface ComponentContextType {
getComponentByName: (name: string) => CustomComponent | undefined
refreshComponents: () => Promise<void>
refreshComponent: (id: string) => Promise<CustomComponent | undefined>
// Manual registered components
/** Natively implemented components exposed to dynamic routes by name. */
registeredComponents: Record<string, React.ComponentType<unknown>>
registerComponent: (name: string, component: React.ComponentType<unknown>) => void
// Database compiled components
renderComponent: (name: string, props?: ComponentProps) => React.ReactNode
compileAndRender: (code: string, props?: ComponentProps) => React.ReactNode
isComponentRegistered: (name: string) => boolean
getRegisteredComponents: () => string[]
getComponentCode: (name: string) => string | null
}
const ComponentContext = createContext<ComponentContextType | undefined>(undefined)
const componentsChangedChannel =
typeof BroadcastChannel === 'undefined'
? undefined
@ -55,10 +64,7 @@ const notifyComponentsChanged = () => {
componentsChangedChannel?.postMessage({ changedAt: Date.now() })
}
const haveSameComponentRevision = (
current: CustomComponent[],
next: CustomComponent[],
) =>
const haveSameComponentRevision = (current: CustomComponent[], next: CustomComponent[]) =>
current.length === next.length &&
current.every((component, index) => {
const candidate = next[index]
@ -75,68 +81,9 @@ const haveSameComponentRevision = (
)
})
const normalizeComponentCodeForBundle = (code: string) =>
code
// Stored components run inside a shared function scope. Imports are supplied
// by that scope and therefore cannot remain as nested ESM declarations.
.replace(/^\s*import\s+(?:(?:type\s+)?[\s\S]*?\s+from\s+)?['"][^'"\r\n]+['"]\s*;?\s*$/gm, '')
.replace(/\bexport\s+default\s+/g, '')
.trim()
const toJavaScriptIdentifier = (value: string) => {
const identifier = value.replace(/[^A-Za-z0-9_$]/g, '_')
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Component_${identifier}`
}
const getComponentRuntimeCode = (component: CustomComponent) => {
try {
const props = component.props ? JSON.parse(component.props) : null
const document = props?.visualDesigner as DesignerDocument | undefined
if (
document?.version === 1 &&
document.sourceMode === 'visual' &&
Array.isArray(document.nodes)
) {
return generateDesignerCode(component.name, document)
}
} catch (error) {
console.warn(`Visual designer schema could not be read for ${component.name}:`, error)
}
return component.code
}
const HelloWorldComponent: React.ComponentType<unknown> = () =>
React.createElement('div', { className: 'p-6 bg-blue-50 rounded-lg' }, [
React.createElement(
'h1',
{ key: 'title', className: 'text-2xl font-bold text-blue-900 mb-4' },
'Hello World!',
),
React.createElement(
'p',
{ key: 'description', className: 'text-blue-700' },
'Bu manuel kayıtlı bir komponent!',
),
])
const TestListComponent: React.ComponentType<unknown> = () =>
React.createElement('div', { className: 'p-6 bg-green-50 rounded-lg' }, [
React.createElement(
'h1',
{ key: 'title', className: 'text-2xl font-bold text-green-900 mb-4' },
'Test List',
),
React.createElement(
'p',
{ key: 'description', className: 'text-green-700' },
'Bu da test için kayıtlı komponent!',
),
])
const defaultRegisteredComponents: Record<string, React.ComponentType<unknown>> = {
HelloWorld: HelloWorldComponent,
TestList: TestListComponent,
}
const EMPTY_REGISTRY: Record<string, RuntimeComponent> = {}
const EMPTY_NATIVE_REGISTRY: Record<string, React.ComponentType<unknown>> = {}
const EMPTY_ERRORS: Record<string, string> = {}
// eslint-disable-next-line react-refresh/only-export-components
export const useComponents = () => {
@ -151,13 +98,26 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const { translate } = useLocalization()
const applicationConfig = useStoreState((state) => state.abpConfig?.config)
const extraProperties = applicationConfig?.extraProperties
const [components, setComponents] = useState<CustomComponent[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [registeredComponents, setRegisteredComponents] = useState(defaultRegisteredComponents)
const [compiledComponents, setCompiledComponents] = useState<
Record<string, React.ComponentType<ComponentProps>>
>({})
const [compiling, setCompiling] = useState(false)
const [registeredComponents, setRegisteredComponents] =
useState<Record<string, React.ComponentType<unknown>>>(EMPTY_NATIVE_REGISTRY)
const [compiledComponents, setCompiledComponents] =
useState<Record<string, RuntimeComponent>>(EMPTY_REGISTRY)
const [componentErrors, setComponentErrors] = useState<Record<string, string>>(EMPTY_ERRORS)
// Keeps `translate` usable inside compiled components without forcing a
// recompile every time the localization store updates.
const translateRef = useRef(translate)
translateRef.current = translate
const stableTranslate = useCallback(
(...args: Parameters<typeof translate>) => translateRef.current(...args),
[],
)
const refreshComponents = useCallback(async () => {
if (!applicationConfig) return
@ -169,19 +129,11 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
haveSameComponentRevision(current, nextComponents) ? current : nextComponents,
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch components')
setError(toErrorMessage(err))
console.error('Failed to fetch components:', err)
}
}, [applicationConfig])
useEffect(() => {
const initialComponents =
(extraProperties?.customComponents as CustomComponentDto[] | undefined) || []
setComponents((current) =>
haveSameComponentRevision(current, initialComponents) ? current : initialComponents,
)
}, [extraProperties])
const refreshComponent = useCallback(
async (id: string): Promise<CustomComponent | undefined> => {
if (!applicationConfig) return undefined
@ -197,7 +149,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
})
return freshComponent
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch component')
setError(toErrorMessage(err))
console.error('Failed to fetch component:', err)
return undefined
}
@ -205,12 +157,21 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
[applicationConfig],
)
// Components delivered with the application configuration are the initial set.
useEffect(() => {
const initialComponents =
(extraProperties?.customComponents as CustomComponentDto[] | undefined) || []
setComponents((current) =>
haveSameComponentRevision(current, initialComponents) ? current : initialComponents,
)
}, [extraProperties])
// Keeps every open tab in sync after a component is created/updated/deleted.
useEffect(() => {
const handleComponentsChanged = () => void refreshComponents()
componentsChangedChannel?.addEventListener('message', handleComponentsChanged)
return () =>
componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
return () => componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
}, [refreshComponents])
const addComponent = useCallback(async (componentData: CreateUpdateCustomComponentDto) => {
@ -221,7 +182,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setComponents((prev) => [...prev, newComponent])
notifyComponentsChanged()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create component')
setError(toErrorMessage(err))
throw err
} finally {
setLoading(false)
@ -239,7 +200,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
)
notifyComponentsChanged()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update component')
setError(toErrorMessage(err))
throw err
} finally {
setLoading(false)
@ -256,7 +217,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setComponents((prev) => prev.filter((component) => component.id !== id))
notifyComponentsChanged()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete component')
setError(toErrorMessage(err))
throw err
} finally {
setLoading(false)
@ -271,15 +232,21 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
() => new Map(components.map((component) => [component.name, component])),
[components],
)
const activeComponentsByName = useMemo(
const compilableComponents = useMemo(
() =>
new Map(
components
.filter((component) => component.isActive)
.map((component) => [component.name, component]),
components.filter(
(component) =>
component.isActive && typeof component.name === 'string' && !!component.name.trim(),
),
[components],
)
// Only code/name/designer-schema changes require a recompile; description or
// audit field updates must not throw the compiled bundle away.
const compilationSignature = useMemo(
() => getCompilationSignature(compilableComponents),
[compilableComponents],
)
const getComponent = useCallback((id: string) => componentsById.get(id), [componentsById])
@ -294,134 +261,24 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
)
}, [])
// Component compilation functions (moved from ComponentRegistryProvider)
const extractComponentInfo = useCallback((code: string, defaultName = '') => {
try {
const fcTypeMatch = code.match(/const\s+([A-Za-z]\w*)\s*:\s*React\.FC/)
if (fcTypeMatch) return fcTypeMatch[1]
const componentsToCompileRef = useRef(compilableComponents)
componentsToCompileRef.current = compilableComponents
const functionMatch = code.match(/function\s+([A-Za-z]\w*)/)
if (functionMatch) return functionMatch[1]
const arrowMatch = code.match(/const\s+([A-Za-z]\w*)\s*=/)
if (arrowMatch) return arrowMatch[1]
const classMatch = code.match(/class\s+([A-Za-z]\w*)/)
if (classMatch) return classMatch[1]
const exportMatch = code.match(/export\s+default\s+([A-Za-z]\w*)/)
if (exportMatch) return exportMatch[1]
if (defaultName) return defaultName
return 'DynamicComponent'
} catch (err) {
console.error('Error extracting component name:', err)
return defaultName || 'DynamicComponent'
}
}, [])
// Compile components when they change
useEffect(() => {
if (!components.length) {
setCompiledComponents({})
if (!compilationSignature) {
setCompiledComponents(EMPTY_REGISTRY)
setComponentErrors(EMPTY_ERRORS)
setCompiling(false)
return
}
let cancelled = false
setCompiling(true)
const compileComponents = async () => {
const compile = async () => {
try {
const activeComponents = components.filter(
(component) => component.isActive && typeof component.name === 'string' && component.name.trim(),
)
const invalidActiveComponents = components.filter(
(component) => component.isActive && (typeof component.name !== 'string' || !component.name.trim()),
)
if (invalidActiveComponents.length) {
console.warn('Adsız aktif komponent kayıtları derleme dışında bırakıldı.', invalidActiveComponents)
}
if (!activeComponents.length) {
setCompiledComponents({})
return
}
const componentInfos = activeComponents.map((comp, index) => {
const name = comp.name
const nameCapitalized = name.charAt(0).toUpperCase() + name.slice(1)
const runtimeCode = getComponentRuntimeCode(comp)
const referenceName = toJavaScriptIdentifier(nameCapitalized)
return {
name: name,
nameCapitalized: nameCapitalized,
referenceName,
bundleVariable: `__component_${index}`,
internalName: extractComponentInfo(runtimeCode, referenceName),
code: normalizeComponentCodeForBundle(runtimeCode),
}
})
// Create cross-referencing bundle
const componentDeclarations = componentInfos
.map((info) => `let ${info.bundleVariable};`)
.join('\n')
const componentDefinitions = componentInfos
.map((info) => {
const declaredReferences = new Set<string>()
const componentVariables = componentInfos
.filter((other) => other.name !== info.name)
.filter((other) => {
if (
other.referenceName === info.internalName ||
declaredReferences.has(other.referenceName)
) {
return false
}
declaredReferences.add(other.referenceName)
return true
})
.map(
(other) =>
`const ${other.referenceName} = (componentProps) => React.createElement(${other.bundleVariable}, componentProps);`,
)
.join('\n ')
return `
${info.bundleVariable} = (function() {
${componentVariables}
${info.code}
return ${info.internalName};
})();`
})
.join('\n')
const componentBundle = componentDeclarations + '\n' + componentDefinitions
const bundledCode = `
(function(React, AutoComplete, Alert, Avatar, Badge, Button, Calendar, Card, Checkbox, ConfigProvider, DatePicker, Dialog, Drawer, Dropdown, FormItem, FormContainer, Input, InputGroup, Menu, MenuItem, Notification, Pagination, Progress, Radio, RangeCalendar, ScrollBar, Segment, Select, Skeleton, Spinner, Steps, Switcher, Table, Tabs, Tag, TimeInput, Timeline, toast, Tooltip, Upload, PlatformViewHost, apiService, DOMPurify, axios, UiKit, translate) {
const { useState, useEffect, useCallback, useMemo, useRef, createContext, useContext } = React;
const componentRegistry = {};
${componentBundle}
${componentInfos
.map(
(info) => `
componentRegistry[${JSON.stringify(info.name)}] = ${info.bundleVariable};
componentRegistry[${JSON.stringify(info.nameCapitalized)}] = ${info.bundleVariable};
`,
)
.join('\n')}
return componentRegistry;
})(React, AutoComplete, Alert, Avatar, Badge, Button, Calendar, Card, Checkbox, ConfigProvider, DatePicker, Dialog, Drawer, Dropdown, FormItem, FormContainer, Input, InputGroup, Menu, MenuItem, Notification, Pagination, Progress, Radio, RangeCalendar, ScrollBar, Segment, Select, Skeleton, Spinner, Steps, Switcher, Table, Tabs, Tag, TimeInput, Timeline, toast, Tooltip, Upload, PlatformViewHost, apiService, DOMPurify, axios, UiKit, translate)
`
// Babel is several megabytes and is only needed when an active runtime
// component exists. Keep it out of the application startup bundle.
// Babel and the UI kit are only needed when an active runtime component
// exists. Keep them out of the application startup bundle.
const [Babel, ui, { default: axios }, { default: PlatformViewHost }] = await Promise.all([
import('@babel/standalone'),
import('../components/ui'),
@ -430,218 +287,86 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
])
if (cancelled) return
const {
AutoComplete,
Alert,
Avatar,
Badge,
Button,
Calendar,
Card,
Checkbox,
ConfigProvider,
DatePicker,
Dialog,
Drawer,
Dropdown,
FormItem,
FormContainer,
Input,
InputGroup,
Menu,
MenuItem,
Notification,
Pagination,
Progress,
Radio,
RangeCalendar,
ScrollBar,
Segment,
Select,
Skeleton,
Spinner,
Steps,
Switcher,
Table,
Tabs,
Tag,
TimeInput,
Timeline,
toast,
Tooltip,
Upload,
} = ui
const compiledBundle = Babel.transform(bundledCode, {
presets: ['react', 'typescript'],
filename: 'components-bundle.tsx',
}).code
if (!compiledBundle) {
throw new Error('Failed to compile components bundle')
}
const componentsFactory = new Function(
'React',
'AutoComplete',
'Alert',
'Avatar',
'Badge',
'Button',
'Calendar',
'Card',
'Checkbox',
'ConfigProvider',
'DatePicker',
'Dialog',
'Drawer',
'Dropdown',
'FormItem',
'FormContainer',
'Input',
'InputGroup',
'Menu',
'MenuItem',
'Notification',
'Pagination',
'Progress',
'Radio',
'RangeCalendar',
'ScrollBar',
'Segment',
'Select',
'Skeleton',
'Spinner',
'Steps',
'Switcher',
'Table',
'Tabs',
'Tag',
'TimeInput',
'Timeline',
'toast',
'Tooltip',
'Upload',
'PlatformViewHost',
'apiService',
'DOMPurify',
'axios',
'UiKit',
'translate',
`return ${compiledBundle}`,
)
const compiledComponentsRegistry = componentsFactory(
const scope = buildRuntimeScope({
React,
AutoComplete,
Alert,
Avatar,
Badge,
Button,
Calendar,
Card,
Checkbox,
ConfigProvider,
DatePicker,
Dialog,
Drawer,
Dropdown,
FormItem,
FormContainer,
Input,
InputGroup,
Menu,
MenuItem,
Notification,
Pagination,
Progress,
Radio,
RangeCalendar,
ScrollBar,
Segment,
Select,
Skeleton,
Spinner,
Steps,
Switcher,
Table,
Tabs,
Tag,
TimeInput,
Timeline,
toast,
Tooltip,
Upload,
...ui,
UiKit: ui,
PlatformViewHost,
apiService,
DOMPurify,
axios,
ui,
translate,
)
translate: stableTranslate,
})
if (!cancelled) setCompiledComponents(compiledComponentsRegistry)
} catch (error) {
console.error('Error compiling components bundle:', error)
if (!cancelled) setCompiledComponents({})
const { registry, errors } = compileComponentBundle(
componentsToCompileRef.current,
scope,
Babel as unknown as BabelLike,
)
if (cancelled) return
setCompiledComponents(registry)
setComponentErrors(errors)
} catch (err) {
console.error('Error compiling components bundle:', err)
if (!cancelled) {
setCompiledComponents(EMPTY_REGISTRY)
setComponentErrors(EMPTY_ERRORS)
}
} finally {
if (!cancelled) setCompiling(false)
}
}
void compileComponents()
void compile()
return () => {
cancelled = true
}
}, [components, extractComponentInfo, translate])
}, [compilationSignature, stableTranslate])
// Render functions
const renderComponent = useCallback(
(name: string, props: ComponentProps = {}) => {
if (compiledComponents[name]) {
const Component = compiledComponents[name]
return <Component {...props} />
const Component = compiledComponents[name]
if (Component) {
// A crash inside a runtime component must not take the shell down.
return (
<ErrorBoundary key={name}>
<Component {...props} />
</ErrorBoundary>
)
}
const component = activeComponentsByName.get(name)
if (!component) {
console.error(`Component not found: ${name}`)
const compileError = componentErrors[name]
if (compileError) {
return (
<div className="p-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700">
<div className="text-sm">Component not found: {name}</div>
<div className="font-semibold text-sm">{name}</div>
<div className="text-sm whitespace-pre-wrap">{compileError}</div>
</div>
)
}
return null
if (compiling) return null
console.error(`Component not found: ${name}`)
return (
<div className="p-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700">
<div className="text-sm">Component not found: {name}</div>
</div>
)
},
[activeComponentsByName, compiledComponents],
[compiledComponents, componentErrors, compiling],
)
const compileAndRender = useCallback((code: string) => {
if (!code?.trim()) return null
// Simplified version - can be extended later
return <div>Code compilation not implemented yet</div>
}, [])
const activeComponentNames = useMemo(
() => new Set(compilableComponents.map((component) => component.name)),
[compilableComponents],
)
const isComponentRegistered = useCallback(
(name: string) => {
return activeComponentsByName.has(name) || !!compiledComponents[name]
},
[activeComponentsByName, compiledComponents],
)
const getRegisteredComponents = useCallback(() => {
const dbComponents = [...activeComponentsByName.keys()]
const compiledNames = Object.keys(compiledComponents)
return [...new Set([...dbComponents, ...compiledNames])]
}, [activeComponentsByName, compiledComponents])
const getComponentCode = useCallback(
(name: string) => {
const component = componentsByName.get(name)
return component ? component.code : null
},
[componentsByName],
(name: string) => activeComponentNames.has(name) || !!compiledComponents[name],
[activeComponentNames, compiledComponents],
)
const value = useMemo<ComponentContextType>(
@ -649,6 +374,8 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
components,
loading,
error,
compiling,
componentErrors,
addComponent,
updateComponent,
deleteComponent,
@ -659,15 +386,14 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
registeredComponents,
registerComponent,
renderComponent,
compileAndRender,
isComponentRegistered,
getRegisteredComponents,
getComponentCode,
}),
[
components,
loading,
error,
compiling,
componentErrors,
addComponent,
updateComponent,
deleteComponent,
@ -678,10 +404,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
registeredComponents,
registerComponent,
renderComponent,
compileAndRender,
isComponentRegistered,
getRegisteredComponents,
getComponentCode,
],
)

Binary file not shown.

View file

@ -22,10 +22,17 @@ import { Helmet } from 'react-helmet'
import { Button } from '@/components/ui'
import Dialog from '@/components/ui/Dialog'
import ComponentEditor from './ComponentEditor'
import { parseComponentDependencies } from '@/contexts/componentRuntime'
const ComponentManager: React.FC = () => {
const { components, loading, updateComponent, deleteComponent, refreshComponents } =
useComponents()
const {
components,
loading,
componentErrors,
updateComponent,
deleteComponent,
refreshComponents,
} = useComponents()
const [searchTerm, setSearchTerm] = useState('')
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all')
const [isRefreshing, setIsRefreshing] = useState(false)
@ -155,7 +162,7 @@ const ComponentManager: React.FC = () => {
onClick={() => void handleRefresh()}
>
<FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} />
Yenile
{translate('::App.Platform.Refresh')}
</Button>
<div>
<Button
@ -200,16 +207,8 @@ const ComponentManager: React.FC = () => {
</div>
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2">
{(() => {
try {
const parsed = JSON.parse(component.dependencies ?? '[]')
return Array.isArray(parsed) && parsed.length > 0
? `${parsed.join(', ')}`
: translate('::App.DeveloperKit.Component.NoDependencies')
} catch {
return translate('::App.DeveloperKit.Component.NoDependencies')
}
})()}
{parseComponentDependencies(component.dependencies).join(', ') ||
translate('::App.DeveloperKit.Component.NoDependencies')}
</p>
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2">
@ -221,6 +220,15 @@ const ComponentManager: React.FC = () => {
{component.description}
</p>
)}
{componentErrors[component.name] && (
<p
className="text-xs text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded px-2 py-1 mb-2 break-words"
title={componentErrors[component.name]}
>
{componentErrors[component.name]}
</p>
)}
</div>
{/* Sağ taraf */}
@ -333,8 +341,8 @@ const ComponentManager: React.FC = () => {
{!searchTerm && filterActive === 'all' && (
<button
type="button"
onClick={() => setEditorComponentId(null)}
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => setEditorComponentId(null)}
>
<FaPlus className="w-4 h-4" />
{translate('::App.DeveloperKit.Component.Empty.Initial.Action')}

View file

@ -6,6 +6,8 @@ import {
FaArrowLeft,
FaBolt,
FaCheckCircle,
FaArrowDown,
FaArrowUp,
FaChevronDown,
FaChevronRight,
FaCode,
@ -46,7 +48,10 @@ import {
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
getDesignerCollectionProperty,
getDesignerValueByPath,
isDesignerOptionComponent,
isDesignerTabularComponent,
resolveDesignerResponse,
walkDesignerNodes,
type DesignerComponentDefinition,
@ -95,21 +100,11 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'disabled',
'loading',
])
const OPTION_DATA_COMPONENT_NAMES = new Set([
'Select',
'AutoComplete',
'Menu',
'Dropdown',
'Pagination',
'Radio.Group',
])
const TABULAR_DATA_COMPONENT_NAMES = new Set(['Grid'])
const isOptionDataComponent = (type?: string) =>
Boolean(type && OPTION_DATA_COMPONENT_NAMES.has(type))
const getOptionDataProperty = (type?: string) =>
['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(type || '') ? 'items' : 'options'
const isTabularDataComponent = (type?: string) =>
Boolean(type && TABULAR_DATA_COMPONENT_NAMES.has(type))
// Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is
// covered by the static/endpoint panel by construction.
const isOptionDataComponent = isDesignerOptionComponent
const getOptionDataProperty = getDesignerCollectionProperty
const isTabularDataComponent = isDesignerTabularComponent
const PROPERTY_EXAMPLE_VALUES: Record<string, unknown> = {
children: 'Örnek içerik',
@ -327,18 +322,6 @@ const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
return result
}
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
const index = nodes.findIndex((node) => node.id === id)
if (index >= 0) {
const target = index + direction
if (target < 0 || target >= nodes.length) return nodes
const next = [...nodes]
;[next[index], next[target]] = [next[target], next[index]]
return next
}
return nodes.map((node) => ({ ...node, children: moveInTree(node.children, id, direction) }))
}
const insertRelativeToNode = (
nodes: DesignerNode[],
targetId: string,
@ -357,6 +340,111 @@ const insertRelativeToNode = (
}))
}
/**
* Moves a node one step up/down. Swapping with a sibling only works while the
* node has one, so a node that sits alone in a container (or at the edge of one)
* is lifted out and placed next to its parent instead otherwise the arrows
* would silently do nothing, which is how they used to behave.
*/
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
const ancestors = findDesignerAncestors(nodes, id)
if (!ancestors) return nodes
const parent = ancestors[ancestors.length - 1]
const siblings = parent ? parent.children : nodes
const index = siblings.findIndex((node) => node.id === id)
if (index < 0) return nodes
// Tabs children live in separate tabs, so only components sharing a tab are
// neighbours — otherwise the arrows would swap with something not on screen.
const group =
parent?.type === 'Tabs'
? siblings.filter((node) => (node.slot || '') === (siblings[index].slot || ''))
: siblings
const groupIndex = group.findIndex((node) => node.id === id)
const targetNode = group[groupIndex + direction]
if (targetNode) {
const target = siblings.findIndex((node) => node.id === targetNode.id)
const next = [...siblings]
;[next[index], next[target]] = [next[target], next[index]]
// In a Table the visible cell comes from `slot`, not from array order, so the
// slots have to stay with the position for the swap to be visible.
if (parent?.type === 'Table') {
next[index] = { ...next[index], slot: siblings[index].slot }
next[target] = { ...next[target], slot: siblings[target].slot }
}
return parent ? updateNodeTree(nodes, parent.id, (node) => ({ ...node, children: next })) : next
}
// Already first/last: at the root there is nowhere left to go.
if (!parent) return nodes
const movingNode = { ...siblings[index], slot: undefined }
const withoutNode = updateNodeTree(nodes, parent.id, (node) => ({
...node,
children: node.children.filter((child) => child.id !== id),
}))
return insertRelativeToNode(
withoutNode,
parent.id,
movingNode,
direction === -1 ? 'before' : 'after',
)
}
/**
* Table cells are addressed by `slot`, and children without one fall back to
* their positional index. Materialising the effective slots keeps every existing
* cell in place while a node is added to or removed from the table.
*/
const withResolvedTableSlots = (table: DesignerNode): DesignerNode => {
const columnCount = normalizeTableDimension(table.props.columnCount, 3)
return {
...table,
children: table.children.map((child, childIndex) => ({
...child,
slot:
child.slot || `table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`,
})),
}
}
const resolveAllTableSlots = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const resolved = { ...node, children: resolveAllTableSlots(node.children) }
return resolved.type === 'Table' ? withResolvedTableSlots(resolved) : resolved
})
/**
* Moves an existing node into a container (or to the root when `parentId` is
* null). Used by drag & drop so a component can leave a Table cell and be put
* back into one.
*/
const moveNodeIntoContainer = (
nodes: DesignerNode[],
nodeId: string,
parentId: string | null,
slot?: string,
): DesignerNode[] => {
const source = findDesignerNode(nodes, nodeId)
if (!source || parentId === nodeId) return nodes
// Dropping a node inside its own subtree would detach that subtree.
if (parentId && findDesignerNode(source.children, parentId)) return nodes
// Pin every table cell before the tree changes, so removing the node does not
// shift the siblings that still rely on their positional fallback.
const pinned = resolveAllTableSlots(nodes)
const movingNode = { ...(findDesignerNode(pinned, nodeId) || source), slot }
const remaining = removeNodeTree(pinned, nodeId)
if (!parentId) return [...remaining, movingNode]
return updateNodeTree(remaining, parentId, (parent) => ({
...parent,
children: [...parent.children, movingNode],
}))
}
const reorderNodeTree = (
nodes: DesignerNode[],
sourceId: string,
@ -714,13 +802,135 @@ const migrateGeneratedCodeToDesignerDocument = (
return document.nodes.length ? document : null
}
/**
* Array-of-object properties otherwise fall back to a raw JSON textarea, which is
* unusable for anything but trivial values. Components listed here get a proper
* row editor instead, keyed by `<component>.<property>`.
*/
const OBJECT_LIST_PROPERTIES: Record<
string,
{ itemLabel: string; fields: Array<{ name: string; label: string; placeholder?: string }> }
> = {
'ImageViewer.images': {
itemLabel: 'Görsel',
fields: [
{ name: 'src', label: 'Görsel URL', placeholder: 'https://…' },
{ name: 'thumbnail', label: 'Küçük görsel URL', placeholder: 'Boşsa görselin kendisi' },
{ name: 'alt', label: 'Alternatif metin', placeholder: 'Erişilebilirlik metni' },
{ name: 'caption', label: 'Açıklama', placeholder: 'Görsel altı yazı' },
],
},
}
const ObjectListEditor = ({
schema,
value,
onChange,
}: {
schema: (typeof OBJECT_LIST_PROPERTIES)[string]
value: unknown
onChange: (value: unknown) => void
}) => {
const items = Array.isArray(value) ? (value as Array<Record<string, unknown>>) : []
const inputClass =
'w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
const replaceItem = (index: number, next: Record<string, unknown>) =>
onChange(items.map((item, itemIndex) => (itemIndex === index ? next : item)))
const move = (index: number, direction: -1 | 1) => {
const target = index + direction
if (target < 0 || target >= items.length) return
const next = [...items]
;[next[index], next[target]] = [next[target], next[index]]
onChange(next)
}
return (
<div className="space-y-2">
{items.map((item, index) => (
<div
key={index}
className="space-y-1.5 rounded-md border border-slate-200 p-2 dark:border-slate-700"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-slate-500">
{schema.itemLabel} {index + 1}
</span>
<div className="flex items-center gap-0.5">
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === 0}
title="Yukarı taşı"
type="button"
onClick={() => move(index, -1)}
>
<FaArrowUp className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === items.length - 1}
title="Aşağı taşı"
type="button"
onClick={() => move(index, 1)}
>
<FaArrowDown className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-red-600"
title="Sil"
type="button"
onClick={() => onChange(items.filter((_, i) => i !== index))}
>
<FaTrash className="h-2.5 w-2.5" />
</button>
</div>
</div>
{item.src ? (
<img
alt=""
className="h-16 w-full rounded border border-slate-200 object-cover dark:border-slate-700"
src={String(item.thumbnail || item.src)}
/>
) : null}
{schema.fields.map((field) => (
<label key={field.name} className="block">
<span className="mb-0.5 block text-[9px] font-medium uppercase tracking-wide text-slate-400">
{field.label}
</span>
<input
className={inputClass}
placeholder={field.placeholder}
type="text"
value={String(item[field.name] ?? '')}
onChange={(event) =>
replaceItem(index, { ...item, [field.name]: event.target.value })
}
/>
</label>
))}
</div>
))}
<button
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700"
type="button"
onClick={() => onChange([...items, { src: '' }])}
>
<FaPlus /> {schema.itemLabel} ekle
</button>
</div>
)
}
const PropertyEditor = ({
componentType,
name,
type,
value,
options,
onChange,
}: {
componentType?: string
name: string
type: string
value: unknown
@ -747,12 +957,16 @@ const PropertyEditor = ({
)
}
if (type === 'select' && options) {
const currentValue = String(value ?? '')
return (
<select
className={inputClass}
value={String(value ?? '')}
value={currentValue}
onChange={(event) => onChange(event.target.value)}
>
{/* An empty value is not emitted, so the component keeps its own or its
container's default spell that out instead of showing a blank row. */}
{!options.includes(currentValue) && <option value="">Varsayılan</option>}
{options.map((option) => (
<option key={option} value={option}>
{option}
@ -761,6 +975,10 @@ const PropertyEditor = ({
</select>
)
}
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
}
if (type === 'object' || type === 'array') {
return (
<textarea
@ -1603,6 +1821,17 @@ const VisualComponentDesigner = () => {
[commitDocument],
)
const moveNodeIntoContainerNode = useCallback(
(nodeId: string, parentId: string | null, slot?: string) => {
commitDocument((current) => ({
...current,
nodes: moveNodeIntoContainer(current.nodes, nodeId, parentId, slot),
}))
selectDesignerNode(nodeId)
},
[commitDocument, selectDesignerNode],
)
const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({
@ -1716,12 +1945,7 @@ const VisualComponentDesigner = () => {
if (event.shiftKey) redo()
else undo()
}
if (
event.key === 'Delete' &&
!isPropertyEditing &&
selectedId &&
workspaceTab === 'design'
) {
if (event.key === 'Delete' && !isPropertyEditing && selectedId && workspaceTab === 'design') {
deleteNode(selectedId)
}
}
@ -2709,6 +2933,7 @@ const VisualComponentDesigner = () => {
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
onDropComponent={addComponent}
onMoveIntoContainer={moveNodeIntoContainerNode}
onMove={moveNode}
onReorder={reorderNode}
onDuplicate={duplicateNode}
@ -3325,6 +3550,7 @@ const VisualComponentDesigner = () => {
</p>
)}
<PropertyEditor
componentType={selectedNode?.type}
name={property.name}
type={property.type}
value={propertyValue}