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 DynamicRenderer from './DynamicRenderer'
import { useComponents } from '@/contexts/ComponentContext' import { useComponents } from '@/contexts/ComponentContext'
import { parseComponentDependencies } from '@/contexts/componentRuntime'
import { Loading } from '../shared' import { Loading } from '../shared'
export interface ComponentPreviewProps { export interface ComponentPreviewProps {
@ -11,12 +12,17 @@ export interface ComponentPreviewProps {
const ComponentPreview: React.FC<ComponentPreviewProps> = ({ componentName, className = '' }) => { const ComponentPreview: React.FC<ComponentPreviewProps> = ({ componentName, className = '' }) => {
const { components, loading } = useComponents() 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) { if (!componentName) {
return <div className="text-sm text-gray-500 dark:text-gray-400">Bileşen ismi yok.</div> 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 || !Array.isArray(components)) {
if (loading || !components || !Array.isArray(components)) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center"> <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 ( return (
<div className={`bg-white dark:bg-gray-900 ${className}`}> <div className={`bg-white dark:bg-gray-900 ${className}`}>
<DynamicRenderer componentName={componentName} dependencies={dependencies} /> <DynamicRenderer componentName={componentName} dependencies={dependencies} />

View file

@ -1,25 +1,36 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import * as Babel from '@babel/standalone'
import axios from 'axios' import axios from 'axios'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import apiService from '@/services/api.service' import apiService from '@/services/api.service'
import * as UiKit from '@/components/ui' import * as UiKit from '@/components/ui'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator' import {
import type { DesignerDocument } from '@/components/visualDesigner/types' getComponentRuntimeCode,
import type { CustomComponent } from '@/proxy/developerKit/models' parseComponentDependencies,
toErrorMessage,
type BabelLike,
} from '@/contexts/componentRuntime'
import { useComponents } from '../../contexts/ComponentContext' import { useComponents } from '../../contexts/ComponentContext'
import ErrorBoundary from './ErrorBoundary' import ErrorBoundary from './ErrorBoundary'
import PlatformViewHost from './PlatformViewHost'
import { toast } from '../ui' import { toast } from '../ui'
import { Loading } from '../shared' 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, { const transpiled = Babel.transform(code, {
filename: 'component.tsx', filename: 'component.tsx',
presets: ['typescript', 'react'], presets: ['typescript', 'react'],
plugins: ['transform-modules-commonjs'], plugins: ['transform-modules-commonjs'],
}).code }).code
const module = { exports: {} } if (!transpiled) throw new Error('Boş derleme çıktısı')
const module: RuntimeModule = { exports: {} }
const require = (moduleName: string) => { const require = (moduleName: string) => {
if (moduleName === 'react') return React if (moduleName === 'react') return React
if (moduleName === 'axios') return axios if (moduleName === 'axios') return axios
@ -28,18 +39,21 @@ const compileComponent = (code: string, scope: Record<string, any> = {}) => {
throw new Error(`Modül bulunamadı: ${moduleName}`) throw new Error(`Modül bulunamadı: ${moduleName}`)
} }
const scopedEval = new Function( const scopedEval = new Function('module', 'exports', 'require', ...Object.keys(scope), transpiled)
'module',
'exports',
'require',
...Object.keys(scope),
transpiled!,
)
scopedEval(module, module.exports, require, ...Object.values(scope)) scopedEval(module, module.exports, require, ...Object.values(scope))
const compiledModule = module.exports as any return module.exports.default
return compiledModule.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 { interface DynamicRendererProps {
@ -47,102 +61,38 @@ interface DynamicRendererProps {
dependencies?: string[] 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> = ({ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
componentName, componentName,
dependencies: externalDeps, dependencies: externalDeps,
}) => { }) => {
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null) const [Component, setComponent] = useState<React.ComponentType<any> | null>(null)
const [error, setError] = useState<string | null>(null)
const { getComponentByName, components } = useComponents() const { getComponentByName, components } = useComponents()
useEffect(() => { useEffect(() => {
let cancelled = false
setComponent(null) setComponent(null)
setError(null)
const storedComponent = getComponentByName(componentName) const storedComponent = getComponentByName(componentName)
if (!storedComponent) return if (!storedComponent) {
setError(`Component ${componentName} not found`)
const map = new Map( return
components.map((c) => {
// Parse dependencies from JSON string
let componentDeps: string[] = []
try {
componentDeps = c.dependencies ? JSON.parse(c.dependencies) : []
} catch {
componentDeps = []
} }
return [ const build = async () => {
c.name, // 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,
{ {
name: c.name, code: getComponentRuntimeCode(component),
jsx_code: getRuntimeCode(c), dependencies: parseComponentDependencies(component.dependencies),
dependencies: Array.isArray(componentDeps) ? componentDeps : [],
}, },
] ]),
}),
) )
const compiled: Record<string, any> = {} const compiled: Record<string, any> = {}
@ -151,43 +101,62 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
if (compiled[name]) return compiled[name] if (compiled[name]) return compiled[name]
const entry = map.get(name) const entry = map.get(name)
if (!entry && staticComponents[name]) { if (!entry) {
if (staticComponents[name]) {
compiled[name] = staticComponents[name] compiled[name] = staticComponents[name]
return staticComponents[name] return staticComponents[name]
} }
if (!entry) throw new Error(`Component ${name} not found`) throw new Error(`Component ${name} not found`)
}
const depNames = const depNames = name === componentName && externalDeps ? externalDeps : entry.dependencies
name === componentName && externalDeps ? externalDeps : entry.dependencies || []
const deps: Record<string, any> = {} const deps: Record<string, any> = {}
for (const dep of depNames) { for (const dep of depNames) {
deps[dep] = compileWithDependencies(dep) deps[dep] = compileWithDependencies(dep)
} }
const comp = compileComponent(entry.jsx_code, { const component = compileComponent(
React, entry.code,
...staticComponents, { React, ...staticComponents, ...deps },
...deps, Babel,
}) )
compiled[name] = comp if (!component) throw new Error(`Component ${name} bir default export döndürmüyor`)
return comp compiled[name] = component
return component
} }
try { try {
const RootComponent = compileWithDependencies(componentName) const RootComponent = compileWithDependencies(componentName)
setComponent(() => RootComponent) if (!cancelled) setComponent(() => RootComponent)
} catch (err: any) { } catch (err) {
console.error('Compilation error:', err) console.error('Compilation error:', err)
if (!cancelled) setError(toErrorMessage(err))
}
}
void build()
return () => {
cancelled = true
} }
}, [componentName, externalDeps, components, getComponentByName]) }, [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) if (!Component)
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center"> <div className="text-center">
<Loading loading={!Component} /> <Loading loading={true} />
</div> </div>
</div> </div>
) )

View file

@ -6,8 +6,17 @@ import PlatformViewHost, {
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa' import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import { import {
fromDesignerDate,
getDesignerCollectionProperty,
getDesignerTabSlot,
getDesignerValueByPath, getDesignerValueByPath,
isDesignerDateComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
normalizeDesignerKeyList, normalizeDesignerKeyList,
resolveDesignerDropdownTitle,
resolveDesignerTabValue,
toDesignerDate,
type DesignerBinding, type DesignerBinding,
type DesignerNode, type DesignerNode,
} from './types' } from './types'
@ -15,18 +24,14 @@ import {
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer' export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
const getTableDimension = (value: unknown, fallback: number) => const getTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback))) Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const resolveStaticLanguageKeys = ( const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
value: unknown,
translate: (key: string) => string,
): unknown => {
if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value
if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate)) 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( return Object.fromEntries(
Object.entries(value).map(([key, item]) => [ Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
key,
resolveStaticLanguageKeys(item, translate),
]),
) )
} }
return value return value
@ -38,6 +43,8 @@ interface VisualCanvasProps {
interactive?: boolean interactive?: boolean
onSelect?: (id: string) => void onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: 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 onMove?: (id: string, direction: -1 | 1) => void
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
onDuplicate?: (id: string) => 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 = ( const getPreviewProps = (
node: DesignerNode, node: DesignerNode,
dataValues: Record<string, unknown>, dataValues: Record<string, unknown>,
@ -184,12 +215,16 @@ const getPreviewProps = (
props[propertyName] = getBindingValue(binding, dataValues, currentItem) props[propertyName] = getBindingValue(binding, dataValues, currentItem)
} }
}) })
if ( // Stored as ISO strings; pickers only accept real Date instances.
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type) if (isDesignerDateComponent(node.type)) {
) { Object.keys(props).forEach((propertyName) => {
const collectionProperty = ['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type) if (isDesignerDateProperty(node.type, propertyName)) {
? 'items' props[propertyName] = toDesignerDate(props[propertyName])
: 'options' }
})
}
if (isDesignerOptionComponent(node.type)) {
const collectionProperty = getDesignerCollectionProperty(node.type)
if (node.type === 'Select') { if (node.type === 'Select') {
const legacyAliases: Record<string, string> = { const legacyAliases: Record<string, string> = {
clearable: 'isClearable', clearable: 'isClearable',
@ -203,6 +238,7 @@ const getPreviewProps = (
} }
delete props[legacyName] delete props[legacyName]
}) })
Object.assign(props, getSelectMenuProps(props))
} }
const optionsBinding = node.bindings?.[collectionProperty] const optionsBinding = node.bindings?.[collectionProperty]
props[collectionProperty] = toSelectOptions( props[collectionProperty] = toSelectOptions(
@ -226,11 +262,9 @@ const getPreviewProps = (
} }
} }
const chainHandler = ( const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => {
eventName: string, const storedHandler =
update: (...args: unknown[]) => void, typeof props[eventName] === 'function'
) => {
const storedHandler = typeof props[eventName] === 'function'
? (props[eventName] as (...args: unknown[]) => void) ? (props[eventName] as (...args: unknown[]) => void)
: undefined : undefined
props[eventName] = (...args: unknown[]) => { props[eventName] = (...args: unknown[]) => {
@ -266,6 +300,8 @@ const getPreviewProps = (
chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')])) chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')]))
} else if (node.type === 'Dropdown') { } else if (node.type === 'Dropdown') {
chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? ''))) 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') { } else if (node.type === 'Radio.Group') {
chainHandler('onChange', (value) => updateProp('value', value)) chainHandler('onChange', (value) => updateProp('value', value))
} else if (node.type === 'Pagination') { } else if (node.type === 'Pagination') {
@ -280,13 +316,15 @@ const getPreviewProps = (
) )
} else if (node.type === 'MenuItem') { } else if (node.type === 'MenuItem') {
chainHandler('onSelect', () => updateProp('isActive', true)) 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) { } else if ('checked' in props) {
chainHandler('onChange', (value, originalEvent) => { chainHandler('onChange', (value, originalEvent) => {
const checked = const checked =
originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent
? Boolean( ? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked)
(originalEvent as { target?: { checked?: unknown } }).target?.checked,
)
: value && typeof value === 'object' && 'target' in value : value && typeof value === 'object' && 'target' in value
? Boolean((value as { target?: { checked?: unknown } }).target?.checked) ? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
: Boolean(value) : Boolean(value)
@ -541,7 +579,8 @@ const renderElement = (
React.Children.count(children) > 0 React.Children.count(children) > 0
? children ? children
: boundChildren === null || boundChildren === undefined : 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) : React.isValidElement(boundChildren)
? boundChildren ? boundChildren
: typeof boundChildren === 'object' : typeof boundChildren === 'object'
@ -581,6 +620,13 @@ const renderElement = (
? (dropdownProps.items as Array<Record<string, unknown>>) ? (dropdownProps.items as Array<Record<string, unknown>>)
: [] : []
delete dropdownProps.items 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 ( return (
<UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}> <UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}>
{options.map((option, index) => ( {options.map((option, index) => (
@ -596,6 +642,8 @@ const renderElement = (
</UiKit.Dropdown> </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') { if (node.type === 'Radio.Group') {
const groupProps = { ...props } const groupProps = { ...props }
const options = Array.isArray(groupProps.items) const options = Array.isArray(groupProps.items)
@ -639,10 +687,13 @@ const renderElement = (
const NodeView = ({ const NodeView = ({
node, node,
index, index,
siblingCount = 1,
isRoot = false,
selectedId, selectedId,
interactive, interactive,
onSelect, onSelect,
onDropComponent, onDropComponent,
onMoveIntoContainer,
onMove, onMove,
onReorder, onReorder,
onDuplicate, onDuplicate,
@ -654,10 +705,13 @@ const NodeView = ({
}: { }: {
node: DesignerNode node: DesignerNode
index: number index: number
siblingCount?: number
isRoot?: boolean
selectedId: string | null selectedId: string | null
interactive: boolean interactive: boolean
onSelect?: (id: string) => void onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: 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 onMove?: (id: string, direction: -1 | 1) => void
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
onDuplicate?: (id: string) => void onDuplicate?: (id: string) => void
@ -669,6 +723,10 @@ const NodeView = ({
}) => { }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const selected = interactive && selectedId === node.id 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 = [ const acceptsDroppedChildren = [
'PageContainer', 'PageContainer',
'TwoColumns', 'TwoColumns',
@ -678,6 +736,8 @@ const NodeView = ({
'div', 'div',
'Card', 'Card',
'FormContainer', 'FormContainer',
// Dropped components land in whichever tab is open.
'Tabs',
].includes(node.type) ].includes(node.type)
const staticChildren = node.props.children const staticChildren = node.props.children
const hasStaticChildren = const hasStaticChildren =
@ -685,8 +745,7 @@ const NodeView = ({
staticChildren !== null && staticChildren !== null &&
(typeof staticChildren !== 'string' || staticChildren.length > 0) (typeof staticChildren !== 'string' || staticChildren.length > 0)
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId) const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
const hasVisibleChildren = const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
node.children.length > 0 || hasStaticChildren || hasChildrenBinding
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
const boundItems = itemsBinding?.sourceId const boundItems = itemsBinding?.sourceId
? getBindingValue(itemsBinding, dataValues, currentItem) ? getBindingValue(itemsBinding, dataValues, currentItem)
@ -710,6 +769,7 @@ const NodeView = ({
key={`${child.id}_${itemIndex}`} key={`${child.id}_${itemIndex}`}
node={child} node={child}
index={childIndex} index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId} selectedId={selectedId}
interactive={interactive} interactive={interactive}
renderCustomComponent={renderCustomComponent} renderCustomComponent={renderCustomComponent}
@ -717,6 +777,7 @@ const NodeView = ({
currentItem={childItem} currentItem={childItem}
onSelect={onSelect} onSelect={onSelect}
onDropComponent={onDropComponent} onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove} onMove={onMove}
onReorder={onReorder} onReorder={onReorder}
onDuplicate={onDuplicate} 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 = const tableContent =
node.type === 'Table' ? ( node.type === 'Table' ? (
<UiKit.Table <UiKit.Table
@ -765,7 +919,10 @@ const NodeView = ({
if (!interactive) return if (!interactive) return
event.preventDefault() event.preventDefault()
event.stopPropagation() 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) => { onDrop={(event) => {
if (!interactive) return if (!interactive) return
@ -779,6 +936,10 @@ const NodeView = ({
const payload = JSON.parse(raw) const payload = JSON.parse(raw)
if (payload.source === 'library') { if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot) 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 { } catch {
// Ignore payloads that do not belong to the visual designer. // Ignore payloads that do not belong to the visual designer.
@ -791,6 +952,7 @@ const NodeView = ({
key={child.id} key={child.id}
node={child} node={child}
index={childIndex} index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId} selectedId={selectedId}
interactive={interactive} interactive={interactive}
renderCustomComponent={renderCustomComponent} renderCustomComponent={renderCustomComponent}
@ -798,6 +960,7 @@ const NodeView = ({
currentItem={currentItem} currentItem={currentItem}
onSelect={onSelect} onSelect={onSelect}
onDropComponent={onDropComponent} onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove} onMove={onMove}
onReorder={onReorder} onReorder={onReorder}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
@ -821,7 +984,9 @@ const NodeView = ({
</UiKit.Table> </UiKit.Table>
) : null ) : null
const renderedChildren = const renderedChildren =
node.type === 'Table' node.type === 'Tabs'
? tabsContent
: node.type === 'Table'
? tableContent ? tableContent
: node.type === 'Grid' && interactive && node.children.length === 0 : node.type === 'Grid' && interactive && node.children.length === 0
? repeatedItems.length ? repeatedItems.length
@ -941,15 +1106,17 @@ const NodeView = ({
if (!interactive) return if (!interactive) return
event.preventDefault() event.preventDefault()
event.stopPropagation() 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) => { onDrop={(event) => {
if (!interactive) return if (!interactive) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const raw = const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
event.dataTransfer.getData('text/plain')
if (!raw) return if (!raw) return
const payload = JSON.parse(raw) const payload = JSON.parse(raw)
if (payload.source === 'canvas' && payload.nodeId !== node.id) { if (payload.source === 'canvas' && payload.nodeId !== node.id) {
@ -969,8 +1136,9 @@ const NodeView = ({
<FaGripVertical /> {node.type} <FaGripVertical /> {node.type}
</span> </span>
<button <button
className="p-1.5 hover:bg-sky-700" className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
title="Yukarı taşı" disabled={!canMoveUp}
title={canMoveUp ? 'Yukarı taşı' : 'Zaten en üstte'}
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
@ -980,8 +1148,9 @@ const NodeView = ({
<FaArrowUp /> <FaArrowUp />
</button> </button>
<button <button
className="p-1.5 hover:bg-sky-700" className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
title="Aşağı taşı" disabled={!canMoveDown}
title={canMoveDown ? 'Aşağı taşı' : 'Zaten en altta'}
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
@ -1029,10 +1198,12 @@ const NodeView = ({
renderCustomComponent, renderCustomComponent,
)} )}
</PreviewBoundary> </PreviewBoundary>
{/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */}
{interactive && {interactive &&
!hasVisibleChildren && !hasVisibleChildren &&
acceptsDroppedChildren && 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"> <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 Bileşeni buraya bırakın
</div> </div>
@ -1048,6 +1219,7 @@ const VisualCanvas = ({
interactive = true, interactive = true,
onSelect, onSelect,
onDropComponent, onDropComponent,
onMoveIntoContainer,
onMove, onMove,
onReorder, onReorder,
onDuplicate, onDuplicate,
@ -1067,11 +1239,15 @@ const VisualCanvas = ({
if (!interactive) return if (!interactive) return
event.preventDefault() event.preventDefault()
const raw = const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
event.dataTransfer.getData('text/plain')
if (!raw) return if (!raw) return
const payload = JSON.parse(raw) 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 ? ( {nodes.length ? (
@ -1079,14 +1255,17 @@ const VisualCanvas = ({
{nodes.map((node, index) => ( {nodes.map((node, index) => (
<NodeView <NodeView
key={node.id} key={node.id}
isRoot
node={node} node={node}
index={index} index={index}
siblingCount={nodes.length}
selectedId={selectedId} selectedId={selectedId}
interactive={interactive} interactive={interactive}
renderCustomComponent={renderCustomComponent} renderCustomComponent={renderCustomComponent}
dataValues={previewDataValues} dataValues={previewDataValues}
onSelect={onSelect} onSelect={onSelect}
onDropComponent={onDropComponent} onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove} onMove={onMove}
onReorder={onReorder} onReorder={onReorder}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}

View file

@ -1,6 +1,15 @@
import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/componentDefinitions' import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/componentDefinitions'
import generatedComponentProps from './generated/componentProps.json' 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 { interface GeneratedProperty {
name: string name: string
@ -19,15 +28,46 @@ interface GeneratedComponentMetadata {
const generatedMetadata = generatedComponentProps as Record<string, GeneratedComponentMetadata> const generatedMetadata = generatedComponentProps as Record<string, GeneratedComponentMetadata>
const CONTAINER_NAMES = new Set(['div', 'Card', 'FormContainer', 'Table', 'Tabs', 'Timeline']) const CONTAINER_NAMES = new Set(['div', 'Card', 'FormContainer', 'Table', 'Tabs', 'Timeline'])
export const DESIGNER_DATA_COMPONENT_NAMES = new Set([ const getToolboxGroup = (name: string): DesignerComponentDefinition['toolboxGroup'] =>
'AutoComplete', name === 'Table' ? 'layout' : DESIGNER_DATA_COMPONENT_NAMES.has(name) ? 'data' : 'ui'
'Dropdown', /** Components intentionally kept out of the toolbox. */
'Grid', const EXCLUDED_COMPONENTS = new Set([
'Menu', 'Alert',
'Pagination', 'Breadcrumb',
'Radio.Group', 'Dialog',
'Select', '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 HTML_UI_DUPLICATES = new Set(['input', 'button', 'textarea', 'select', 'checkbox'])
const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = { const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
Select: { Select: {
@ -48,6 +88,7 @@ export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
Pagination: ['onChange'], Pagination: ['onChange'],
'Radio.Group': ['onChange'], 'Radio.Group': ['onChange'],
Select: ['onChange'], Select: ['onChange'],
Tabs: ['onChange'],
} }
const DESIGNER_EVENT_SNIPPETS: Record<string, Record<string, string>> = { const DESIGNER_EVENT_SNIPPETS: Record<string, Record<string, string>> = {
@ -152,6 +193,7 @@ const TABLE_DIMENSION_PROPERTIES = [
category: 'properties' as const, category: 'properties' as const,
description: 'Table içindeki statik satır sayısı', description: 'Table içindeki statik satır sayısı',
tsType: 'number', tsType: 'number',
options: undefined,
required: false, required: false,
}, },
{ {
@ -161,6 +203,7 @@ const TABLE_DIMENSION_PROPERTIES = [
category: 'properties' as const, category: 'properties' as const,
description: 'Her satırdaki statik hücre sayısı', description: 'Her satırdaki statik hücre sayısı',
tsType: 'number', tsType: 'number',
options: undefined,
required: false, required: false,
}, },
] ]
@ -182,6 +225,24 @@ const MENU_ITEMS_PROPERTY = {
name: 'items', name: 'items',
description: 'Menu items; statik olarak veya endpoint koleksiyonundan beslenebilir', 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 = ( const normalizeDefinition = (
definition: (typeof CUSTOM_COMPONENTS)[number], definition: (typeof CUSTOM_COMPONENTS)[number],
@ -193,8 +254,7 @@ const normalizeDefinition = (
? definition.properties.filter((property) => property.name !== 'children') ? definition.properties.filter((property) => property.name !== 'children')
: definition.properties : definition.properties
let properties = let properties =
definition.name === 'Grid' && definition.name === 'Grid' && !sourceProperties.some((property) => property.name === 'items')
!sourceProperties.some((property) => property.name === 'items')
? [COLLECTION_ITEMS_PROPERTY, ...sourceProperties] ? [COLLECTION_ITEMS_PROPERTY, ...sourceProperties]
: sourceProperties : sourceProperties
if (definition.name === 'Grid' && !properties.some((property) => property.name === 'compact')) { if (definition.name === 'Grid' && !properties.some((property) => property.name === 'compact')) {
@ -264,19 +324,26 @@ const inferUiCategory = (name: string) => {
const generatedPropertiesToDesigner = ( const generatedPropertiesToDesigner = (
generated: GeneratedComponentMetadata, generated: GeneratedComponentMetadata,
existingProperties = new Map<string, (typeof CUSTOM_COMPONENTS)[number]['properties'][number]>(), existingProperties = new Map<string, (typeof CUSTOM_COMPONENTS)[number]['properties'][number]>(),
componentName = '',
) => ) =>
generated.properties.map((property) => { generated.properties.map((property) => {
const existing = existingProperties.get(property.name) const existing = existingProperties.get(property.name)
const override = PROPERTY_OVERRIDES[componentName]?.[property.name]
const type = override?.type || property.type
return { return {
name: property.name, name: property.name,
type: property.type, type,
value: value:
property.defaultValue !== undefined override && 'value' in override
? override.value
: property.defaultValue !== undefined
? property.defaultValue ? property.defaultValue
: existing?.value !== undefined : existing?.value !== undefined
? existing.value ? existing.value
: fallbackValue(property.type), : fallbackValue(type),
options: property.options || existing?.options, options: override?.type
? override.options
: override?.options || property.options || existing?.options,
description: property.tsType, description: property.tsType,
category: category:
property.type === 'function' property.type === 'function'
@ -295,15 +362,7 @@ const normalizeUiDefinition = (
): DesignerComponentDefinition => { ): DesignerComponentDefinition => {
const generated = generatedMetadata[definition.name] const generated = generatedMetadata[definition.name]
if (!generated) { if (!generated) {
return normalizeDefinition( return normalizeDefinition(definition, 'ui', getToolboxGroup(definition.name))
definition,
'ui',
definition.name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
? 'data'
: 'ui',
)
} }
const propertyAliases = UI_PROPERTY_ALIASES[definition.name] || {} const propertyAliases = UI_PROPERTY_ALIASES[definition.name] || {}
@ -319,28 +378,25 @@ const normalizeUiDefinition = (
name: propertyAliases[property.name] || property.name, name: propertyAliases[property.name] || property.name,
})) }))
const existingProperties = new Map(curatedProperties.map((property) => [property.name, property])) const existingProperties = new Map(curatedProperties.map((property) => [property.name, property]))
const generatedProperties = generatedPropertiesToDesigner(generated, existingProperties) const generatedProperties = generatedPropertiesToDesigner(
if ( generated,
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes( existingProperties,
definition.name, definition.name,
) &&
!generatedProperties.some(
(property) =>
property.name ===
(['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(definition.name)
? 'items'
: 'options'),
) )
// 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 (
isDesignerOptionComponent(definition.name) &&
!generatedProperties.some((property) => property.name === collectionProperty)
) { ) {
generatedProperties.unshift( generatedProperties.unshift(
['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(definition.name) getCollectionPropertyDefinition(definition.name, collectionProperty),
? MENU_ITEMS_PROPERTY
: SELECT_OPTIONS_PROPERTY,
) )
} }
if ( if (
definition.name === 'Grid' && isDesignerTabularComponent(definition.name) &&
!generatedProperties.some((property) => property.name === 'items') !generatedProperties.some((property) => property.name === collectionProperty)
) { ) {
generatedProperties.unshift(COLLECTION_ITEMS_PROPERTY) generatedProperties.unshift(COLLECTION_ITEMS_PROPERTY)
} }
@ -365,12 +421,7 @@ const normalizeUiDefinition = (
return { return {
...definition, ...definition,
kind: 'ui', kind: 'ui',
toolboxGroup: toolboxGroup: getToolboxGroup(definition.name),
definition.name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
? 'data'
: 'ui',
acceptsChildren: CONTAINER_NAMES.has(definition.name), acceptsChildren: CONTAINER_NAMES.has(definition.name),
sourceFile: generated.sourceFile, sourceFile: generated.sourceFile,
interfaceName: generated.interfaceName, 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( const html = HTML_ELEMENTS.filter((definition) => !HTML_UI_DUPLICATES.has(definition.name)).map(
(definition) => normalizeDefinition(definition, 'html', 'html'), (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 definedUiNames = new Set(CUSTOM_COMPONENTS.map((definition) => definition.name))
const generatedOnlyUi: DesignerComponentDefinition[] = Object.entries(generatedMetadata) const generatedOnlyUi: DesignerComponentDefinition[] = Object.entries(generatedMetadata)
.filter(([name]) => !definedUiNames.has(name)) .filter(([name]) => !definedUiNames.has(name) && !EXCLUDED_COMPONENTS.has(name))
.map(([name, metadata]) => ({ .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, name,
icon: 'Blocks', icon: 'Blocks',
category: inferUiCategory(name), category: inferUiCategory(name),
kind: 'ui', kind: 'ui' as const,
toolboxGroup: toolboxGroup: getToolboxGroup(name),
name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(name)
? 'data'
: 'ui',
description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI componenti`, description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI componenti`,
sourceFile: metadata.sourceFile, sourceFile: metadata.sourceFile,
interfaceName: metadata.interfaceName, interfaceName: metadata.interfaceName,
acceptsChildren: CONTAINER_NAMES.has(name), acceptsChildren: CONTAINER_NAMES.has(name),
properties: generatedPropertiesToDesigner(metadata), properties,
hooks: [], hooks: [],
})) }
})
const custom: DesignerComponentDefinition[] = customNames.map((name) => ({ const custom: DesignerComponentDefinition[] = customNames.map((name) => ({
name, name,
icon: 'Blocks', 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 safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_') const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
@ -7,9 +17,7 @@ const safeIdentifier = (value: string) => {
const staticValueExpression = (value: unknown): string => { const staticValueExpression = (value: unknown): string => {
if (typeof value === 'string') { if (typeof value === 'string') {
return value.startsWith('::') return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
? `translate(${JSON.stringify(value)})`
: JSON.stringify(value)
} }
if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]` if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]`
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
@ -52,6 +60,7 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
'Radio.Group': { propertyName: 'value', eventName: 'onChange' }, 'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
Select: { propertyName: 'value', eventName: 'onChange' }, Select: { propertyName: 'value', eventName: 'onChange' },
Steps: { propertyName: 'current', eventName: 'onChange' }, Steps: { propertyName: 'current', eventName: 'onChange' },
Tabs: { propertyName: 'value', eventName: 'onChange' },
Upload: { propertyName: 'fileList', eventName: 'onChange' }, Upload: { propertyName: 'fileList', eventName: 'onChange' },
} }
const fallback = const fallback =
@ -84,6 +93,8 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
) || null ) || null
} else if (node.type === 'Menu') { } else if (node.type === 'Menu') {
initialValue = normalizeDesignerKeyList(initialValue) initialValue = normalizeDesignerKeyList(initialValue)
} else if (node.type === 'Tabs') {
initialValue = initialValue === undefined || initialValue === null ? '' : String(initialValue)
} else if (node.type === 'Pagination') { } else if (node.type === 'Pagination') {
initialValue = Number(initialValue) || 1 initialValue = Number(initialValue) || 1
} else if (initialValue === undefined) { } else if (initialValue === undefined) {
@ -100,11 +111,38 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
} }
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) => const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
(['Select', 'AutoComplete'].includes(node.type) && propertyName === 'options') || isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type)
(['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type) &&
propertyName === 'items')
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 runtimeState = getRuntimeStateSpec(node)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId) const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) const hasInputChangeHandler = Boolean(node.events?.onChange?.trim())
@ -113,23 +151,29 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
([key, value]) => ([key, value]) =>
key !== 'children' && key !== 'children' &&
key !== runtimeState?.propertyName && key !== runtimeState?.propertyName &&
!omitted.has(key) &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) && !(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') && !(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
!node.bindings?.[key]?.sourceId && !node.bindings?.[key]?.sourceId &&
value !== '' && value !== '' &&
value !== undefined, value !== undefined,
) )
.map(([key, value]) => { .map(([key, value]) =>
return `${key}=${serializeValue(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) => { Object.keys(node.bindings || {}).forEach((propertyName) => {
if (propertyName === 'children') return if (propertyName === 'children') return
const expression = bindingExpression(node, propertyName, itemVariable) const expression = bindingExpression(node, propertyName, itemVariable)
if (expression) { if (expression) {
const propertyExpression = // Label/value columns come from the bound property itself; the collection
isOptionCollectionProperty(node, propertyName) // is named `items` on some data components and `options` on others.
? `toSelectOptions(${expression}, ${JSON.stringify(node.bindings.options?.labelPath || '')}, ${JSON.stringify(node.bindings.options?.valuePath || '')})` const binding = node.bindings?.[propertyName]
const propertyExpression = isOptionCollectionProperty(node, propertyName)
? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
: expression : expression
props.push(`${propertyName}={${propertyExpression}}`) props.push(`${propertyName}={${propertyExpression}}`)
} }
@ -137,7 +181,9 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}') 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( const handlerNames = new Set(
Object.entries(node.events) Object.entries(node.events)
@ -149,7 +195,10 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`), 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) => const indent = (text: string, level: number) =>
@ -199,10 +248,7 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin
if (node.type === 'Table') { if (node.type === 'Table') {
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2))) const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
const columnCount = Math.min( const columnCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.columnCount) || 3)))
20,
Math.max(1, Math.floor(Number(node.props.columnCount) || 3)),
)
const rows = Array.from({ length: rowCount }, (_, rowIndex) => { const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
const cells = Array.from({ length: columnCount }, (_, columnIndex) => { const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}` const slot = `table:${rowIndex}:${columnIndex}`
@ -337,75 +383,118 @@ ${indent(')}', level + 1)}`
} }
if (node.type === 'Menu') { 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.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys) menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const { items: optionsBinding, ...menuBindings } = node.bindings || {}
const menuNode = { ...node, props: menuProps, bindings: menuBindings }
const menuPropsCode = propsToCode(menuNode, itemVariable) const menuPropsCode = propsToCode(menuNode, itemVariable)
const binding = optionsBinding?.sourceId const optionsExpression = optionCollectionExpression(node, itemVariable)
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
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)}` 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') { if (node.type === 'Dropdown') {
const { items: staticOptions = [], ...dropdownProps } = node.props const { node: dropdownNode, props: dropdownProps, bindings } = withoutCollection(node)
const { items: optionsBinding, ...dropdownBindings } = node.bindings || {} // Rendered from the active option below, so `title` must not also be emitted
const dropdownPropsCode = propsToCode( // by propsToCode — whether it is static or bound to an endpoint field.
{ ...node, props: dropdownProps, bindings: dropdownBindings }, const boundTitle = bindings.title?.sourceId
itemVariable, ? bindingExpression(node, 'title', itemVariable)
)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: '' : ''
const optionsExpression = binding const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '')
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})` delete dropdownProps.title
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : []) delete bindings.title
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 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') { if (node.type === 'Pagination') {
const { items: staticOptions = [], ...paginationProps } = node.props const { node: paginationNode } = withoutCollection(node)
const { items: optionsBinding, ...paginationBindings } = node.bindings || {} const paginationPropsCode = propsToCode(paginationNode, itemVariable)
const paginationPropsCode = propsToCode( const optionsExpression = optionCollectionExpression(node, itemVariable)
{ ...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 : [])
return indent( return indent(
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`, `<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
level, level,
) )
} }
if (node.type === 'Radio.Group') { if (node.type === 'Tabs') {
const { const { node: tabsNode } = withoutCollection(node)
items: staticOptions = [], // `value` is emitted below, resolved against the (possibly async) tab list.
checked: _checked, const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'])
defaultChecked: _defaultChecked, const identifier = safeIdentifier(node.id)
readOnly: _readOnly, const optionsVariable = `tabItems_${identifier}`
...groupProps const activeVariable = `tabValue_${identifier}`
} = node.props const optionVariable = `tabItem_${identifier}`
const { items: optionsBinding, ...groupBindings } = node.bindings || {} const activeStateExpression =
const groupPropsCode = propsToCode( getRuntimeStateSpec(node)?.stateName ||
{ ...node, props: groupProps, bindings: groupBindings }, bindingExpression(node, 'value', itemVariable) ||
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)}`,
) )
const binding = optionsBinding?.sourceId .join('\n')
? bindingExpression(node, 'items', itemVariable) // Children saved before per-tab slots existed belong to the first tab.
: '' const fallbackChildren = slotGroups.get('') || []
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})` return `${indent('{(() => {', level)}
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : []) ${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 { 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)}` 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[] = [] const runtimeStateHooks: string[] = []
let hasSelect = false let hasSelect = false
let hasDataTable = false let hasDataTable = false
let hasDropdown = false
let hasSelectComponent = false
let hasDatePicker = false
let hasTabs = false
const visit = (nodes: DesignerNode[]) => { const visit = (nodes: DesignerNode[]) => {
nodes.forEach((node) => { nodes.forEach((node) => {
if ( if (isDesignerOptionComponent(node.type)) hasSelect = true
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes( if (node.type === 'Select') hasSelectComponent = true
node.type, if (node.type === 'Dropdown') hasDropdown = true
)
) {
hasSelect = true
}
if (node.type === 'Grid') hasDataTable = true if (node.type === 'Grid') hasDataTable = true
if (node.type === 'Tabs') hasTabs = true
if (isDesignerDateComponent(node.type)) hasDatePicker = true
const runtimeState = getRuntimeStateSpec(node) const runtimeState = getRuntimeStateSpec(node)
if (runtimeState) { 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( 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( const eventNames = new Set(
@ -478,6 +573,8 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
? `${runtimeState.setterName}(valueOrEvent ?? "")` ? `${runtimeState.setterName}(valueOrEvent ?? "")`
: node.type === 'Menu' : node.type === 'Menu'
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])` ? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
: node.type === 'Tabs'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Dropdown' : node.type === 'Dropdown'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))` ? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Pagination' : node.type === 'Pagination'
@ -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 = const dataHelpers =
dataSources.length || hasDataTable dataSources.length || hasDataTable
? ` const getByPath = (value, path) => { ? ` const getByPath = (value, path) => {
@ -555,5 +690,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const designerBackup = encodeURIComponent(JSON.stringify(document)) 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[] 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 { export interface DesignerPropertyInfo extends PropertyInfo {
tsType?: string tsType?: string
required?: boolean required?: boolean
@ -130,9 +259,8 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
if (typeof candidate === 'string') { if (typeof candidate === 'string') {
const rawValue = candidate.trim() const rawValue = candidate.trim()
const jsonValue = rawValue.startsWith('{') && rawValue.endsWith('}') const jsonValue =
? rawValue.slice(1, -1).trim() rawValue.startsWith('{') && rawValue.endsWith('}') ? rawValue.slice(1, -1).trim() : rawValue
: rawValue
try { try {
candidate = JSON.parse(jsonValue) candidate = JSON.parse(jsonValue)
@ -144,9 +272,7 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
if (!Array.isArray(candidate)) return [] if (!Array.isArray(candidate)) return []
return candidate return candidate
.filter((item): item is string | number => .filter((item): item is string | number => typeof item === 'string' || typeof item === 'number')
typeof item === 'string' || typeof item === 'number',
)
.map(String) .map(String)
.filter(Boolean) .filter(Boolean)
} }

View file

@ -5,8 +5,7 @@ import type {
} from '@/proxy/developerKit/models' } from '@/proxy/developerKit/models'
import { developerKitService } from '@/services/developerKit.service' import { developerKitService } from '@/services/developerKit.service'
import apiService from '@/services/api.service' import apiService from '@/services/api.service'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator' import ErrorBoundary from '@/components/componentEditor/ErrorBoundary'
import type { DesignerDocument } from '@/components/visualDesigner/types'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
@ -17,7 +16,16 @@ import React, {
useEffect, useEffect,
useCallback, useCallback,
useMemo, useMemo,
useRef,
} from 'react' } from 'react'
import {
buildRuntimeScope,
compileComponentBundle,
getCompilationSignature,
toErrorMessage,
type BabelLike,
type RuntimeComponent,
} from './componentRuntime'
interface ComponentProps { interface ComponentProps {
[key: string]: unknown [key: string]: unknown
@ -27,6 +35,10 @@ interface ComponentContextType {
components: CustomComponent[] components: CustomComponent[]
loading: boolean loading: boolean
error: string | null 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> addComponent: (component: CreateUpdateCustomComponentDto) => Promise<void>
updateComponent: (id: string, component: CreateUpdateCustomComponentDto) => Promise<void> updateComponent: (id: string, component: CreateUpdateCustomComponentDto) => Promise<void>
deleteComponent: (id: string) => Promise<void> deleteComponent: (id: string) => Promise<void>
@ -34,18 +46,15 @@ interface ComponentContextType {
getComponentByName: (name: string) => CustomComponent | undefined getComponentByName: (name: string) => CustomComponent | undefined
refreshComponents: () => Promise<void> refreshComponents: () => Promise<void>
refreshComponent: (id: string) => Promise<CustomComponent | undefined> refreshComponent: (id: string) => Promise<CustomComponent | undefined>
// Manual registered components /** Natively implemented components exposed to dynamic routes by name. */
registeredComponents: Record<string, React.ComponentType<unknown>> registeredComponents: Record<string, React.ComponentType<unknown>>
registerComponent: (name: string, component: React.ComponentType<unknown>) => void registerComponent: (name: string, component: React.ComponentType<unknown>) => void
// Database compiled components
renderComponent: (name: string, props?: ComponentProps) => React.ReactNode renderComponent: (name: string, props?: ComponentProps) => React.ReactNode
compileAndRender: (code: string, props?: ComponentProps) => React.ReactNode
isComponentRegistered: (name: string) => boolean isComponentRegistered: (name: string) => boolean
getRegisteredComponents: () => string[]
getComponentCode: (name: string) => string | null
} }
const ComponentContext = createContext<ComponentContextType | undefined>(undefined) const ComponentContext = createContext<ComponentContextType | undefined>(undefined)
const componentsChangedChannel = const componentsChangedChannel =
typeof BroadcastChannel === 'undefined' typeof BroadcastChannel === 'undefined'
? undefined ? undefined
@ -55,10 +64,7 @@ const notifyComponentsChanged = () => {
componentsChangedChannel?.postMessage({ changedAt: Date.now() }) componentsChangedChannel?.postMessage({ changedAt: Date.now() })
} }
const haveSameComponentRevision = ( const haveSameComponentRevision = (current: CustomComponent[], next: CustomComponent[]) =>
current: CustomComponent[],
next: CustomComponent[],
) =>
current.length === next.length && current.length === next.length &&
current.every((component, index) => { current.every((component, index) => {
const candidate = next[index] const candidate = next[index]
@ -75,68 +81,9 @@ const haveSameComponentRevision = (
) )
}) })
const normalizeComponentCodeForBundle = (code: string) => const EMPTY_REGISTRY: Record<string, RuntimeComponent> = {}
code const EMPTY_NATIVE_REGISTRY: Record<string, React.ComponentType<unknown>> = {}
// Stored components run inside a shared function scope. Imports are supplied const EMPTY_ERRORS: Record<string, string> = {}
// 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,
}
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useComponents = () => { export const useComponents = () => {
@ -151,13 +98,26 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const { translate } = useLocalization() const { translate } = useLocalization()
const applicationConfig = useStoreState((state) => state.abpConfig?.config) const applicationConfig = useStoreState((state) => state.abpConfig?.config)
const extraProperties = applicationConfig?.extraProperties const extraProperties = applicationConfig?.extraProperties
const [components, setComponents] = useState<CustomComponent[]>([]) const [components, setComponents] = useState<CustomComponent[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [registeredComponents, setRegisteredComponents] = useState(defaultRegisteredComponents) const [compiling, setCompiling] = useState(false)
const [compiledComponents, setCompiledComponents] = useState< const [registeredComponents, setRegisteredComponents] =
Record<string, React.ComponentType<ComponentProps>> 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 () => { const refreshComponents = useCallback(async () => {
if (!applicationConfig) return if (!applicationConfig) return
@ -169,19 +129,11 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
haveSameComponentRevision(current, nextComponents) ? current : nextComponents, haveSameComponentRevision(current, nextComponents) ? current : nextComponents,
) )
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch components') setError(toErrorMessage(err))
console.error('Failed to fetch components:', err) console.error('Failed to fetch components:', err)
} }
}, [applicationConfig]) }, [applicationConfig])
useEffect(() => {
const initialComponents =
(extraProperties?.customComponents as CustomComponentDto[] | undefined) || []
setComponents((current) =>
haveSameComponentRevision(current, initialComponents) ? current : initialComponents,
)
}, [extraProperties])
const refreshComponent = useCallback( const refreshComponent = useCallback(
async (id: string): Promise<CustomComponent | undefined> => { async (id: string): Promise<CustomComponent | undefined> => {
if (!applicationConfig) return undefined if (!applicationConfig) return undefined
@ -197,7 +149,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
}) })
return freshComponent return freshComponent
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch component') setError(toErrorMessage(err))
console.error('Failed to fetch component:', err) console.error('Failed to fetch component:', err)
return undefined return undefined
} }
@ -205,12 +157,21 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
[applicationConfig], [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(() => { useEffect(() => {
const handleComponentsChanged = () => void refreshComponents() const handleComponentsChanged = () => void refreshComponents()
componentsChangedChannel?.addEventListener('message', handleComponentsChanged) componentsChangedChannel?.addEventListener('message', handleComponentsChanged)
return () => return () => componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
}, [refreshComponents]) }, [refreshComponents])
const addComponent = useCallback(async (componentData: CreateUpdateCustomComponentDto) => { const addComponent = useCallback(async (componentData: CreateUpdateCustomComponentDto) => {
@ -221,7 +182,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setComponents((prev) => [...prev, newComponent]) setComponents((prev) => [...prev, newComponent])
notifyComponentsChanged() notifyComponentsChanged()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create component') setError(toErrorMessage(err))
throw err throw err
} finally { } finally {
setLoading(false) setLoading(false)
@ -239,7 +200,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
) )
notifyComponentsChanged() notifyComponentsChanged()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update component') setError(toErrorMessage(err))
throw err throw err
} finally { } finally {
setLoading(false) setLoading(false)
@ -256,7 +217,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setComponents((prev) => prev.filter((component) => component.id !== id)) setComponents((prev) => prev.filter((component) => component.id !== id))
notifyComponentsChanged() notifyComponentsChanged()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete component') setError(toErrorMessage(err))
throw err throw err
} finally { } finally {
setLoading(false) setLoading(false)
@ -271,15 +232,21 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
() => new Map(components.map((component) => [component.name, component])), () => new Map(components.map((component) => [component.name, component])),
[components], [components],
) )
const activeComponentsByName = useMemo(
const compilableComponents = useMemo(
() => () =>
new Map( components.filter(
components (component) =>
.filter((component) => component.isActive) component.isActive && typeof component.name === 'string' && !!component.name.trim(),
.map((component) => [component.name, component]),
), ),
[components], [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]) 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 componentsToCompileRef = useRef(compilableComponents)
const extractComponentInfo = useCallback((code: string, defaultName = '') => { componentsToCompileRef.current = compilableComponents
try {
const fcTypeMatch = code.match(/const\s+([A-Za-z]\w*)\s*:\s*React\.FC/)
if (fcTypeMatch) return fcTypeMatch[1]
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(() => { useEffect(() => {
if (!components.length) { if (!compilationSignature) {
setCompiledComponents({}) setCompiledComponents(EMPTY_REGISTRY)
setComponentErrors(EMPTY_ERRORS)
setCompiling(false)
return return
} }
let cancelled = false let cancelled = false
setCompiling(true)
const compileComponents = async () => { const compile = async () => {
try { try {
const activeComponents = components.filter( // Babel and the UI kit are only needed when an active runtime component
(component) => component.isActive && typeof component.name === 'string' && component.name.trim(), // exists. Keep them out of the application startup bundle.
)
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.
const [Babel, ui, { default: axios }, { default: PlatformViewHost }] = await Promise.all([ const [Babel, ui, { default: axios }, { default: PlatformViewHost }] = await Promise.all([
import('@babel/standalone'), import('@babel/standalone'),
import('../components/ui'), import('../components/ui'),
@ -430,218 +287,86 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
]) ])
if (cancelled) return if (cancelled) return
const { const scope = buildRuntimeScope({
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(
React, React,
AutoComplete, ...ui,
Alert, UiKit: ui,
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, PlatformViewHost,
apiService, apiService,
DOMPurify, DOMPurify,
axios, axios,
ui, translate: stableTranslate,
translate, })
const { registry, errors } = compileComponentBundle(
componentsToCompileRef.current,
scope,
Babel as unknown as BabelLike,
) )
if (cancelled) return
if (!cancelled) setCompiledComponents(compiledComponentsRegistry) setCompiledComponents(registry)
} catch (error) { setComponentErrors(errors)
console.error('Error compiling components bundle:', error) } catch (err) {
if (!cancelled) setCompiledComponents({}) 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 () => { return () => {
cancelled = true cancelled = true
} }
}, [components, extractComponentInfo, translate]) }, [compilationSignature, stableTranslate])
// Render functions
const renderComponent = useCallback( const renderComponent = useCallback(
(name: string, props: ComponentProps = {}) => { (name: string, props: ComponentProps = {}) => {
if (compiledComponents[name]) {
const Component = compiledComponents[name] const Component = compiledComponents[name]
return <Component {...props} /> 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) const compileError = componentErrors[name]
if (!component) { if (compileError) {
return (
<div className="p-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700">
<div className="font-semibold text-sm">{name}</div>
<div className="text-sm whitespace-pre-wrap">{compileError}</div>
</div>
)
}
if (compiling) return null
console.error(`Component not found: ${name}`) console.error(`Component not found: ${name}`)
return ( return (
<div className="p-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700"> <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="text-sm">Component not found: {name}</div>
</div> </div>
) )
}
return null
}, },
[activeComponentsByName, compiledComponents], [compiledComponents, componentErrors, compiling],
) )
const compileAndRender = useCallback((code: string) => { const activeComponentNames = useMemo(
if (!code?.trim()) return null () => new Set(compilableComponents.map((component) => component.name)),
// Simplified version - can be extended later [compilableComponents],
return <div>Code compilation not implemented yet</div> )
}, [])
const isComponentRegistered = useCallback( const isComponentRegistered = useCallback(
(name: string) => { (name: string) => activeComponentNames.has(name) || !!compiledComponents[name],
return activeComponentsByName.has(name) || !!compiledComponents[name] [activeComponentNames, compiledComponents],
},
[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],
) )
const value = useMemo<ComponentContextType>( const value = useMemo<ComponentContextType>(
@ -649,6 +374,8 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
components, components,
loading, loading,
error, error,
compiling,
componentErrors,
addComponent, addComponent,
updateComponent, updateComponent,
deleteComponent, deleteComponent,
@ -659,15 +386,14 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
registeredComponents, registeredComponents,
registerComponent, registerComponent,
renderComponent, renderComponent,
compileAndRender,
isComponentRegistered, isComponentRegistered,
getRegisteredComponents,
getComponentCode,
}), }),
[ [
components, components,
loading, loading,
error, error,
compiling,
componentErrors,
addComponent, addComponent,
updateComponent, updateComponent,
deleteComponent, deleteComponent,
@ -678,10 +404,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
registeredComponents, registeredComponents,
registerComponent, registerComponent,
renderComponent, renderComponent,
compileAndRender,
isComponentRegistered, isComponentRegistered,
getRegisteredComponents,
getComponentCode,
], ],
) )

Binary file not shown.

View file

@ -22,10 +22,17 @@ import { Helmet } from 'react-helmet'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import Dialog from '@/components/ui/Dialog' import Dialog from '@/components/ui/Dialog'
import ComponentEditor from './ComponentEditor' import ComponentEditor from './ComponentEditor'
import { parseComponentDependencies } from '@/contexts/componentRuntime'
const ComponentManager: React.FC = () => { const ComponentManager: React.FC = () => {
const { components, loading, updateComponent, deleteComponent, refreshComponents } = const {
useComponents() components,
loading,
componentErrors,
updateComponent,
deleteComponent,
refreshComponents,
} = useComponents()
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all') const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all')
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
@ -155,7 +162,7 @@ const ComponentManager: React.FC = () => {
onClick={() => void handleRefresh()} onClick={() => void handleRefresh()}
> >
<FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} /> <FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} />
Yenile {translate('::App.Platform.Refresh')}
</Button> </Button>
<div> <div>
<Button <Button
@ -200,16 +207,8 @@ const ComponentManager: React.FC = () => {
</div> </div>
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2"> <p className="text-slate-600 dark:text-gray-300 text-sm mb-2">
{(() => { {parseComponentDependencies(component.dependencies).join(', ') ||
try { translate('::App.DeveloperKit.Component.NoDependencies')}
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')
}
})()}
</p> </p>
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2"> <p className="text-slate-600 dark:text-gray-300 text-sm mb-2">
@ -221,6 +220,15 @@ const ComponentManager: React.FC = () => {
{component.description} {component.description}
</p> </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> </div>
{/* Sağ taraf */} {/* Sağ taraf */}
@ -333,8 +341,8 @@ const ComponentManager: React.FC = () => {
{!searchTerm && filterActive === 'all' && ( {!searchTerm && filterActive === 'all' && (
<button <button
type="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" 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" /> <FaPlus className="w-4 h-4" />
{translate('::App.DeveloperKit.Component.Empty.Initial.Action')} {translate('::App.DeveloperKit.Component.Empty.Initial.Action')}

View file

@ -6,6 +6,8 @@ import {
FaArrowLeft, FaArrowLeft,
FaBolt, FaBolt,
FaCheckCircle, FaCheckCircle,
FaArrowDown,
FaArrowUp,
FaChevronDown, FaChevronDown,
FaChevronRight, FaChevronRight,
FaCode, FaCode,
@ -46,7 +48,10 @@ import {
createDesignerId, createDesignerId,
createEmptyDesignerDocument, createEmptyDesignerDocument,
findDesignerNode, findDesignerNode,
getDesignerCollectionProperty,
getDesignerValueByPath, getDesignerValueByPath,
isDesignerOptionComponent,
isDesignerTabularComponent,
resolveDesignerResponse, resolveDesignerResponse,
walkDesignerNodes, walkDesignerNodes,
type DesignerComponentDefinition, type DesignerComponentDefinition,
@ -95,21 +100,11 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'disabled', 'disabled',
'loading', 'loading',
]) ])
const OPTION_DATA_COMPONENT_NAMES = new Set([ // Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is
'Select', // covered by the static/endpoint panel by construction.
'AutoComplete', const isOptionDataComponent = isDesignerOptionComponent
'Menu', const getOptionDataProperty = getDesignerCollectionProperty
'Dropdown', const isTabularDataComponent = isDesignerTabularComponent
'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))
const PROPERTY_EXAMPLE_VALUES: Record<string, unknown> = { const PROPERTY_EXAMPLE_VALUES: Record<string, unknown> = {
children: 'Örnek içerik', children: 'Örnek içerik',
@ -327,18 +322,6 @@ const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
return result 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 = ( const insertRelativeToNode = (
nodes: DesignerNode[], nodes: DesignerNode[],
targetId: string, 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 = ( const reorderNodeTree = (
nodes: DesignerNode[], nodes: DesignerNode[],
sourceId: string, sourceId: string,
@ -714,13 +802,135 @@ const migrateGeneratedCodeToDesignerDocument = (
return document.nodes.length ? document : null 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 = ({ const PropertyEditor = ({
componentType,
name, name,
type, type,
value, value,
options, options,
onChange, onChange,
}: { }: {
componentType?: string
name: string name: string
type: string type: string
value: unknown value: unknown
@ -747,12 +957,16 @@ const PropertyEditor = ({
) )
} }
if (type === 'select' && options) { if (type === 'select' && options) {
const currentValue = String(value ?? '')
return ( return (
<select <select
className={inputClass} className={inputClass}
value={String(value ?? '')} value={currentValue}
onChange={(event) => onChange(event.target.value)} 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) => ( {options.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
@ -761,6 +975,10 @@ const PropertyEditor = ({
</select> </select>
) )
} }
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
}
if (type === 'object' || type === 'array') { if (type === 'object' || type === 'array') {
return ( return (
<textarea <textarea
@ -1603,6 +1821,17 @@ const VisualComponentDesigner = () => {
[commitDocument], [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( const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => { (sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({ commitDocument((current) => ({
@ -1716,12 +1945,7 @@ const VisualComponentDesigner = () => {
if (event.shiftKey) redo() if (event.shiftKey) redo()
else undo() else undo()
} }
if ( if (event.key === 'Delete' && !isPropertyEditing && selectedId && workspaceTab === 'design') {
event.key === 'Delete' &&
!isPropertyEditing &&
selectedId &&
workspaceTab === 'design'
) {
deleteNode(selectedId) deleteNode(selectedId)
} }
} }
@ -2709,6 +2933,7 @@ const VisualComponentDesigner = () => {
onSelect={(nodeId) => selectDesignerNode(nodeId || null)} onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp} onNodePropChange={updateNodeProp}
onDropComponent={addComponent} onDropComponent={addComponent}
onMoveIntoContainer={moveNodeIntoContainerNode}
onMove={moveNode} onMove={moveNode}
onReorder={reorderNode} onReorder={reorderNode}
onDuplicate={duplicateNode} onDuplicate={duplicateNode}
@ -3325,6 +3550,7 @@ const VisualComponentDesigner = () => {
</p> </p>
)} )}
<PropertyEditor <PropertyEditor
componentType={selectedNode?.type}
name={property.name} name={property.name}
type={property.type} type={property.type}
value={propertyValue} value={propertyValue}