Claude güncellemesi ile Custom Component
This commit is contained in:
parent
24eb249f9f
commit
ac4ccd76d6
10 changed files with 1286 additions and 876 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import DynamicRenderer from './DynamicRenderer'
|
||||
import { useComponents } from '@/contexts/ComponentContext'
|
||||
import { parseComponentDependencies } from '@/contexts/componentRuntime'
|
||||
import { Loading } from '../shared'
|
||||
|
||||
export interface ComponentPreviewProps {
|
||||
|
|
@ -11,12 +12,17 @@ export interface ComponentPreviewProps {
|
|||
const ComponentPreview: React.FC<ComponentPreviewProps> = ({ componentName, className = '' }) => {
|
||||
const { components, loading } = useComponents()
|
||||
|
||||
// Referans olarak sabit tutulmalı: DynamicRenderer bunu effect bağımlılığı olarak kullanıyor.
|
||||
const dependencies = useMemo(() => {
|
||||
const component = components?.find((item) => item.name === componentName && item.isActive)
|
||||
return parseComponentDependencies(component?.dependencies)
|
||||
}, [components, componentName])
|
||||
|
||||
if (!componentName) {
|
||||
return <div className="text-sm text-gray-500 dark:text-gray-400">Bileşen ismi yok.</div>
|
||||
}
|
||||
|
||||
// components dizisinin varlığını kontrol et
|
||||
if (loading || !components || !Array.isArray(components)) {
|
||||
if (loading || !Array.isArray(components)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
|
|
@ -26,26 +32,6 @@ const ComponentPreview: React.FC<ComponentPreviewProps> = ({ componentName, clas
|
|||
)
|
||||
}
|
||||
|
||||
// Belirtilen bileşeni bul
|
||||
const component = components.find((c) => c.name === componentName && c.isActive)
|
||||
|
||||
let dependencies: string[] = []
|
||||
|
||||
if (component?.dependencies) {
|
||||
try {
|
||||
// JSON string mi?
|
||||
if (component.dependencies.startsWith('[')) {
|
||||
dependencies = JSON.parse(component.dependencies)
|
||||
} else {
|
||||
// Virgülle ayrılmış düz metin
|
||||
dependencies = component.dependencies.split(',').map((d) => d.trim())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Dependency parse hatası:', err)
|
||||
dependencies = []
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-900 ${className}`}>
|
||||
<DynamicRenderer componentName={componentName} dependencies={dependencies} />
|
||||
|
|
|
|||
|
|
@ -1,25 +1,36 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import * as Babel from '@babel/standalone'
|
||||
import axios from 'axios'
|
||||
import DOMPurify from 'dompurify'
|
||||
import apiService from '@/services/api.service'
|
||||
import * as UiKit from '@/components/ui'
|
||||
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
||||
import type { DesignerDocument } from '@/components/visualDesigner/types'
|
||||
import type { CustomComponent } from '@/proxy/developerKit/models'
|
||||
import {
|
||||
getComponentRuntimeCode,
|
||||
parseComponentDependencies,
|
||||
toErrorMessage,
|
||||
type BabelLike,
|
||||
} from '@/contexts/componentRuntime'
|
||||
import { useComponents } from '../../contexts/ComponentContext'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import PlatformViewHost from './PlatformViewHost'
|
||||
import { toast } from '../ui'
|
||||
import { Loading } from '../shared'
|
||||
|
||||
const compileComponent = (code: string, scope: Record<string, any> = {}) => {
|
||||
type RuntimeModule = { exports: { default?: React.ComponentType<any> } }
|
||||
|
||||
const compileComponent = (
|
||||
code: string,
|
||||
scope: Record<string, any>,
|
||||
Babel: BabelLike,
|
||||
): React.ComponentType<any> | undefined => {
|
||||
const transpiled = Babel.transform(code, {
|
||||
filename: 'component.tsx',
|
||||
presets: ['typescript', 'react'],
|
||||
plugins: ['transform-modules-commonjs'],
|
||||
}).code
|
||||
|
||||
const module = { exports: {} }
|
||||
if (!transpiled) throw new Error('Boş derleme çıktısı')
|
||||
|
||||
const module: RuntimeModule = { exports: {} }
|
||||
const require = (moduleName: string) => {
|
||||
if (moduleName === 'react') return React
|
||||
if (moduleName === 'axios') return axios
|
||||
|
|
@ -28,18 +39,21 @@ const compileComponent = (code: string, scope: Record<string, any> = {}) => {
|
|||
throw new Error(`Modül bulunamadı: ${moduleName}`)
|
||||
}
|
||||
|
||||
const scopedEval = new Function(
|
||||
'module',
|
||||
'exports',
|
||||
'require',
|
||||
...Object.keys(scope),
|
||||
transpiled!,
|
||||
)
|
||||
|
||||
const scopedEval = new Function('module', 'exports', 'require', ...Object.keys(scope), transpiled)
|
||||
scopedEval(module, module.exports, require, ...Object.values(scope))
|
||||
|
||||
const compiledModule = module.exports as any
|
||||
return compiledModule.default
|
||||
return module.exports.default
|
||||
}
|
||||
|
||||
// The UI kit is spread as-is: every exported component is available to runtime
|
||||
// code without an extra per-component entry here.
|
||||
const staticComponents: Record<string, any> = {
|
||||
...UiKit,
|
||||
PlatformViewHost,
|
||||
toast,
|
||||
apiService,
|
||||
DOMPurify,
|
||||
UiKit,
|
||||
}
|
||||
|
||||
interface DynamicRendererProps {
|
||||
|
|
@ -47,147 +61,102 @@ interface DynamicRendererProps {
|
|||
dependencies?: string[]
|
||||
}
|
||||
|
||||
const staticComponents: Record<string, any> = {
|
||||
...UiKit,
|
||||
Alert: React.lazy(() => import('../ui/Alert')),
|
||||
Avatar: React.lazy(() => import('../ui/Avatar')),
|
||||
Badge: React.lazy(() => import('../ui/Badge')),
|
||||
Button: React.lazy(() => import('../ui/Button')),
|
||||
Calendar: React.lazy(() => import('../ui/Calendar')),
|
||||
Card: React.lazy(() => import('../ui/Card')),
|
||||
Checkbox: React.lazy(() => import('../ui/Checkbox')),
|
||||
ConfigProvider: React.lazy(() => import('../ui/ConfigProvider')),
|
||||
DatePicker: React.lazy(() => import('../ui/DatePicker')),
|
||||
Dialog: React.lazy(() => import('../ui/Dialog')),
|
||||
Drawer: React.lazy(() => import('../ui/Drawer')),
|
||||
Dropdown: React.lazy(() => import('../ui/Dropdown')),
|
||||
FormItem: React.lazy(() => import('../ui/Form/FormItem')),
|
||||
FormContainer: React.lazy(() => import('../ui/Form/FormContainer')),
|
||||
Input: React.lazy(() => import('../ui/Input')),
|
||||
InputGroup: React.lazy(() => import('../ui/InputGroup')),
|
||||
Menu: React.lazy(() => import('../ui/Menu')),
|
||||
MenuItem: React.lazy(() => import('../ui/MenuItem')),
|
||||
Notification: React.lazy(() => import('../ui/Notification')),
|
||||
Pagination: React.lazy(() => import('../ui/Pagination')),
|
||||
Progress: React.lazy(() => import('../ui/Progress')),
|
||||
Radio: React.lazy(() => import('../ui/Radio')),
|
||||
RangeCalendar: React.lazy(() => import('../ui/RangeCalendar')),
|
||||
ScrollBar: React.lazy(() => import('../ui/ScrollBar')),
|
||||
Segment: React.lazy(() => import('../ui/Segment')),
|
||||
Select: React.lazy(() => import('../ui/Select')),
|
||||
Skeleton: React.lazy(() => import('../ui/Skeleton')),
|
||||
Spinner: React.lazy(() => import('../ui/Spinner')),
|
||||
Steps: React.lazy(() => import('../ui/Steps')),
|
||||
Switcher: React.lazy(() => import('../ui/Switcher')),
|
||||
Table: React.lazy(() => import('../ui/Table')),
|
||||
Tabs: React.lazy(() => import('../ui/Tabs')),
|
||||
Tag: React.lazy(() => import('../ui/Tag')),
|
||||
TimeInput: React.lazy(() => import('../ui/TimeInput')),
|
||||
Timeline: React.lazy(() => import('../ui/Timeline')),
|
||||
Tooltip: React.lazy(() => import('../ui/Tooltip')),
|
||||
Upload: React.lazy(() => import('../ui/Upload')),
|
||||
PlatformViewHost: React.lazy(() => import('./PlatformViewHost')),
|
||||
toast,
|
||||
apiService,
|
||||
DOMPurify,
|
||||
UiKit,
|
||||
}
|
||||
|
||||
const getRuntimeCode = (component: CustomComponent) => {
|
||||
if (!component) return ''
|
||||
try {
|
||||
const props = component.props ? JSON.parse(component.props) : null
|
||||
const document = props?.visualDesigner as DesignerDocument | undefined
|
||||
if (
|
||||
document?.version === 1 &&
|
||||
document.sourceMode === 'visual' &&
|
||||
Array.isArray(document.nodes)
|
||||
) {
|
||||
return generateDesignerCode(component.name, document)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Visual designer schema could not be read for ${component.name}:`, error)
|
||||
}
|
||||
return component.code
|
||||
}
|
||||
|
||||
const DynamicRenderer: React.FC<DynamicRendererProps> = ({
|
||||
componentName,
|
||||
dependencies: externalDeps,
|
||||
}) => {
|
||||
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { getComponentByName, components } = useComponents()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setComponent(null)
|
||||
setError(null)
|
||||
|
||||
const storedComponent = getComponentByName(componentName)
|
||||
if (!storedComponent) return
|
||||
if (!storedComponent) {
|
||||
setError(`Component ${componentName} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const map = new Map(
|
||||
components.map((c) => {
|
||||
// Parse dependencies from JSON string
|
||||
let componentDeps: string[] = []
|
||||
try {
|
||||
componentDeps = c.dependencies ? JSON.parse(c.dependencies) : []
|
||||
} catch {
|
||||
componentDeps = []
|
||||
const build = async () => {
|
||||
// Babel is several megabytes; load it only when something must be compiled.
|
||||
const Babel = (await import('@babel/standalone')) as unknown as BabelLike
|
||||
if (cancelled) return
|
||||
|
||||
const map = new Map(
|
||||
components.map((component) => [
|
||||
component.name,
|
||||
{
|
||||
code: getComponentRuntimeCode(component),
|
||||
dependencies: parseComponentDependencies(component.dependencies),
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const compiled: Record<string, any> = {}
|
||||
|
||||
const compileWithDependencies = (name: string): any => {
|
||||
if (compiled[name]) return compiled[name]
|
||||
|
||||
const entry = map.get(name)
|
||||
if (!entry) {
|
||||
if (staticComponents[name]) {
|
||||
compiled[name] = staticComponents[name]
|
||||
return staticComponents[name]
|
||||
}
|
||||
throw new Error(`Component ${name} not found`)
|
||||
}
|
||||
|
||||
return [
|
||||
c.name,
|
||||
{
|
||||
name: c.name,
|
||||
jsx_code: getRuntimeCode(c),
|
||||
dependencies: Array.isArray(componentDeps) ? componentDeps : [],
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
const depNames = name === componentName && externalDeps ? externalDeps : entry.dependencies
|
||||
|
||||
const compiled: Record<string, any> = {}
|
||||
const deps: Record<string, any> = {}
|
||||
for (const dep of depNames) {
|
||||
deps[dep] = compileWithDependencies(dep)
|
||||
}
|
||||
|
||||
const compileWithDependencies = (name: string): any => {
|
||||
if (compiled[name]) return compiled[name]
|
||||
const component = compileComponent(
|
||||
entry.code,
|
||||
{ React, ...staticComponents, ...deps },
|
||||
Babel,
|
||||
)
|
||||
if (!component) throw new Error(`Component ${name} bir default export döndürmüyor`)
|
||||
|
||||
const entry = map.get(name)
|
||||
if (!entry && staticComponents[name]) {
|
||||
compiled[name] = staticComponents[name]
|
||||
return staticComponents[name]
|
||||
}
|
||||
if (!entry) throw new Error(`Component ${name} not found`)
|
||||
|
||||
const depNames =
|
||||
name === componentName && externalDeps ? externalDeps : entry.dependencies || []
|
||||
|
||||
const deps: Record<string, any> = {}
|
||||
for (const dep of depNames) {
|
||||
deps[dep] = compileWithDependencies(dep)
|
||||
compiled[name] = component
|
||||
return component
|
||||
}
|
||||
|
||||
const comp = compileComponent(entry.jsx_code, {
|
||||
React,
|
||||
...staticComponents,
|
||||
...deps,
|
||||
})
|
||||
compiled[name] = comp
|
||||
|
||||
return comp
|
||||
try {
|
||||
const RootComponent = compileWithDependencies(componentName)
|
||||
if (!cancelled) setComponent(() => RootComponent)
|
||||
} catch (err) {
|
||||
console.error('Compilation error:', err)
|
||||
if (!cancelled) setError(toErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const RootComponent = compileWithDependencies(componentName)
|
||||
setComponent(() => RootComponent)
|
||||
} catch (err: any) {
|
||||
console.error('Compilation error:', err)
|
||||
void build()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [componentName, externalDeps, components, getComponentByName])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 m-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300">
|
||||
<div className="font-semibold text-sm">{componentName}</div>
|
||||
<div className="text-sm whitespace-pre-wrap">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Component)
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<Loading loading={!Component} />
|
||||
<Loading loading={true} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,17 @@ import PlatformViewHost, {
|
|||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
|
||||
import {
|
||||
fromDesignerDate,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerTabSlot,
|
||||
getDesignerValueByPath,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateProperty,
|
||||
isDesignerOptionComponent,
|
||||
normalizeDesignerKeyList,
|
||||
resolveDesignerDropdownTitle,
|
||||
resolveDesignerTabValue,
|
||||
toDesignerDate,
|
||||
type DesignerBinding,
|
||||
type DesignerNode,
|
||||
} from './types'
|
||||
|
|
@ -15,18 +24,14 @@ import {
|
|||
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
|
||||
const getTableDimension = (value: unknown, fallback: number) =>
|
||||
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
|
||||
const resolveStaticLanguageKeys = (
|
||||
value: unknown,
|
||||
translate: (key: string) => string,
|
||||
): unknown => {
|
||||
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
|
||||
if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value
|
||||
if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate))
|
||||
if (value && typeof value === 'object') {
|
||||
// Only plain objects are walked: `Object.entries` on a Date yields nothing and
|
||||
// would silently turn it into `{}`.
|
||||
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
resolveStaticLanguageKeys(item, translate),
|
||||
]),
|
||||
Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
|
|
@ -38,6 +43,8 @@ interface VisualCanvasProps {
|
|||
interactive?: boolean
|
||||
onSelect?: (id: string) => void
|
||||
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
|
||||
/** Moves an existing node into a container/table cell, or to the root. */
|
||||
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
|
||||
onMove?: (id: string, direction: -1 | 1) => void
|
||||
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
|
||||
onDuplicate?: (id: string) => void
|
||||
|
|
@ -149,6 +156,30 @@ const toSelectOptions = (
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select renders its menu inline, so any ancestor with `overflow: hidden/auto`
|
||||
* (the canvas page, a Grid/Table scroll wrapper) clips it — the menu of a Select
|
||||
* in the last row would be cut off. Rendering it in a body portal keeps it
|
||||
* visible; explicit props still win.
|
||||
*/
|
||||
const getSelectMenuProps = (props: Record<string, unknown>) => {
|
||||
const menuProps: Record<string, unknown> = {}
|
||||
if (props.menuPosition === undefined) menuProps.menuPosition = 'fixed'
|
||||
if (props.menuPortalTarget === undefined && typeof window !== 'undefined') {
|
||||
menuProps.menuPortalTarget = window.document.body
|
||||
}
|
||||
if (props.styles === undefined) {
|
||||
menuProps.styles = {
|
||||
menuPortal: (base: Record<string, unknown>) => ({ ...base, zIndex: 60 }),
|
||||
}
|
||||
}
|
||||
return menuProps
|
||||
}
|
||||
|
||||
/** Resolved tab list of a Tabs node, from static items or an endpoint binding. */
|
||||
const getTabOptions = (props: Record<string, unknown>) =>
|
||||
Array.isArray(props.items) ? (props.items as Array<Record<string, unknown>>) : []
|
||||
|
||||
const getPreviewProps = (
|
||||
node: DesignerNode,
|
||||
dataValues: Record<string, unknown>,
|
||||
|
|
@ -184,12 +215,16 @@ const getPreviewProps = (
|
|||
props[propertyName] = getBindingValue(binding, dataValues, currentItem)
|
||||
}
|
||||
})
|
||||
if (
|
||||
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type)
|
||||
) {
|
||||
const collectionProperty = ['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type)
|
||||
? 'items'
|
||||
: 'options'
|
||||
// Stored as ISO strings; pickers only accept real Date instances.
|
||||
if (isDesignerDateComponent(node.type)) {
|
||||
Object.keys(props).forEach((propertyName) => {
|
||||
if (isDesignerDateProperty(node.type, propertyName)) {
|
||||
props[propertyName] = toDesignerDate(props[propertyName])
|
||||
}
|
||||
})
|
||||
}
|
||||
if (isDesignerOptionComponent(node.type)) {
|
||||
const collectionProperty = getDesignerCollectionProperty(node.type)
|
||||
if (node.type === 'Select') {
|
||||
const legacyAliases: Record<string, string> = {
|
||||
clearable: 'isClearable',
|
||||
|
|
@ -203,6 +238,7 @@ const getPreviewProps = (
|
|||
}
|
||||
delete props[legacyName]
|
||||
})
|
||||
Object.assign(props, getSelectMenuProps(props))
|
||||
}
|
||||
const optionsBinding = node.bindings?.[collectionProperty]
|
||||
props[collectionProperty] = toSelectOptions(
|
||||
|
|
@ -226,13 +262,11 @@ const getPreviewProps = (
|
|||
}
|
||||
}
|
||||
|
||||
const chainHandler = (
|
||||
eventName: string,
|
||||
update: (...args: unknown[]) => void,
|
||||
) => {
|
||||
const storedHandler = typeof props[eventName] === 'function'
|
||||
? (props[eventName] as (...args: unknown[]) => void)
|
||||
: undefined
|
||||
const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => {
|
||||
const storedHandler =
|
||||
typeof props[eventName] === 'function'
|
||||
? (props[eventName] as (...args: unknown[]) => void)
|
||||
: undefined
|
||||
props[eventName] = (...args: unknown[]) => {
|
||||
update(...args)
|
||||
storedHandler?.(...args)
|
||||
|
|
@ -266,6 +300,8 @@ const getPreviewProps = (
|
|||
chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')]))
|
||||
} else if (node.type === 'Dropdown') {
|
||||
chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? '')))
|
||||
} else if (node.type === 'Tabs') {
|
||||
chainHandler('onChange', (value) => updateProp('value', String(value ?? '')))
|
||||
} else if (node.type === 'Radio.Group') {
|
||||
chainHandler('onChange', (value) => updateProp('value', value))
|
||||
} else if (node.type === 'Pagination') {
|
||||
|
|
@ -280,16 +316,18 @@ const getPreviewProps = (
|
|||
)
|
||||
} else if (node.type === 'MenuItem') {
|
||||
chainHandler('onSelect', () => updateProp('isActive', true))
|
||||
} else if (isDesignerDateComponent(node.type)) {
|
||||
// A Date is not JSON serialisable, so the picked value is kept as an ISO
|
||||
// string — otherwise it is lost on the next render/save.
|
||||
chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value)))
|
||||
} else if ('checked' in props) {
|
||||
chainHandler('onChange', (value, originalEvent) => {
|
||||
const checked =
|
||||
originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent
|
||||
? Boolean(
|
||||
(originalEvent as { target?: { checked?: unknown } }).target?.checked,
|
||||
)
|
||||
? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked)
|
||||
: value && typeof value === 'object' && 'target' in value
|
||||
? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
|
||||
: Boolean(value)
|
||||
? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
|
||||
: Boolean(value)
|
||||
updateProp('checked', checked)
|
||||
})
|
||||
} else if ('value' in props) {
|
||||
|
|
@ -541,7 +579,8 @@ const renderElement = (
|
|||
React.Children.count(children) > 0
|
||||
? children
|
||||
: boundChildren === null || boundChildren === undefined
|
||||
? (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) || undefined
|
||||
? (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) ||
|
||||
undefined
|
||||
: React.isValidElement(boundChildren)
|
||||
? boundChildren
|
||||
: typeof boundChildren === 'object'
|
||||
|
|
@ -581,6 +620,13 @@ const renderElement = (
|
|||
? (dropdownProps.items as Array<Record<string, unknown>>)
|
||||
: []
|
||||
delete dropdownProps.items
|
||||
// Without this the toggle keeps showing the static title and the component
|
||||
// looks untouched after a selection.
|
||||
dropdownProps.title = resolveDesignerDropdownTitle(
|
||||
options,
|
||||
dropdownProps.activeKey,
|
||||
dropdownProps.title,
|
||||
)
|
||||
return (
|
||||
<UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}>
|
||||
{options.map((option, index) => (
|
||||
|
|
@ -596,6 +642,8 @@ const renderElement = (
|
|||
</UiKit.Dropdown>
|
||||
)
|
||||
}
|
||||
// Built in NodeView so each tab can own its own children and drop target.
|
||||
if (node.type === 'Tabs') return <>{children}</>
|
||||
if (node.type === 'Radio.Group') {
|
||||
const groupProps = { ...props }
|
||||
const options = Array.isArray(groupProps.items)
|
||||
|
|
@ -639,10 +687,13 @@ const renderElement = (
|
|||
const NodeView = ({
|
||||
node,
|
||||
index,
|
||||
siblingCount = 1,
|
||||
isRoot = false,
|
||||
selectedId,
|
||||
interactive,
|
||||
onSelect,
|
||||
onDropComponent,
|
||||
onMoveIntoContainer,
|
||||
onMove,
|
||||
onReorder,
|
||||
onDuplicate,
|
||||
|
|
@ -654,10 +705,13 @@ const NodeView = ({
|
|||
}: {
|
||||
node: DesignerNode
|
||||
index: number
|
||||
siblingCount?: number
|
||||
isRoot?: boolean
|
||||
selectedId: string | null
|
||||
interactive: boolean
|
||||
onSelect?: (id: string) => void
|
||||
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
|
||||
onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
|
||||
onMove?: (id: string, direction: -1 | 1) => void
|
||||
onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
|
||||
onDuplicate?: (id: string) => void
|
||||
|
|
@ -669,6 +723,10 @@ const NodeView = ({
|
|||
}) => {
|
||||
const { translate } = useLocalization()
|
||||
const selected = interactive && selectedId === node.id
|
||||
// A nested node can always move: at the edge of its container it is lifted out.
|
||||
// Only the first/last node at the root has nowhere left to go.
|
||||
const canMoveUp = !isRoot || index > 0
|
||||
const canMoveDown = !isRoot || index < siblingCount - 1
|
||||
const acceptsDroppedChildren = [
|
||||
'PageContainer',
|
||||
'TwoColumns',
|
||||
|
|
@ -678,6 +736,8 @@ const NodeView = ({
|
|||
'div',
|
||||
'Card',
|
||||
'FormContainer',
|
||||
// Dropped components land in whichever tab is open.
|
||||
'Tabs',
|
||||
].includes(node.type)
|
||||
const staticChildren = node.props.children
|
||||
const hasStaticChildren =
|
||||
|
|
@ -685,8 +745,7 @@ const NodeView = ({
|
|||
staticChildren !== null &&
|
||||
(typeof staticChildren !== 'string' || staticChildren.length > 0)
|
||||
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
|
||||
const hasVisibleChildren =
|
||||
node.children.length > 0 || hasStaticChildren || hasChildrenBinding
|
||||
const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
|
||||
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
|
||||
const boundItems = itemsBinding?.sourceId
|
||||
? getBindingValue(itemsBinding, dataValues, currentItem)
|
||||
|
|
@ -710,6 +769,7 @@ const NodeView = ({
|
|||
key={`${child.id}_${itemIndex}`}
|
||||
node={child}
|
||||
index={childIndex}
|
||||
siblingCount={node.children.length}
|
||||
selectedId={selectedId}
|
||||
interactive={interactive}
|
||||
renderCustomComponent={renderCustomComponent}
|
||||
|
|
@ -717,6 +777,7 @@ const NodeView = ({
|
|||
currentItem={childItem}
|
||||
onSelect={onSelect}
|
||||
onDropComponent={onDropComponent}
|
||||
onMoveIntoContainer={onMoveIntoContainer}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
onDuplicate={onDuplicate}
|
||||
|
|
@ -725,6 +786,99 @@ const NodeView = ({
|
|||
/>
|
||||
)),
|
||||
)
|
||||
const tabsContent = (() => {
|
||||
if (node.type !== 'Tabs') return null
|
||||
const tabsProps = getPreviewProps(node, dataValues, currentItem, onNodePropChange, translate)
|
||||
const options = getTabOptions(tabsProps)
|
||||
delete tabsProps.items
|
||||
const activeValue = resolveDesignerTabValue(options, tabsProps.value)
|
||||
tabsProps.value = activeValue
|
||||
// Children created before per-tab slots existed belong to the first tab.
|
||||
const firstSlot = options.length ? getDesignerTabSlot(String(options[0]?.value ?? 0)) : ''
|
||||
|
||||
return (
|
||||
<UiKit.Tabs {...(tabsProps as React.ComponentProps<typeof UiKit.Tabs>)}>
|
||||
<UiKit.Tabs.TabList>
|
||||
{options.map((option, index) => (
|
||||
<UiKit.Tabs.TabNav
|
||||
key={String(option.value ?? index)}
|
||||
disabled={Boolean(option.disabled)}
|
||||
value={String(option.value ?? index)}
|
||||
>
|
||||
{String(option.label ?? option.value ?? `Sekme ${index + 1}`)}
|
||||
</UiKit.Tabs.TabNav>
|
||||
))}
|
||||
</UiKit.Tabs.TabList>
|
||||
{options.map((option, index) => {
|
||||
const tabValue = String(option.value ?? index)
|
||||
const slot = getDesignerTabSlot(tabValue)
|
||||
const tabNodes = node.children.filter((child) => (child.slot || firstSlot) === slot)
|
||||
return (
|
||||
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
|
||||
<div
|
||||
className="min-h-14 py-2"
|
||||
onDragOver={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect =
|
||||
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, node.id, slot)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||
}
|
||||
} catch {
|
||||
// Ignore payloads that do not belong to the visual designer.
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.content ? <div>{String(option.content)}</div> : null}
|
||||
{tabNodes.map((child, childIndex) => (
|
||||
<NodeView
|
||||
key={child.id}
|
||||
node={child}
|
||||
index={childIndex}
|
||||
siblingCount={tabNodes.length}
|
||||
selectedId={selectedId}
|
||||
interactive={interactive}
|
||||
renderCustomComponent={renderCustomComponent}
|
||||
dataValues={dataValues}
|
||||
currentItem={currentItem}
|
||||
onSelect={onSelect}
|
||||
onDropComponent={onDropComponent}
|
||||
onMoveIntoContainer={onMoveIntoContainer}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
onNodePropChange={onNodePropChange}
|
||||
/>
|
||||
))}
|
||||
{interactive && !tabNodes.length && !option.content && (
|
||||
<div className="flex min-h-12 items-center justify-center rounded border border-dashed border-slate-300 px-2 text-center text-[10px] text-slate-400 dark:border-slate-700">
|
||||
Bu sekme için komponent bırakın
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</UiKit.Tabs.TabContent>
|
||||
)
|
||||
})}
|
||||
</UiKit.Tabs>
|
||||
)
|
||||
})()
|
||||
|
||||
const tableContent =
|
||||
node.type === 'Table' ? (
|
||||
<UiKit.Table
|
||||
|
|
@ -765,7 +919,10 @@ const NodeView = ({
|
|||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
// Must match the source's effectAllowed, otherwise the
|
||||
// browser rejects the drop and never fires onDrop.
|
||||
event.dataTransfer.dropEffect =
|
||||
event.dataTransfer.effectAllowed === 'move' ? 'move' : 'copy'
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
|
|
@ -779,6 +936,10 @@ const NodeView = ({
|
|||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, node.id, slot)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
// Lets a component be dragged back into a cell, from
|
||||
// another cell or from anywhere else on the canvas.
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||
}
|
||||
} catch {
|
||||
// Ignore payloads that do not belong to the visual designer.
|
||||
|
|
@ -791,6 +952,7 @@ const NodeView = ({
|
|||
key={child.id}
|
||||
node={child}
|
||||
index={childIndex}
|
||||
siblingCount={node.children.length}
|
||||
selectedId={selectedId}
|
||||
interactive={interactive}
|
||||
renderCustomComponent={renderCustomComponent}
|
||||
|
|
@ -798,6 +960,7 @@ const NodeView = ({
|
|||
currentItem={currentItem}
|
||||
onSelect={onSelect}
|
||||
onDropComponent={onDropComponent}
|
||||
onMoveIntoContainer={onMoveIntoContainer}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
onDuplicate={onDuplicate}
|
||||
|
|
@ -821,65 +984,12 @@ const NodeView = ({
|
|||
</UiKit.Table>
|
||||
) : null
|
||||
const renderedChildren =
|
||||
node.type === 'Table'
|
||||
? tableContent
|
||||
: node.type === 'Grid' && interactive && node.children.length === 0
|
||||
? repeatedItems.length
|
||||
? [
|
||||
<GridDataTablePreview
|
||||
key={`grid_data_${node.id}`}
|
||||
borderlessRow={Boolean(node.props.borderlessRow)}
|
||||
compact={Boolean(node.props.compact)}
|
||||
hoverable={node.props.hoverable !== false}
|
||||
items={repeatedItems}
|
||||
overflow={node.props.overflow !== false}
|
||||
selectedColumns={
|
||||
Array.isArray(node.props.dataColumns)
|
||||
? node.props.dataColumns.filter(
|
||||
(column): column is string => typeof column === 'string',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
]
|
||||
: [
|
||||
<GridColumnHeaders
|
||||
key={`grid_headers_${node.id}`}
|
||||
columns={
|
||||
Array.isArray(node.props.dataColumns)
|
||||
? node.props.dataColumns.filter(
|
||||
(column): column is string => typeof column === 'string',
|
||||
)
|
||||
: []
|
||||
}
|
||||
/>,
|
||||
]
|
||||
: node.type === 'Grid' &&
|
||||
!interactive &&
|
||||
itemsBinding?.sourceId &&
|
||||
effectiveBoundItems === undefined
|
||||
? [
|
||||
<div
|
||||
key={`grid_loading_${node.id}`}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
|
||||
>
|
||||
Veriler yükleniyor…
|
||||
</div>,
|
||||
]
|
||||
: node.type === 'Grid' &&
|
||||
itemsBinding?.sourceId &&
|
||||
effectiveBoundItems !== undefined &&
|
||||
!Array.isArray(effectiveBoundItems)
|
||||
? [
|
||||
<div
|
||||
key={`grid_binding_error_${node.id}`}
|
||||
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
|
||||
>
|
||||
Grid items bağlantısı bir koleksiyon döndürmelidir. Seçili path:{' '}
|
||||
<code>{itemsBinding.path || '(root)'}</code>
|
||||
</div>,
|
||||
]
|
||||
: node.type === 'Grid' && node.children.length === 0 && repeatedItems.length
|
||||
node.type === 'Tabs'
|
||||
? tabsContent
|
||||
: node.type === 'Table'
|
||||
? tableContent
|
||||
: node.type === 'Grid' && interactive && node.children.length === 0
|
||||
? repeatedItems.length
|
||||
? [
|
||||
<GridDataTablePreview
|
||||
key={`grid_data_${node.id}`}
|
||||
|
|
@ -897,7 +1007,62 @@ const NodeView = ({
|
|||
}
|
||||
/>,
|
||||
]
|
||||
: children
|
||||
: [
|
||||
<GridColumnHeaders
|
||||
key={`grid_headers_${node.id}`}
|
||||
columns={
|
||||
Array.isArray(node.props.dataColumns)
|
||||
? node.props.dataColumns.filter(
|
||||
(column): column is string => typeof column === 'string',
|
||||
)
|
||||
: []
|
||||
}
|
||||
/>,
|
||||
]
|
||||
: node.type === 'Grid' &&
|
||||
!interactive &&
|
||||
itemsBinding?.sourceId &&
|
||||
effectiveBoundItems === undefined
|
||||
? [
|
||||
<div
|
||||
key={`grid_loading_${node.id}`}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
|
||||
>
|
||||
Veriler yükleniyor…
|
||||
</div>,
|
||||
]
|
||||
: node.type === 'Grid' &&
|
||||
itemsBinding?.sourceId &&
|
||||
effectiveBoundItems !== undefined &&
|
||||
!Array.isArray(effectiveBoundItems)
|
||||
? [
|
||||
<div
|
||||
key={`grid_binding_error_${node.id}`}
|
||||
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
|
||||
>
|
||||
Grid items bağlantısı bir koleksiyon döndürmelidir. Seçili path:{' '}
|
||||
<code>{itemsBinding.path || '(root)'}</code>
|
||||
</div>,
|
||||
]
|
||||
: node.type === 'Grid' && node.children.length === 0 && repeatedItems.length
|
||||
? [
|
||||
<GridDataTablePreview
|
||||
key={`grid_data_${node.id}`}
|
||||
borderlessRow={Boolean(node.props.borderlessRow)}
|
||||
compact={Boolean(node.props.compact)}
|
||||
hoverable={node.props.hoverable !== false}
|
||||
items={repeatedItems}
|
||||
overflow={node.props.overflow !== false}
|
||||
selectedColumns={
|
||||
Array.isArray(node.props.dataColumns)
|
||||
? node.props.dataColumns.filter(
|
||||
(column): column is string => typeof column === 'string',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
]
|
||||
: children
|
||||
const contentChildren =
|
||||
interactive && node.type === 'Card' && node.children.length === 0
|
||||
? [
|
||||
|
|
@ -941,15 +1106,17 @@ const NodeView = ({
|
|||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
// Must match the source's effectAllowed, otherwise the browser rejects
|
||||
// the drop and never fires onDrop.
|
||||
event.dataTransfer.dropEffect =
|
||||
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'canvas' && payload.nodeId !== node.id) {
|
||||
|
|
@ -969,8 +1136,9 @@ const NodeView = ({
|
|||
<FaGripVertical /> {node.type}
|
||||
</span>
|
||||
<button
|
||||
className="p-1.5 hover:bg-sky-700"
|
||||
title="Yukarı taşı"
|
||||
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!canMoveUp}
|
||||
title={canMoveUp ? 'Yukarı taşı' : 'Zaten en üstte'}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
|
|
@ -980,8 +1148,9 @@ const NodeView = ({
|
|||
<FaArrowUp />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-sky-700"
|
||||
title="Aşağı taşı"
|
||||
className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!canMoveDown}
|
||||
title={canMoveDown ? 'Aşağı taşı' : 'Zaten en altta'}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
|
|
@ -1029,10 +1198,12 @@ const NodeView = ({
|
|||
renderCustomComponent,
|
||||
)}
|
||||
</PreviewBoundary>
|
||||
{/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */}
|
||||
{interactive &&
|
||||
!hasVisibleChildren &&
|
||||
acceptsDroppedChildren &&
|
||||
node.type !== 'Card' && (
|
||||
node.type !== 'Card' &&
|
||||
node.type !== 'Tabs' && (
|
||||
<div className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400">
|
||||
Bileşeni buraya bırakın
|
||||
</div>
|
||||
|
|
@ -1048,6 +1219,7 @@ const VisualCanvas = ({
|
|||
interactive = true,
|
||||
onSelect,
|
||||
onDropComponent,
|
||||
onMoveIntoContainer,
|
||||
onMove,
|
||||
onReorder,
|
||||
onDuplicate,
|
||||
|
|
@ -1067,11 +1239,15 @@ const VisualCanvas = ({
|
|||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') onDropComponent?.(payload.name, null)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, null)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
// Dropping on empty canvas takes the node out of its container.
|
||||
onMoveIntoContainer?.(payload.nodeId, null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nodes.length ? (
|
||||
|
|
@ -1079,14 +1255,17 @@ const VisualCanvas = ({
|
|||
{nodes.map((node, index) => (
|
||||
<NodeView
|
||||
key={node.id}
|
||||
isRoot
|
||||
node={node}
|
||||
index={index}
|
||||
siblingCount={nodes.length}
|
||||
selectedId={selectedId}
|
||||
interactive={interactive}
|
||||
renderCustomComponent={renderCustomComponent}
|
||||
dataValues={previewDataValues}
|
||||
onSelect={onSelect}
|
||||
onDropComponent={onDropComponent}
|
||||
onMoveIntoContainer={onMoveIntoContainer}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
onDuplicate={onDuplicate}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/componentDefinitions'
|
||||
import generatedComponentProps from './generated/componentProps.json'
|
||||
import type { DesignerComponentDefinition, DesignerNodeKind } from './types'
|
||||
import {
|
||||
DESIGNER_DATA_COMPONENT_NAMES,
|
||||
getDesignerCollectionProperty,
|
||||
isDesignerOptionComponent,
|
||||
isDesignerTabularComponent,
|
||||
type DesignerComponentDefinition,
|
||||
type DesignerNodeKind,
|
||||
} from './types'
|
||||
|
||||
export { DESIGNER_DATA_COMPONENT_NAMES }
|
||||
|
||||
interface GeneratedProperty {
|
||||
name: string
|
||||
|
|
@ -19,15 +28,46 @@ interface GeneratedComponentMetadata {
|
|||
|
||||
const generatedMetadata = generatedComponentProps as Record<string, GeneratedComponentMetadata>
|
||||
const CONTAINER_NAMES = new Set(['div', 'Card', 'FormContainer', 'Table', 'Tabs', 'Timeline'])
|
||||
export const DESIGNER_DATA_COMPONENT_NAMES = new Set([
|
||||
'AutoComplete',
|
||||
'Dropdown',
|
||||
'Grid',
|
||||
'Menu',
|
||||
'Pagination',
|
||||
'Radio.Group',
|
||||
'Select',
|
||||
const getToolboxGroup = (name: string): DesignerComponentDefinition['toolboxGroup'] =>
|
||||
name === 'Table' ? 'layout' : DESIGNER_DATA_COMPONENT_NAMES.has(name) ? 'data' : 'ui'
|
||||
/** Components intentionally kept out of the toolbox. */
|
||||
const EXCLUDED_COMPONENTS = new Set([
|
||||
'Alert',
|
||||
'Breadcrumb',
|
||||
'Dialog',
|
||||
'Drawer',
|
||||
'FormContainer',
|
||||
'FormItem',
|
||||
'InputGroup',
|
||||
'MenuItem',
|
||||
'Segment',
|
||||
'Steps',
|
||||
])
|
||||
interface PropertyOverride {
|
||||
/** Seed value that makes a freshly dropped component render something useful. */
|
||||
value?: unknown
|
||||
/** Corrects an editor type the metadata generator could not infer. */
|
||||
type?: GeneratedProperty['type']
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
const PROPERTY_OVERRIDES: Record<string, Record<string, PropertyOverride>> = {
|
||||
// `Exclude<Shape, 'none'> | 'square'` collapses to a single option.
|
||||
Avatar: { shape: { options: ['round', 'circle', 'square'], value: 'circle' } },
|
||||
Chips: { value: { value: ['Deneme', 'Deneme2'] } },
|
||||
// ImageViewer renders nothing without images, and its clickable gallery only
|
||||
// appears when `children` is set — so a dropped component would be invisible.
|
||||
ImageViewer: {
|
||||
images: {
|
||||
value: [
|
||||
{ src: 'https://picsum.photos/id/1015/1200/800', alt: 'Görsel 1', caption: 'Görsel 1' },
|
||||
{ src: 'https://picsum.photos/id/1025/1200/800', alt: 'Görsel 2', caption: 'Görsel 2' },
|
||||
],
|
||||
},
|
||||
children: { value: 'Galeri' },
|
||||
},
|
||||
Marquee: { gradient: { value: false }, speed: { value: 100 } },
|
||||
}
|
||||
const HTML_UI_DUPLICATES = new Set(['input', 'button', 'textarea', 'select', 'checkbox'])
|
||||
const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
|
||||
Select: {
|
||||
|
|
@ -48,6 +88,7 @@ export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
|
|||
Pagination: ['onChange'],
|
||||
'Radio.Group': ['onChange'],
|
||||
Select: ['onChange'],
|
||||
Tabs: ['onChange'],
|
||||
}
|
||||
|
||||
const DESIGNER_EVENT_SNIPPETS: Record<string, Record<string, string>> = {
|
||||
|
|
@ -152,6 +193,7 @@ const TABLE_DIMENSION_PROPERTIES = [
|
|||
category: 'properties' as const,
|
||||
description: 'Table içindeki statik satır sayısı',
|
||||
tsType: 'number',
|
||||
options: undefined,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -161,6 +203,7 @@ const TABLE_DIMENSION_PROPERTIES = [
|
|||
category: 'properties' as const,
|
||||
description: 'Her satırdaki statik hücre sayısı',
|
||||
tsType: 'number',
|
||||
options: undefined,
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
|
|
@ -182,6 +225,24 @@ const MENU_ITEMS_PROPERTY = {
|
|||
name: 'items',
|
||||
description: 'Menu items; statik olarak veya endpoint koleksiyonundan beslenebilir',
|
||||
}
|
||||
const TABS_ITEMS_PROPERTY = {
|
||||
...SELECT_OPTIONS_PROPERTY,
|
||||
name: 'items',
|
||||
value: [
|
||||
{ label: 'Sekme 1', value: '1', content: 'Sekme 1 içeriği' },
|
||||
{ label: 'Sekme 2', value: '2', content: 'Sekme 2 içeriği' },
|
||||
],
|
||||
description:
|
||||
'Sekmeler; statik olarak veya endpoint koleksiyonundan beslenebilir. content alanı sekme içeriğini yazar.',
|
||||
tsType: 'Array<{ label: string; value: unknown; content?: string }>',
|
||||
}
|
||||
|
||||
/** Seed definition for a data component's collection property. */
|
||||
const getCollectionPropertyDefinition = (name: string, collectionProperty: string) => {
|
||||
if (isDesignerTabularComponent(name)) return COLLECTION_ITEMS_PROPERTY
|
||||
if (name === 'Tabs') return TABS_ITEMS_PROPERTY
|
||||
return collectionProperty === 'items' ? MENU_ITEMS_PROPERTY : SELECT_OPTIONS_PROPERTY
|
||||
}
|
||||
|
||||
const normalizeDefinition = (
|
||||
definition: (typeof CUSTOM_COMPONENTS)[number],
|
||||
|
|
@ -193,8 +254,7 @@ const normalizeDefinition = (
|
|||
? definition.properties.filter((property) => property.name !== 'children')
|
||||
: definition.properties
|
||||
let properties =
|
||||
definition.name === 'Grid' &&
|
||||
!sourceProperties.some((property) => property.name === 'items')
|
||||
definition.name === 'Grid' && !sourceProperties.some((property) => property.name === 'items')
|
||||
? [COLLECTION_ITEMS_PROPERTY, ...sourceProperties]
|
||||
: sourceProperties
|
||||
if (definition.name === 'Grid' && !properties.some((property) => property.name === 'compact')) {
|
||||
|
|
@ -264,19 +324,26 @@ const inferUiCategory = (name: string) => {
|
|||
const generatedPropertiesToDesigner = (
|
||||
generated: GeneratedComponentMetadata,
|
||||
existingProperties = new Map<string, (typeof CUSTOM_COMPONENTS)[number]['properties'][number]>(),
|
||||
componentName = '',
|
||||
) =>
|
||||
generated.properties.map((property) => {
|
||||
const existing = existingProperties.get(property.name)
|
||||
const override = PROPERTY_OVERRIDES[componentName]?.[property.name]
|
||||
const type = override?.type || property.type
|
||||
return {
|
||||
name: property.name,
|
||||
type: property.type,
|
||||
type,
|
||||
value:
|
||||
property.defaultValue !== undefined
|
||||
? property.defaultValue
|
||||
: existing?.value !== undefined
|
||||
? existing.value
|
||||
: fallbackValue(property.type),
|
||||
options: property.options || existing?.options,
|
||||
override && 'value' in override
|
||||
? override.value
|
||||
: property.defaultValue !== undefined
|
||||
? property.defaultValue
|
||||
: existing?.value !== undefined
|
||||
? existing.value
|
||||
: fallbackValue(type),
|
||||
options: override?.type
|
||||
? override.options
|
||||
: override?.options || property.options || existing?.options,
|
||||
description: property.tsType,
|
||||
category:
|
||||
property.type === 'function'
|
||||
|
|
@ -295,15 +362,7 @@ const normalizeUiDefinition = (
|
|||
): DesignerComponentDefinition => {
|
||||
const generated = generatedMetadata[definition.name]
|
||||
if (!generated) {
|
||||
return normalizeDefinition(
|
||||
definition,
|
||||
'ui',
|
||||
definition.name === 'Table'
|
||||
? 'layout'
|
||||
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
|
||||
? 'data'
|
||||
: 'ui',
|
||||
)
|
||||
return normalizeDefinition(definition, 'ui', getToolboxGroup(definition.name))
|
||||
}
|
||||
|
||||
const propertyAliases = UI_PROPERTY_ALIASES[definition.name] || {}
|
||||
|
|
@ -319,28 +378,25 @@ const normalizeUiDefinition = (
|
|||
name: propertyAliases[property.name] || property.name,
|
||||
}))
|
||||
const existingProperties = new Map(curatedProperties.map((property) => [property.name, property]))
|
||||
const generatedProperties = generatedPropertiesToDesigner(generated, existingProperties)
|
||||
const generatedProperties = generatedPropertiesToDesigner(
|
||||
generated,
|
||||
existingProperties,
|
||||
definition.name,
|
||||
)
|
||||
// Every data component must expose its collection property so the data panel
|
||||
// can drive it from static values or an endpoint.
|
||||
const collectionProperty = getDesignerCollectionProperty(definition.name)
|
||||
if (
|
||||
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(
|
||||
definition.name,
|
||||
) &&
|
||||
!generatedProperties.some(
|
||||
(property) =>
|
||||
property.name ===
|
||||
(['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(definition.name)
|
||||
? 'items'
|
||||
: 'options'),
|
||||
)
|
||||
isDesignerOptionComponent(definition.name) &&
|
||||
!generatedProperties.some((property) => property.name === collectionProperty)
|
||||
) {
|
||||
generatedProperties.unshift(
|
||||
['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(definition.name)
|
||||
? MENU_ITEMS_PROPERTY
|
||||
: SELECT_OPTIONS_PROPERTY,
|
||||
getCollectionPropertyDefinition(definition.name, collectionProperty),
|
||||
)
|
||||
}
|
||||
if (
|
||||
definition.name === 'Grid' &&
|
||||
!generatedProperties.some((property) => property.name === 'items')
|
||||
isDesignerTabularComponent(definition.name) &&
|
||||
!generatedProperties.some((property) => property.name === collectionProperty)
|
||||
) {
|
||||
generatedProperties.unshift(COLLECTION_ITEMS_PROPERTY)
|
||||
}
|
||||
|
|
@ -365,12 +421,7 @@ const normalizeUiDefinition = (
|
|||
return {
|
||||
...definition,
|
||||
kind: 'ui',
|
||||
toolboxGroup:
|
||||
definition.name === 'Table'
|
||||
? 'layout'
|
||||
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
|
||||
? 'data'
|
||||
: 'ui',
|
||||
toolboxGroup: getToolboxGroup(definition.name),
|
||||
acceptsChildren: CONTAINER_NAMES.has(definition.name),
|
||||
sourceFile: generated.sourceFile,
|
||||
interfaceName: generated.interfaceName,
|
||||
|
|
@ -518,28 +569,35 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
|
|||
const html = HTML_ELEMENTS.filter((definition) => !HTML_UI_DUPLICATES.has(definition.name)).map(
|
||||
(definition) => normalizeDefinition(definition, 'html', 'html'),
|
||||
)
|
||||
const ui = CUSTOM_COMPONENTS.map(normalizeUiDefinition)
|
||||
const ui = CUSTOM_COMPONENTS.filter(
|
||||
(definition) => !EXCLUDED_COMPONENTS.has(definition.name),
|
||||
).map(normalizeUiDefinition)
|
||||
const definedUiNames = new Set(CUSTOM_COMPONENTS.map((definition) => definition.name))
|
||||
const generatedOnlyUi: DesignerComponentDefinition[] = Object.entries(generatedMetadata)
|
||||
.filter(([name]) => !definedUiNames.has(name))
|
||||
.map(([name, metadata]) => ({
|
||||
name,
|
||||
icon: 'Blocks',
|
||||
category: inferUiCategory(name),
|
||||
kind: 'ui',
|
||||
toolboxGroup:
|
||||
name === 'Table'
|
||||
? 'layout'
|
||||
: DESIGNER_DATA_COMPONENT_NAMES.has(name)
|
||||
? 'data'
|
||||
: 'ui',
|
||||
description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI component’i`,
|
||||
sourceFile: metadata.sourceFile,
|
||||
interfaceName: metadata.interfaceName,
|
||||
acceptsChildren: CONTAINER_NAMES.has(name),
|
||||
properties: generatedPropertiesToDesigner(metadata),
|
||||
hooks: [],
|
||||
}))
|
||||
.filter(([name]) => !definedUiNames.has(name) && !EXCLUDED_COMPONENTS.has(name))
|
||||
.map(([name, metadata]) => {
|
||||
const properties = generatedPropertiesToDesigner(metadata, undefined, name)
|
||||
const collectionProperty = getDesignerCollectionProperty(name)
|
||||
if (
|
||||
DESIGNER_DATA_COMPONENT_NAMES.has(name) &&
|
||||
!properties.some((property) => property.name === collectionProperty)
|
||||
) {
|
||||
properties.unshift(getCollectionPropertyDefinition(name, collectionProperty))
|
||||
}
|
||||
return {
|
||||
name,
|
||||
icon: 'Blocks',
|
||||
category: inferUiCategory(name),
|
||||
kind: 'ui' as const,
|
||||
toolboxGroup: getToolboxGroup(name),
|
||||
description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI component’i`,
|
||||
sourceFile: metadata.sourceFile,
|
||||
interfaceName: metadata.interfaceName,
|
||||
acceptsChildren: CONTAINER_NAMES.has(name),
|
||||
properties,
|
||||
hooks: [],
|
||||
}
|
||||
})
|
||||
const custom: DesignerComponentDefinition[] = customNames.map((name) => ({
|
||||
name,
|
||||
icon: 'Blocks',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { normalizeDesignerKeyList, type DesignerDocument, type DesignerNode } from './types'
|
||||
import {
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerTabSlotValue,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateProperty,
|
||||
isDesignerOptionComponent,
|
||||
normalizeDesignerKeyList,
|
||||
DESIGNER_DROPDOWN_PLACEHOLDER,
|
||||
type DesignerDocument,
|
||||
type DesignerNode,
|
||||
} from './types'
|
||||
|
||||
const safeIdentifier = (value: string) => {
|
||||
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
|
||||
|
|
@ -7,9 +17,7 @@ const safeIdentifier = (value: string) => {
|
|||
|
||||
const staticValueExpression = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('::')
|
||||
? `translate(${JSON.stringify(value)})`
|
||||
: JSON.stringify(value)
|
||||
return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
|
||||
}
|
||||
if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
|
|
@ -52,6 +60,7 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
|
|||
'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
|
||||
Select: { propertyName: 'value', eventName: 'onChange' },
|
||||
Steps: { propertyName: 'current', eventName: 'onChange' },
|
||||
Tabs: { propertyName: 'value', eventName: 'onChange' },
|
||||
Upload: { propertyName: 'fileList', eventName: 'onChange' },
|
||||
}
|
||||
const fallback =
|
||||
|
|
@ -84,6 +93,8 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
|
|||
) || null
|
||||
} else if (node.type === 'Menu') {
|
||||
initialValue = normalizeDesignerKeyList(initialValue)
|
||||
} else if (node.type === 'Tabs') {
|
||||
initialValue = initialValue === undefined || initialValue === null ? '' : String(initialValue)
|
||||
} else if (node.type === 'Pagination') {
|
||||
initialValue = Number(initialValue) || 1
|
||||
} else if (initialValue === undefined) {
|
||||
|
|
@ -100,11 +111,38 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
|
|||
}
|
||||
|
||||
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
|
||||
(['Select', 'AutoComplete'].includes(node.type) && propertyName === 'options') ||
|
||||
(['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(node.type) &&
|
||||
propertyName === 'items')
|
||||
isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type)
|
||||
|
||||
const propsToCode = (node: DesignerNode, itemVariable?: string) => {
|
||||
/**
|
||||
* Options expression for a data component: the endpoint binding mapped through
|
||||
* `toSelectOptions` (honouring the configured label/value columns), otherwise the
|
||||
* static collection maintained in the designer.
|
||||
*/
|
||||
const optionCollectionExpression = (node: DesignerNode, itemVariable?: string) => {
|
||||
const collectionProperty = getDesignerCollectionProperty(node.type)
|
||||
const binding = node.bindings?.[collectionProperty]
|
||||
const boundExpression = binding?.sourceId
|
||||
? bindingExpression(node, collectionProperty, itemVariable)
|
||||
: ''
|
||||
if (boundExpression) {
|
||||
return `toSelectOptions(${boundExpression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
|
||||
}
|
||||
const staticOptions = node.props[collectionProperty]
|
||||
return staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
|
||||
}
|
||||
|
||||
/** Node stripped of its collection prop/binding; those are rendered as children. */
|
||||
const withoutCollection = (node: DesignerNode) => {
|
||||
const collectionProperty = getDesignerCollectionProperty(node.type)
|
||||
const props = { ...node.props }
|
||||
const bindings = { ...(node.bindings || {}) }
|
||||
delete props[collectionProperty]
|
||||
delete bindings[collectionProperty]
|
||||
return { node: { ...node, props, bindings }, props, bindings }
|
||||
}
|
||||
|
||||
const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: string[] = []) => {
|
||||
const omitted = new Set(omitProperties)
|
||||
const runtimeState = getRuntimeStateSpec(node)
|
||||
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
|
||||
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim())
|
||||
|
|
@ -113,31 +151,39 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
|
|||
([key, value]) =>
|
||||
key !== 'children' &&
|
||||
key !== runtimeState?.propertyName &&
|
||||
!omitted.has(key) &&
|
||||
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
|
||||
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
|
||||
!node.bindings?.[key]?.sourceId &&
|
||||
value !== '' &&
|
||||
value !== undefined,
|
||||
)
|
||||
.map(([key, value]) => {
|
||||
return `${key}=${serializeValue(value)}`
|
||||
})
|
||||
.map(([key, value]) =>
|
||||
// Dates live in the document as ISO strings; pickers need Date instances.
|
||||
isDesignerDateProperty(node.type, key)
|
||||
? `${key}={toDesignerDate(${staticValueExpression(value)})}`
|
||||
: `${key}=${serializeValue(value)}`,
|
||||
)
|
||||
|
||||
Object.keys(node.bindings || {}).forEach((propertyName) => {
|
||||
if (propertyName === 'children') return
|
||||
const expression = bindingExpression(node, propertyName, itemVariable)
|
||||
if (expression) {
|
||||
const propertyExpression =
|
||||
isOptionCollectionProperty(node, propertyName)
|
||||
? `toSelectOptions(${expression}, ${JSON.stringify(node.bindings.options?.labelPath || '')}, ${JSON.stringify(node.bindings.options?.valuePath || '')})`
|
||||
: expression
|
||||
// Label/value columns come from the bound property itself; the collection
|
||||
// is named `items` on some data components and `options` on others.
|
||||
const binding = node.bindings?.[propertyName]
|
||||
const propertyExpression = isOptionCollectionProperty(node, propertyName)
|
||||
? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
|
||||
: expression
|
||||
props.push(`${propertyName}={${propertyExpression}}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
|
||||
|
||||
if (runtimeState) props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
|
||||
if (runtimeState && !omitted.has(runtimeState.propertyName)) {
|
||||
props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
|
||||
}
|
||||
|
||||
const handlerNames = new Set(
|
||||
Object.entries(node.events)
|
||||
|
|
@ -149,7 +195,10 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
|
|||
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`),
|
||||
)
|
||||
|
||||
return props.length ? ` ${props.join(' ')}` : ''
|
||||
// Spread first so anything configured on the node overrides the defaults.
|
||||
const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : []
|
||||
const allProps = [...leading, ...props]
|
||||
return allProps.length ? ` ${allProps.join(' ')}` : ''
|
||||
}
|
||||
|
||||
const indent = (text: string, level: number) =>
|
||||
|
|
@ -199,10 +248,7 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin
|
|||
|
||||
if (node.type === 'Table') {
|
||||
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
|
||||
const columnCount = Math.min(
|
||||
20,
|
||||
Math.max(1, Math.floor(Number(node.props.columnCount) || 3)),
|
||||
)
|
||||
const columnCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.columnCount) || 3)))
|
||||
const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
|
||||
const slot = `table:${rowIndex}:${columnIndex}`
|
||||
|
|
@ -337,75 +383,118 @@ ${indent(')}', level + 1)}`
|
|||
}
|
||||
|
||||
if (node.type === 'Menu') {
|
||||
const { items: staticOptions = [], variant: _variant, ...menuProps } = node.props
|
||||
const { node: menuNode, props: menuProps } = withoutCollection(node)
|
||||
delete menuProps.variant
|
||||
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
|
||||
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
|
||||
const { items: optionsBinding, ...menuBindings } = node.bindings || {}
|
||||
const menuNode = { ...node, props: menuProps, bindings: menuBindings }
|
||||
const menuPropsCode = propsToCode(menuNode, itemVariable)
|
||||
const binding = optionsBinding?.sourceId
|
||||
? bindingExpression(node, 'items', itemVariable)
|
||||
: ''
|
||||
const optionsExpression = binding
|
||||
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
|
||||
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
|
||||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||||
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
|
||||
}
|
||||
|
||||
if (node.type === 'Dropdown') {
|
||||
const { items: staticOptions = [], ...dropdownProps } = node.props
|
||||
const { items: optionsBinding, ...dropdownBindings } = node.bindings || {}
|
||||
const dropdownPropsCode = propsToCode(
|
||||
{ ...node, props: dropdownProps, bindings: dropdownBindings },
|
||||
itemVariable,
|
||||
)
|
||||
const binding = optionsBinding?.sourceId
|
||||
? bindingExpression(node, 'items', itemVariable)
|
||||
const { node: dropdownNode, props: dropdownProps, bindings } = withoutCollection(node)
|
||||
// Rendered from the active option below, so `title` must not also be emitted
|
||||
// by propsToCode — whether it is static or bound to an endpoint field.
|
||||
const boundTitle = bindings.title?.sourceId
|
||||
? bindingExpression(node, 'title', itemVariable)
|
||||
: ''
|
||||
const optionsExpression = binding
|
||||
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
|
||||
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
|
||||
return `${indent(`<UiKit.Dropdown${dropdownPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
|
||||
const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '')
|
||||
delete dropdownProps.title
|
||||
delete bindings.title
|
||||
const dropdownPropsCode = propsToCode(dropdownNode, itemVariable)
|
||||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||||
const activeKeyExpression =
|
||||
getRuntimeStateSpec(node)?.stateName ||
|
||||
bindingExpression(node, 'activeKey', itemVariable) ||
|
||||
staticValueExpression(node.props.activeKey ?? '')
|
||||
const titleExpression = `toDropdownTitle(${optionsExpression}, ${activeKeyExpression}, ${fallbackTitleExpression})`
|
||||
return `${indent(`<UiKit.Dropdown${dropdownPropsCode} title={${titleExpression}}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
|
||||
}
|
||||
|
||||
if (node.type === 'Pagination') {
|
||||
const { items: staticOptions = [], ...paginationProps } = node.props
|
||||
const { items: optionsBinding, ...paginationBindings } = node.bindings || {}
|
||||
const paginationPropsCode = propsToCode(
|
||||
{ ...node, props: paginationProps, bindings: paginationBindings },
|
||||
itemVariable,
|
||||
)
|
||||
const binding = optionsBinding?.sourceId
|
||||
? bindingExpression(node, 'items', itemVariable)
|
||||
: ''
|
||||
const optionsExpression = binding
|
||||
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
|
||||
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
|
||||
const { node: paginationNode } = withoutCollection(node)
|
||||
const paginationPropsCode = propsToCode(paginationNode, itemVariable)
|
||||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||||
return indent(
|
||||
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
|
||||
level,
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'Tabs') {
|
||||
const { node: tabsNode } = withoutCollection(node)
|
||||
// `value` is emitted below, resolved against the (possibly async) tab list.
|
||||
const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'])
|
||||
const identifier = safeIdentifier(node.id)
|
||||
const optionsVariable = `tabItems_${identifier}`
|
||||
const activeVariable = `tabValue_${identifier}`
|
||||
const optionVariable = `tabItem_${identifier}`
|
||||
const activeStateExpression =
|
||||
getRuntimeStateSpec(node)?.stateName ||
|
||||
bindingExpression(node, 'value', itemVariable) ||
|
||||
staticValueExpression(node.props.value ?? '')
|
||||
const childrenVariable = `tabChildren_${identifier}`
|
||||
const fallbackVariable = `tabFallback_${identifier}`
|
||||
|
||||
// Children are assigned to a tab through a `tab:<value>` slot. Building a
|
||||
// lookup keyed by tab value keeps this working when the tab list itself is
|
||||
// only known at runtime (endpoint driven tabs).
|
||||
const slotGroups = new Map<string, DesignerNode[]>()
|
||||
node.children.forEach((child) => {
|
||||
const key = getDesignerTabSlotValue(child.slot)
|
||||
const group = slotGroups.get(key)
|
||||
if (group) group.push(child)
|
||||
else slotGroups.set(key, [child])
|
||||
})
|
||||
const fragmentFor = (children: DesignerNode[], fragmentLevel: number) =>
|
||||
`${indent('<>', fragmentLevel)}\n${children
|
||||
.map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable))
|
||||
.join('\n')}\n${indent('</>', fragmentLevel)}`
|
||||
|
||||
const slottedEntries = [...slotGroups]
|
||||
.filter(([key]) => key !== '')
|
||||
.map(
|
||||
([key, children]) =>
|
||||
`${indent(`${JSON.stringify(key)}: (`, level + 2)}\n${fragmentFor(children, level + 3)}\n${indent('),', level + 2)}`,
|
||||
)
|
||||
.join('\n')
|
||||
// Children saved before per-tab slots existed belong to the first tab.
|
||||
const fallbackChildren = slotGroups.get('') || []
|
||||
|
||||
return `${indent('{(() => {', level)}
|
||||
${indent(`const ${optionsVariable} = ${optionCollectionExpression(node, itemVariable)}`, level + 1)}
|
||||
${indent(`const ${activeVariable} = toTabValue(${optionsVariable}, ${activeStateExpression})`, level + 1)}
|
||||
${indent(`const ${childrenVariable} = {`, level + 1)}
|
||||
${slottedEntries}
|
||||
${indent('}', level + 1)}
|
||||
${indent(`const ${fallbackVariable} = (`, level + 1)}
|
||||
${fallbackChildren.length ? fragmentFor(fallbackChildren, level + 2) : indent('null', level + 2)}
|
||||
${indent(')', level + 1)}
|
||||
${indent('return (', level + 1)}
|
||||
${indent(`<UiKit.Tabs${tabsPropsCode} value={${activeVariable}}>`, level + 2)}
|
||||
${indent('<UiKit.Tabs.TabList>', level + 3)}
|
||||
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => <UiKit.Tabs.TabNav key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)} disabled={Boolean(${optionVariable}.disabled)}>{String(${optionVariable}.label ?? ${optionVariable}.value ?? \`Sekme \${tabIndex + 1}\`)}</UiKit.Tabs.TabNav>)}`, level + 4)}
|
||||
${indent('</UiKit.Tabs.TabList>', level + 3)}
|
||||
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => (`, level + 3)}
|
||||
${indent(`<UiKit.Tabs.TabContent key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)}>`, level + 4)}
|
||||
${indent(`{${optionVariable}.content ? <div>{String(${optionVariable}.content)}</div> : null}`, level + 5)}
|
||||
${indent(`{${childrenVariable}[String(${optionVariable}.value ?? tabIndex)] ?? null}`, level + 5)}
|
||||
${indent(`{tabIndex === 0 ? ${fallbackVariable} : null}`, level + 5)}
|
||||
${indent('</UiKit.Tabs.TabContent>', level + 4)}
|
||||
${indent('))}', level + 3)}
|
||||
${indent('</UiKit.Tabs>', level + 2)}
|
||||
${indent(')', level + 1)}
|
||||
${indent('})()}', level)}`
|
||||
}
|
||||
|
||||
if (node.type === 'Radio.Group') {
|
||||
const {
|
||||
items: staticOptions = [],
|
||||
checked: _checked,
|
||||
defaultChecked: _defaultChecked,
|
||||
readOnly: _readOnly,
|
||||
...groupProps
|
||||
} = node.props
|
||||
const { items: optionsBinding, ...groupBindings } = node.bindings || {}
|
||||
const groupPropsCode = propsToCode(
|
||||
{ ...node, props: groupProps, bindings: groupBindings },
|
||||
itemVariable,
|
||||
)
|
||||
const binding = optionsBinding?.sourceId
|
||||
? bindingExpression(node, 'items', itemVariable)
|
||||
: ''
|
||||
const optionsExpression = binding
|
||||
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
|
||||
: staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
|
||||
const { node: groupNode, props: groupProps } = withoutCollection(node)
|
||||
delete groupProps.checked
|
||||
delete groupProps.defaultChecked
|
||||
delete groupProps.readOnly
|
||||
const groupPropsCode = propsToCode(groupNode, itemVariable)
|
||||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||||
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
|
||||
}
|
||||
|
||||
|
|
@ -445,21 +534,27 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
const runtimeStateHooks: string[] = []
|
||||
let hasSelect = false
|
||||
let hasDataTable = false
|
||||
let hasDropdown = false
|
||||
let hasSelectComponent = false
|
||||
let hasDatePicker = false
|
||||
let hasTabs = false
|
||||
|
||||
const visit = (nodes: DesignerNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (
|
||||
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(
|
||||
node.type,
|
||||
)
|
||||
) {
|
||||
hasSelect = true
|
||||
}
|
||||
if (isDesignerOptionComponent(node.type)) hasSelect = true
|
||||
if (node.type === 'Select') hasSelectComponent = true
|
||||
if (node.type === 'Dropdown') hasDropdown = true
|
||||
if (node.type === 'Grid') hasDataTable = true
|
||||
if (node.type === 'Tabs') hasTabs = true
|
||||
if (isDesignerDateComponent(node.type)) hasDatePicker = true
|
||||
const runtimeState = getRuntimeStateSpec(node)
|
||||
if (runtimeState) {
|
||||
// Date pickers hold a Date in state but an ISO string in the document.
|
||||
const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName)
|
||||
? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)})`
|
||||
: JSON.stringify(runtimeState.initialValue)
|
||||
runtimeStateHooks.push(
|
||||
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${JSON.stringify(runtimeState.initialValue)})`,
|
||||
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
|
||||
)
|
||||
}
|
||||
const eventNames = new Set(
|
||||
|
|
@ -478,19 +573,21 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
? `${runtimeState.setterName}(valueOrEvent ?? "")`
|
||||
: node.type === 'Menu'
|
||||
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
|
||||
: node.type === 'Dropdown'
|
||||
: node.type === 'Tabs'
|
||||
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
|
||||
: node.type === 'Pagination'
|
||||
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
|
||||
: node.type === 'Steps' || node.type === 'ImageViewer'
|
||||
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
|
||||
: node.type === 'Upload'
|
||||
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
|
||||
: runtimeState.propertyName === 'checked'
|
||||
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
|
||||
: node.type === 'Input' || node.kind === 'html'
|
||||
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
|
||||
: `${runtimeState.setterName}(valueOrEvent)`
|
||||
: node.type === 'Dropdown'
|
||||
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
|
||||
: node.type === 'Pagination'
|
||||
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
|
||||
: node.type === 'Steps' || node.type === 'ImageViewer'
|
||||
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
|
||||
: node.type === 'Upload'
|
||||
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
|
||||
: runtimeState.propertyName === 'checked'
|
||||
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
|
||||
: node.type === 'Input' || node.kind === 'html'
|
||||
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
|
||||
: `${runtimeState.setterName}(valueOrEvent)`
|
||||
: ''
|
||||
const eventDeclaration =
|
||||
node.type === 'Checkbox' && eventName === 'onChange'
|
||||
|
|
@ -520,6 +617,44 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
})
|
||||
}`
|
||||
: ''
|
||||
// Dropdown paints its toggle from `title`; `activeKey` only marks the item
|
||||
// inside the closed menu, so the label of the active option is resolved here.
|
||||
const dropdownHelpers = hasDropdown
|
||||
? ` const toDropdownTitle = (options, activeKey, fallbackTitle) => {
|
||||
const key = activeKey === undefined || activeKey === null ? "" : String(activeKey)
|
||||
const selected = key ? options.find((option) => String(option?.value ?? "") === key) : undefined
|
||||
if (selected) return String(selected.label ?? selected.value ?? key)
|
||||
return String(fallbackTitle ?? "") || ${JSON.stringify(DESIGNER_DROPDOWN_PLACEHOLDER)}
|
||||
}`
|
||||
: ''
|
||||
// Falls back to the first tab while an endpoint driven tab list is still empty
|
||||
// or when the stored value no longer exists.
|
||||
const tabHelpers = hasTabs
|
||||
? ` const toTabValue = (options, value) => {
|
||||
const key = value === undefined || value === null ? "" : String(value)
|
||||
if (key && options.some((option) => String(option?.value ?? "") === key)) return key
|
||||
return options.length ? String(options[0]?.value ?? "") : ""
|
||||
}`
|
||||
: ''
|
||||
// Pickers check `instanceof Date`, but the document only stores ISO strings.
|
||||
const dateHelpers = hasDatePicker
|
||||
? ` const toDesignerDate = (value) => {
|
||||
if (Array.isArray(value)) return value.map(toDesignerDate)
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
||||
if (typeof value !== "string" || !value.trim()) return null
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}`
|
||||
: ''
|
||||
// Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table,
|
||||
// Card) would cut it off. A body portal keeps the menu fully visible.
|
||||
const selectMenuHelpers = hasSelectComponent
|
||||
? ` const selectMenuProps = {
|
||||
menuPosition: "fixed",
|
||||
menuPortalTarget: typeof window === "undefined" ? null : window.document.body,
|
||||
styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) },
|
||||
}`
|
||||
: ''
|
||||
const dataHelpers =
|
||||
dataSources.length || hasDataTable
|
||||
? ` const getByPath = (value, path) => {
|
||||
|
|
@ -555,5 +690,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
|
||||
const designerBackup = encodeURIComponent(JSON.stringify(document))
|
||||
|
||||
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
|
||||
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dropdownHelpers, selectMenuHelpers, dateHelpers, tabHelpers, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,135 @@ export interface DesignerDocument {
|
|||
dataSources: DesignerDataSource[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the toolbox `data` category. Every component listed
|
||||
* here is managed by the designer's static/endpoint data panel, so adding an
|
||||
* entry is all that is needed to make a new data component fully configurable.
|
||||
*
|
||||
* - `option` → label/value pair collections (Select, Dropdown, …)
|
||||
* - `tabular` → row collections rendered as selectable columns (Grid)
|
||||
*/
|
||||
export type DesignerDataKind = 'option' | 'tabular'
|
||||
|
||||
export interface DesignerDataComponentMeta {
|
||||
dataKind: DesignerDataKind
|
||||
/** Node prop holding the collection, both for static values and bindings. */
|
||||
collectionProperty: 'items' | 'options'
|
||||
}
|
||||
|
||||
export const DESIGNER_DATA_COMPONENTS: Record<string, DesignerDataComponentMeta> = {
|
||||
AutoComplete: { dataKind: 'option', collectionProperty: 'options' },
|
||||
Dropdown: { dataKind: 'option', collectionProperty: 'items' },
|
||||
Grid: { dataKind: 'tabular', collectionProperty: 'items' },
|
||||
Menu: { dataKind: 'option', collectionProperty: 'items' },
|
||||
Pagination: { dataKind: 'option', collectionProperty: 'items' },
|
||||
'Radio.Group': { dataKind: 'option', collectionProperty: 'items' },
|
||||
Select: { dataKind: 'option', collectionProperty: 'options' },
|
||||
Tabs: { dataKind: 'option', collectionProperty: 'items' },
|
||||
}
|
||||
|
||||
export const DESIGNER_DATA_COMPONENT_NAMES = new Set(Object.keys(DESIGNER_DATA_COMPONENTS))
|
||||
|
||||
export const getDesignerDataComponent = (type?: string) =>
|
||||
type ? DESIGNER_DATA_COMPONENTS[type] : undefined
|
||||
|
||||
export const isDesignerDataComponent = (type?: string) => Boolean(getDesignerDataComponent(type))
|
||||
|
||||
export const isDesignerOptionComponent = (type?: string) =>
|
||||
getDesignerDataComponent(type)?.dataKind === 'option'
|
||||
|
||||
export const isDesignerTabularComponent = (type?: string) =>
|
||||
getDesignerDataComponent(type)?.dataKind === 'tabular'
|
||||
|
||||
/** Collection prop name; `items` is the fallback for non-data components. */
|
||||
export const getDesignerCollectionProperty = (type?: string) =>
|
||||
getDesignerDataComponent(type)?.collectionProperty ?? 'items'
|
||||
|
||||
/**
|
||||
* Date pickers expect real `Date` instances (they check `instanceof Date`), but a
|
||||
* designer document has to stay JSON serialisable. Dates are therefore stored as
|
||||
* ISO strings and revived right before rendering.
|
||||
*/
|
||||
export const DESIGNER_DATE_COMPONENTS = new Set([
|
||||
'Calendar',
|
||||
'DatePicker',
|
||||
'DatePickerRange',
|
||||
'DateTimepicker',
|
||||
'RangeCalendar',
|
||||
])
|
||||
|
||||
const DESIGNER_DATE_PROPERTIES = new Set([
|
||||
'value',
|
||||
'defaultValue',
|
||||
'defaultMonth',
|
||||
'minDate',
|
||||
'maxDate',
|
||||
])
|
||||
|
||||
export const isDesignerDateComponent = (type?: string) =>
|
||||
Boolean(type && DESIGNER_DATE_COMPONENTS.has(type))
|
||||
|
||||
export const isDesignerDateProperty = (type?: string, propertyName?: string) =>
|
||||
isDesignerDateComponent(type) && !!propertyName && DESIGNER_DATE_PROPERTIES.has(propertyName)
|
||||
|
||||
/** Stored value (ISO string) → `Date` for rendering. */
|
||||
export const toDesignerDate = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(toDesignerDate)
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
/** `Date` from a picker → ISO string for storage. */
|
||||
export const fromDesignerDate = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(fromDesignerDate)
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString()
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
/** Tabs children are assigned to a tab through this slot, like Table cells. */
|
||||
export const DESIGNER_TAB_SLOT_PREFIX = 'tab:'
|
||||
|
||||
export const getDesignerTabSlot = (tabValue: string) => `${DESIGNER_TAB_SLOT_PREFIX}${tabValue}`
|
||||
|
||||
/** Tab key a child belongs to; empty when it predates per-tab slots. */
|
||||
export const getDesignerTabSlotValue = (slot?: string) =>
|
||||
slot?.startsWith(DESIGNER_TAB_SLOT_PREFIX) ? slot.slice(DESIGNER_TAB_SLOT_PREFIX.length) : ''
|
||||
|
||||
/**
|
||||
* Active tab key. Falls back to the first tab whenever nothing is selected yet or
|
||||
* the stored value is not in the list — which is the normal case while endpoint
|
||||
* driven tabs are still loading.
|
||||
*/
|
||||
export const resolveDesignerTabValue = (
|
||||
options: Array<Record<string, unknown>>,
|
||||
value: unknown,
|
||||
) => {
|
||||
const key = value === undefined || value === null ? '' : String(value)
|
||||
if (key && options.some((option) => String(option?.value ?? '') === key)) return key
|
||||
return options.length ? String(options[0]?.value ?? '') : ''
|
||||
}
|
||||
|
||||
export const DESIGNER_DROPDOWN_PLACEHOLDER = 'Seçiniz'
|
||||
|
||||
/**
|
||||
* Dropdown renders its toggle from `title` only; `activeKey` merely highlights an
|
||||
* item inside the closed menu. Without this the component looks untouched after a
|
||||
* selection, so the toggle label is resolved from the selected option.
|
||||
*/
|
||||
export const resolveDesignerDropdownTitle = (
|
||||
options: Array<Record<string, unknown>>,
|
||||
activeKey: unknown,
|
||||
fallbackTitle: unknown,
|
||||
) => {
|
||||
const key = activeKey === undefined || activeKey === null ? '' : String(activeKey)
|
||||
const selected = key ? options.find((option) => String(option?.value ?? '') === key) : undefined
|
||||
if (selected) return String(selected.label ?? selected.value ?? key)
|
||||
const title = fallbackTitle === undefined || fallbackTitle === null ? '' : String(fallbackTitle)
|
||||
return title || DESIGNER_DROPDOWN_PLACEHOLDER
|
||||
}
|
||||
|
||||
export interface DesignerPropertyInfo extends PropertyInfo {
|
||||
tsType?: string
|
||||
required?: boolean
|
||||
|
|
@ -130,9 +259,8 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
|
|||
|
||||
if (typeof candidate === 'string') {
|
||||
const rawValue = candidate.trim()
|
||||
const jsonValue = rawValue.startsWith('{') && rawValue.endsWith('}')
|
||||
? rawValue.slice(1, -1).trim()
|
||||
: rawValue
|
||||
const jsonValue =
|
||||
rawValue.startsWith('{') && rawValue.endsWith('}') ? rawValue.slice(1, -1).trim() : rawValue
|
||||
|
||||
try {
|
||||
candidate = JSON.parse(jsonValue)
|
||||
|
|
@ -144,9 +272,7 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
|
|||
if (!Array.isArray(candidate)) return []
|
||||
|
||||
return candidate
|
||||
.filter((item): item is string | number =>
|
||||
typeof item === 'string' || typeof item === 'number',
|
||||
)
|
||||
.filter((item): item is string | number => typeof item === 'string' || typeof item === 'number')
|
||||
.map(String)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import type {
|
|||
} from '@/proxy/developerKit/models'
|
||||
import { developerKitService } from '@/services/developerKit.service'
|
||||
import apiService from '@/services/api.service'
|
||||
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
||||
import type { DesignerDocument } from '@/components/visualDesigner/types'
|
||||
import ErrorBoundary from '@/components/componentEditor/ErrorBoundary'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
|
|
@ -17,7 +16,16 @@ import React, {
|
|||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
buildRuntimeScope,
|
||||
compileComponentBundle,
|
||||
getCompilationSignature,
|
||||
toErrorMessage,
|
||||
type BabelLike,
|
||||
type RuntimeComponent,
|
||||
} from './componentRuntime'
|
||||
|
||||
interface ComponentProps {
|
||||
[key: string]: unknown
|
||||
|
|
@ -27,6 +35,10 @@ interface ComponentContextType {
|
|||
components: CustomComponent[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
/** True while the runtime bundle is being (re)compiled. */
|
||||
compiling: boolean
|
||||
/** Compilation/initialization errors keyed by component name. */
|
||||
componentErrors: Record<string, string>
|
||||
addComponent: (component: CreateUpdateCustomComponentDto) => Promise<void>
|
||||
updateComponent: (id: string, component: CreateUpdateCustomComponentDto) => Promise<void>
|
||||
deleteComponent: (id: string) => Promise<void>
|
||||
|
|
@ -34,18 +46,15 @@ interface ComponentContextType {
|
|||
getComponentByName: (name: string) => CustomComponent | undefined
|
||||
refreshComponents: () => Promise<void>
|
||||
refreshComponent: (id: string) => Promise<CustomComponent | undefined>
|
||||
// Manual registered components
|
||||
/** Natively implemented components exposed to dynamic routes by name. */
|
||||
registeredComponents: Record<string, React.ComponentType<unknown>>
|
||||
registerComponent: (name: string, component: React.ComponentType<unknown>) => void
|
||||
// Database compiled components
|
||||
renderComponent: (name: string, props?: ComponentProps) => React.ReactNode
|
||||
compileAndRender: (code: string, props?: ComponentProps) => React.ReactNode
|
||||
isComponentRegistered: (name: string) => boolean
|
||||
getRegisteredComponents: () => string[]
|
||||
getComponentCode: (name: string) => string | null
|
||||
}
|
||||
|
||||
const ComponentContext = createContext<ComponentContextType | undefined>(undefined)
|
||||
|
||||
const componentsChangedChannel =
|
||||
typeof BroadcastChannel === 'undefined'
|
||||
? undefined
|
||||
|
|
@ -55,10 +64,7 @@ const notifyComponentsChanged = () => {
|
|||
componentsChangedChannel?.postMessage({ changedAt: Date.now() })
|
||||
}
|
||||
|
||||
const haveSameComponentRevision = (
|
||||
current: CustomComponent[],
|
||||
next: CustomComponent[],
|
||||
) =>
|
||||
const haveSameComponentRevision = (current: CustomComponent[], next: CustomComponent[]) =>
|
||||
current.length === next.length &&
|
||||
current.every((component, index) => {
|
||||
const candidate = next[index]
|
||||
|
|
@ -75,68 +81,9 @@ const haveSameComponentRevision = (
|
|||
)
|
||||
})
|
||||
|
||||
const normalizeComponentCodeForBundle = (code: string) =>
|
||||
code
|
||||
// Stored components run inside a shared function scope. Imports are supplied
|
||||
// by that scope and therefore cannot remain as nested ESM declarations.
|
||||
.replace(/^\s*import\s+(?:(?:type\s+)?[\s\S]*?\s+from\s+)?['"][^'"\r\n]+['"]\s*;?\s*$/gm, '')
|
||||
.replace(/\bexport\s+default\s+/g, '')
|
||||
.trim()
|
||||
|
||||
const toJavaScriptIdentifier = (value: string) => {
|
||||
const identifier = value.replace(/[^A-Za-z0-9_$]/g, '_')
|
||||
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Component_${identifier}`
|
||||
}
|
||||
|
||||
const getComponentRuntimeCode = (component: CustomComponent) => {
|
||||
try {
|
||||
const props = component.props ? JSON.parse(component.props) : null
|
||||
const document = props?.visualDesigner as DesignerDocument | undefined
|
||||
if (
|
||||
document?.version === 1 &&
|
||||
document.sourceMode === 'visual' &&
|
||||
Array.isArray(document.nodes)
|
||||
) {
|
||||
return generateDesignerCode(component.name, document)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Visual designer schema could not be read for ${component.name}:`, error)
|
||||
}
|
||||
return component.code
|
||||
}
|
||||
|
||||
const HelloWorldComponent: React.ComponentType<unknown> = () =>
|
||||
React.createElement('div', { className: 'p-6 bg-blue-50 rounded-lg' }, [
|
||||
React.createElement(
|
||||
'h1',
|
||||
{ key: 'title', className: 'text-2xl font-bold text-blue-900 mb-4' },
|
||||
'Hello World!',
|
||||
),
|
||||
React.createElement(
|
||||
'p',
|
||||
{ key: 'description', className: 'text-blue-700' },
|
||||
'Bu manuel kayıtlı bir komponent!',
|
||||
),
|
||||
])
|
||||
|
||||
const TestListComponent: React.ComponentType<unknown> = () =>
|
||||
React.createElement('div', { className: 'p-6 bg-green-50 rounded-lg' }, [
|
||||
React.createElement(
|
||||
'h1',
|
||||
{ key: 'title', className: 'text-2xl font-bold text-green-900 mb-4' },
|
||||
'Test List',
|
||||
),
|
||||
React.createElement(
|
||||
'p',
|
||||
{ key: 'description', className: 'text-green-700' },
|
||||
'Bu da test için kayıtlı komponent!',
|
||||
),
|
||||
])
|
||||
|
||||
const defaultRegisteredComponents: Record<string, React.ComponentType<unknown>> = {
|
||||
HelloWorld: HelloWorldComponent,
|
||||
TestList: TestListComponent,
|
||||
}
|
||||
const EMPTY_REGISTRY: Record<string, RuntimeComponent> = {}
|
||||
const EMPTY_NATIVE_REGISTRY: Record<string, React.ComponentType<unknown>> = {}
|
||||
const EMPTY_ERRORS: Record<string, string> = {}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useComponents = () => {
|
||||
|
|
@ -151,13 +98,26 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
const { translate } = useLocalization()
|
||||
const applicationConfig = useStoreState((state) => state.abpConfig?.config)
|
||||
const extraProperties = applicationConfig?.extraProperties
|
||||
|
||||
const [components, setComponents] = useState<CustomComponent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [registeredComponents, setRegisteredComponents] = useState(defaultRegisteredComponents)
|
||||
const [compiledComponents, setCompiledComponents] = useState<
|
||||
Record<string, React.ComponentType<ComponentProps>>
|
||||
>({})
|
||||
const [compiling, setCompiling] = useState(false)
|
||||
const [registeredComponents, setRegisteredComponents] =
|
||||
useState<Record<string, React.ComponentType<unknown>>>(EMPTY_NATIVE_REGISTRY)
|
||||
const [compiledComponents, setCompiledComponents] =
|
||||
useState<Record<string, RuntimeComponent>>(EMPTY_REGISTRY)
|
||||
const [componentErrors, setComponentErrors] = useState<Record<string, string>>(EMPTY_ERRORS)
|
||||
|
||||
// Keeps `translate` usable inside compiled components without forcing a
|
||||
// recompile every time the localization store updates.
|
||||
const translateRef = useRef(translate)
|
||||
translateRef.current = translate
|
||||
const stableTranslate = useCallback(
|
||||
(...args: Parameters<typeof translate>) => translateRef.current(...args),
|
||||
[],
|
||||
)
|
||||
|
||||
const refreshComponents = useCallback(async () => {
|
||||
if (!applicationConfig) return
|
||||
|
||||
|
|
@ -169,19 +129,11 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
haveSameComponentRevision(current, nextComponents) ? current : nextComponents,
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch components')
|
||||
setError(toErrorMessage(err))
|
||||
console.error('Failed to fetch components:', err)
|
||||
}
|
||||
}, [applicationConfig])
|
||||
|
||||
useEffect(() => {
|
||||
const initialComponents =
|
||||
(extraProperties?.customComponents as CustomComponentDto[] | undefined) || []
|
||||
setComponents((current) =>
|
||||
haveSameComponentRevision(current, initialComponents) ? current : initialComponents,
|
||||
)
|
||||
}, [extraProperties])
|
||||
|
||||
const refreshComponent = useCallback(
|
||||
async (id: string): Promise<CustomComponent | undefined> => {
|
||||
if (!applicationConfig) return undefined
|
||||
|
|
@ -197,7 +149,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
})
|
||||
return freshComponent
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch component')
|
||||
setError(toErrorMessage(err))
|
||||
console.error('Failed to fetch component:', err)
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -205,12 +157,21 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
[applicationConfig],
|
||||
)
|
||||
|
||||
// Components delivered with the application configuration are the initial set.
|
||||
useEffect(() => {
|
||||
const initialComponents =
|
||||
(extraProperties?.customComponents as CustomComponentDto[] | undefined) || []
|
||||
setComponents((current) =>
|
||||
haveSameComponentRevision(current, initialComponents) ? current : initialComponents,
|
||||
)
|
||||
}, [extraProperties])
|
||||
|
||||
// Keeps every open tab in sync after a component is created/updated/deleted.
|
||||
useEffect(() => {
|
||||
const handleComponentsChanged = () => void refreshComponents()
|
||||
|
||||
componentsChangedChannel?.addEventListener('message', handleComponentsChanged)
|
||||
return () =>
|
||||
componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
|
||||
return () => componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
|
||||
}, [refreshComponents])
|
||||
|
||||
const addComponent = useCallback(async (componentData: CreateUpdateCustomComponentDto) => {
|
||||
|
|
@ -221,7 +182,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
setComponents((prev) => [...prev, newComponent])
|
||||
notifyComponentsChanged()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create component')
|
||||
setError(toErrorMessage(err))
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -239,7 +200,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
)
|
||||
notifyComponentsChanged()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update component')
|
||||
setError(toErrorMessage(err))
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -256,7 +217,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
setComponents((prev) => prev.filter((component) => component.id !== id))
|
||||
notifyComponentsChanged()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete component')
|
||||
setError(toErrorMessage(err))
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -271,15 +232,21 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
() => new Map(components.map((component) => [component.name, component])),
|
||||
[components],
|
||||
)
|
||||
const activeComponentsByName = useMemo(
|
||||
|
||||
const compilableComponents = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
components
|
||||
.filter((component) => component.isActive)
|
||||
.map((component) => [component.name, component]),
|
||||
components.filter(
|
||||
(component) =>
|
||||
component.isActive && typeof component.name === 'string' && !!component.name.trim(),
|
||||
),
|
||||
[components],
|
||||
)
|
||||
// Only code/name/designer-schema changes require a recompile; description or
|
||||
// audit field updates must not throw the compiled bundle away.
|
||||
const compilationSignature = useMemo(
|
||||
() => getCompilationSignature(compilableComponents),
|
||||
[compilableComponents],
|
||||
)
|
||||
|
||||
const getComponent = useCallback((id: string) => componentsById.get(id), [componentsById])
|
||||
|
||||
|
|
@ -294,134 +261,24 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
)
|
||||
}, [])
|
||||
|
||||
// Component compilation functions (moved from ComponentRegistryProvider)
|
||||
const extractComponentInfo = useCallback((code: string, defaultName = '') => {
|
||||
try {
|
||||
const fcTypeMatch = code.match(/const\s+([A-Za-z]\w*)\s*:\s*React\.FC/)
|
||||
if (fcTypeMatch) return fcTypeMatch[1]
|
||||
const componentsToCompileRef = useRef(compilableComponents)
|
||||
componentsToCompileRef.current = compilableComponents
|
||||
|
||||
const functionMatch = code.match(/function\s+([A-Za-z]\w*)/)
|
||||
if (functionMatch) return functionMatch[1]
|
||||
|
||||
const arrowMatch = code.match(/const\s+([A-Za-z]\w*)\s*=/)
|
||||
if (arrowMatch) return arrowMatch[1]
|
||||
|
||||
const classMatch = code.match(/class\s+([A-Za-z]\w*)/)
|
||||
if (classMatch) return classMatch[1]
|
||||
|
||||
const exportMatch = code.match(/export\s+default\s+([A-Za-z]\w*)/)
|
||||
if (exportMatch) return exportMatch[1]
|
||||
|
||||
if (defaultName) return defaultName
|
||||
return 'DynamicComponent'
|
||||
} catch (err) {
|
||||
console.error('Error extracting component name:', err)
|
||||
return defaultName || 'DynamicComponent'
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Compile components when they change
|
||||
useEffect(() => {
|
||||
if (!components.length) {
|
||||
setCompiledComponents({})
|
||||
if (!compilationSignature) {
|
||||
setCompiledComponents(EMPTY_REGISTRY)
|
||||
setComponentErrors(EMPTY_ERRORS)
|
||||
setCompiling(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setCompiling(true)
|
||||
|
||||
const compileComponents = async () => {
|
||||
const compile = async () => {
|
||||
try {
|
||||
const activeComponents = components.filter(
|
||||
(component) => component.isActive && typeof component.name === 'string' && component.name.trim(),
|
||||
)
|
||||
|
||||
const invalidActiveComponents = components.filter(
|
||||
(component) => component.isActive && (typeof component.name !== 'string' || !component.name.trim()),
|
||||
)
|
||||
if (invalidActiveComponents.length) {
|
||||
console.warn('Adsız aktif komponent kayıtları derleme dışında bırakıldı.', invalidActiveComponents)
|
||||
}
|
||||
|
||||
if (!activeComponents.length) {
|
||||
setCompiledComponents({})
|
||||
return
|
||||
}
|
||||
|
||||
const componentInfos = activeComponents.map((comp, index) => {
|
||||
const name = comp.name
|
||||
const nameCapitalized = name.charAt(0).toUpperCase() + name.slice(1)
|
||||
const runtimeCode = getComponentRuntimeCode(comp)
|
||||
const referenceName = toJavaScriptIdentifier(nameCapitalized)
|
||||
|
||||
return {
|
||||
name: name,
|
||||
nameCapitalized: nameCapitalized,
|
||||
referenceName,
|
||||
bundleVariable: `__component_${index}`,
|
||||
internalName: extractComponentInfo(runtimeCode, referenceName),
|
||||
code: normalizeComponentCodeForBundle(runtimeCode),
|
||||
}
|
||||
})
|
||||
|
||||
// Create cross-referencing bundle
|
||||
const componentDeclarations = componentInfos
|
||||
.map((info) => `let ${info.bundleVariable};`)
|
||||
.join('\n')
|
||||
|
||||
const componentDefinitions = componentInfos
|
||||
.map((info) => {
|
||||
const declaredReferences = new Set<string>()
|
||||
const componentVariables = componentInfos
|
||||
.filter((other) => other.name !== info.name)
|
||||
.filter((other) => {
|
||||
if (
|
||||
other.referenceName === info.internalName ||
|
||||
declaredReferences.has(other.referenceName)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
declaredReferences.add(other.referenceName)
|
||||
return true
|
||||
})
|
||||
.map(
|
||||
(other) =>
|
||||
`const ${other.referenceName} = (componentProps) => React.createElement(${other.bundleVariable}, componentProps);`,
|
||||
)
|
||||
.join('\n ')
|
||||
|
||||
return `
|
||||
${info.bundleVariable} = (function() {
|
||||
${componentVariables}
|
||||
${info.code}
|
||||
return ${info.internalName};
|
||||
})();`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const componentBundle = componentDeclarations + '\n' + componentDefinitions
|
||||
|
||||
const bundledCode = `
|
||||
(function(React, AutoComplete, Alert, Avatar, Badge, Button, Calendar, Card, Checkbox, ConfigProvider, DatePicker, Dialog, Drawer, Dropdown, FormItem, FormContainer, Input, InputGroup, Menu, MenuItem, Notification, Pagination, Progress, Radio, RangeCalendar, ScrollBar, Segment, Select, Skeleton, Spinner, Steps, Switcher, Table, Tabs, Tag, TimeInput, Timeline, toast, Tooltip, Upload, PlatformViewHost, apiService, DOMPurify, axios, UiKit, translate) {
|
||||
const { useState, useEffect, useCallback, useMemo, useRef, createContext, useContext } = React;
|
||||
const componentRegistry = {};
|
||||
|
||||
${componentBundle}
|
||||
|
||||
${componentInfos
|
||||
.map(
|
||||
(info) => `
|
||||
componentRegistry[${JSON.stringify(info.name)}] = ${info.bundleVariable};
|
||||
componentRegistry[${JSON.stringify(info.nameCapitalized)}] = ${info.bundleVariable};
|
||||
`,
|
||||
)
|
||||
.join('\n')}
|
||||
|
||||
return componentRegistry;
|
||||
})(React, AutoComplete, Alert, Avatar, Badge, Button, Calendar, Card, Checkbox, ConfigProvider, DatePicker, Dialog, Drawer, Dropdown, FormItem, FormContainer, Input, InputGroup, Menu, MenuItem, Notification, Pagination, Progress, Radio, RangeCalendar, ScrollBar, Segment, Select, Skeleton, Spinner, Steps, Switcher, Table, Tabs, Tag, TimeInput, Timeline, toast, Tooltip, Upload, PlatformViewHost, apiService, DOMPurify, axios, UiKit, translate)
|
||||
`
|
||||
|
||||
// Babel is several megabytes and is only needed when an active runtime
|
||||
// component exists. Keep it out of the application startup bundle.
|
||||
// Babel and the UI kit are only needed when an active runtime component
|
||||
// exists. Keep them out of the application startup bundle.
|
||||
const [Babel, ui, { default: axios }, { default: PlatformViewHost }] = await Promise.all([
|
||||
import('@babel/standalone'),
|
||||
import('../components/ui'),
|
||||
|
|
@ -430,218 +287,86 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
])
|
||||
if (cancelled) return
|
||||
|
||||
const {
|
||||
AutoComplete,
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Calendar,
|
||||
Card,
|
||||
Checkbox,
|
||||
ConfigProvider,
|
||||
DatePicker,
|
||||
Dialog,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
FormItem,
|
||||
FormContainer,
|
||||
Input,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Notification,
|
||||
Pagination,
|
||||
Progress,
|
||||
Radio,
|
||||
RangeCalendar,
|
||||
ScrollBar,
|
||||
Segment,
|
||||
Select,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
Steps,
|
||||
Switcher,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
TimeInput,
|
||||
Timeline,
|
||||
toast,
|
||||
Tooltip,
|
||||
Upload,
|
||||
} = ui
|
||||
|
||||
const compiledBundle = Babel.transform(bundledCode, {
|
||||
presets: ['react', 'typescript'],
|
||||
filename: 'components-bundle.tsx',
|
||||
}).code
|
||||
|
||||
if (!compiledBundle) {
|
||||
throw new Error('Failed to compile components bundle')
|
||||
}
|
||||
|
||||
const componentsFactory = new Function(
|
||||
'React',
|
||||
'AutoComplete',
|
||||
'Alert',
|
||||
'Avatar',
|
||||
'Badge',
|
||||
'Button',
|
||||
'Calendar',
|
||||
'Card',
|
||||
'Checkbox',
|
||||
'ConfigProvider',
|
||||
'DatePicker',
|
||||
'Dialog',
|
||||
'Drawer',
|
||||
'Dropdown',
|
||||
'FormItem',
|
||||
'FormContainer',
|
||||
'Input',
|
||||
'InputGroup',
|
||||
'Menu',
|
||||
'MenuItem',
|
||||
'Notification',
|
||||
'Pagination',
|
||||
'Progress',
|
||||
'Radio',
|
||||
'RangeCalendar',
|
||||
'ScrollBar',
|
||||
'Segment',
|
||||
'Select',
|
||||
'Skeleton',
|
||||
'Spinner',
|
||||
'Steps',
|
||||
'Switcher',
|
||||
'Table',
|
||||
'Tabs',
|
||||
'Tag',
|
||||
'TimeInput',
|
||||
'Timeline',
|
||||
'toast',
|
||||
'Tooltip',
|
||||
'Upload',
|
||||
'PlatformViewHost',
|
||||
'apiService',
|
||||
'DOMPurify',
|
||||
'axios',
|
||||
'UiKit',
|
||||
'translate',
|
||||
`return ${compiledBundle}`,
|
||||
)
|
||||
|
||||
const compiledComponentsRegistry = componentsFactory(
|
||||
const scope = buildRuntimeScope({
|
||||
React,
|
||||
AutoComplete,
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Calendar,
|
||||
Card,
|
||||
Checkbox,
|
||||
ConfigProvider,
|
||||
DatePicker,
|
||||
Dialog,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
FormItem,
|
||||
FormContainer,
|
||||
Input,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Notification,
|
||||
Pagination,
|
||||
Progress,
|
||||
Radio,
|
||||
RangeCalendar,
|
||||
ScrollBar,
|
||||
Segment,
|
||||
Select,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
Steps,
|
||||
Switcher,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
TimeInput,
|
||||
Timeline,
|
||||
toast,
|
||||
Tooltip,
|
||||
Upload,
|
||||
...ui,
|
||||
UiKit: ui,
|
||||
PlatformViewHost,
|
||||
apiService,
|
||||
DOMPurify,
|
||||
axios,
|
||||
ui,
|
||||
translate,
|
||||
)
|
||||
translate: stableTranslate,
|
||||
})
|
||||
|
||||
if (!cancelled) setCompiledComponents(compiledComponentsRegistry)
|
||||
} catch (error) {
|
||||
console.error('Error compiling components bundle:', error)
|
||||
if (!cancelled) setCompiledComponents({})
|
||||
const { registry, errors } = compileComponentBundle(
|
||||
componentsToCompileRef.current,
|
||||
scope,
|
||||
Babel as unknown as BabelLike,
|
||||
)
|
||||
if (cancelled) return
|
||||
|
||||
setCompiledComponents(registry)
|
||||
setComponentErrors(errors)
|
||||
} catch (err) {
|
||||
console.error('Error compiling components bundle:', err)
|
||||
if (!cancelled) {
|
||||
setCompiledComponents(EMPTY_REGISTRY)
|
||||
setComponentErrors(EMPTY_ERRORS)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setCompiling(false)
|
||||
}
|
||||
}
|
||||
|
||||
void compileComponents()
|
||||
void compile()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [components, extractComponentInfo, translate])
|
||||
}, [compilationSignature, stableTranslate])
|
||||
|
||||
// Render functions
|
||||
const renderComponent = useCallback(
|
||||
(name: string, props: ComponentProps = {}) => {
|
||||
if (compiledComponents[name]) {
|
||||
const Component = compiledComponents[name]
|
||||
return <Component {...props} />
|
||||
const Component = compiledComponents[name]
|
||||
if (Component) {
|
||||
// A crash inside a runtime component must not take the shell down.
|
||||
return (
|
||||
<ErrorBoundary key={name}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const component = activeComponentsByName.get(name)
|
||||
if (!component) {
|
||||
console.error(`Component not found: ${name}`)
|
||||
const compileError = componentErrors[name]
|
||||
if (compileError) {
|
||||
return (
|
||||
<div className="p-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700">
|
||||
<div className="text-sm">Component not found: {name}</div>
|
||||
<div className="font-semibold text-sm">{name}</div>
|
||||
<div className="text-sm whitespace-pre-wrap">{compileError}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
if (compiling) return null
|
||||
|
||||
console.error(`Component not found: ${name}`)
|
||||
return (
|
||||
<div className="p-4 border-2 border-red-300 rounded-lg bg-red-50 text-red-700">
|
||||
<div className="text-sm">Component not found: {name}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[activeComponentsByName, compiledComponents],
|
||||
[compiledComponents, componentErrors, compiling],
|
||||
)
|
||||
|
||||
const compileAndRender = useCallback((code: string) => {
|
||||
if (!code?.trim()) return null
|
||||
// Simplified version - can be extended later
|
||||
return <div>Code compilation not implemented yet</div>
|
||||
}, [])
|
||||
const activeComponentNames = useMemo(
|
||||
() => new Set(compilableComponents.map((component) => component.name)),
|
||||
[compilableComponents],
|
||||
)
|
||||
|
||||
const isComponentRegistered = useCallback(
|
||||
(name: string) => {
|
||||
return activeComponentsByName.has(name) || !!compiledComponents[name]
|
||||
},
|
||||
[activeComponentsByName, compiledComponents],
|
||||
)
|
||||
|
||||
const getRegisteredComponents = useCallback(() => {
|
||||
const dbComponents = [...activeComponentsByName.keys()]
|
||||
const compiledNames = Object.keys(compiledComponents)
|
||||
return [...new Set([...dbComponents, ...compiledNames])]
|
||||
}, [activeComponentsByName, compiledComponents])
|
||||
|
||||
const getComponentCode = useCallback(
|
||||
(name: string) => {
|
||||
const component = componentsByName.get(name)
|
||||
return component ? component.code : null
|
||||
},
|
||||
[componentsByName],
|
||||
(name: string) => activeComponentNames.has(name) || !!compiledComponents[name],
|
||||
[activeComponentNames, compiledComponents],
|
||||
)
|
||||
|
||||
const value = useMemo<ComponentContextType>(
|
||||
|
|
@ -649,6 +374,8 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
components,
|
||||
loading,
|
||||
error,
|
||||
compiling,
|
||||
componentErrors,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
deleteComponent,
|
||||
|
|
@ -659,15 +386,14 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
registeredComponents,
|
||||
registerComponent,
|
||||
renderComponent,
|
||||
compileAndRender,
|
||||
isComponentRegistered,
|
||||
getRegisteredComponents,
|
||||
getComponentCode,
|
||||
}),
|
||||
[
|
||||
components,
|
||||
loading,
|
||||
error,
|
||||
compiling,
|
||||
componentErrors,
|
||||
addComponent,
|
||||
updateComponent,
|
||||
deleteComponent,
|
||||
|
|
@ -678,10 +404,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
registeredComponents,
|
||||
registerComponent,
|
||||
renderComponent,
|
||||
compileAndRender,
|
||||
isComponentRegistered,
|
||||
getRegisteredComponents,
|
||||
getComponentCode,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
BIN
ui/src/contexts/componentRuntime.ts
Normal file
BIN
ui/src/contexts/componentRuntime.ts
Normal file
Binary file not shown.
|
|
@ -22,10 +22,17 @@ import { Helmet } from 'react-helmet'
|
|||
import { Button } from '@/components/ui'
|
||||
import Dialog from '@/components/ui/Dialog'
|
||||
import ComponentEditor from './ComponentEditor'
|
||||
import { parseComponentDependencies } from '@/contexts/componentRuntime'
|
||||
|
||||
const ComponentManager: React.FC = () => {
|
||||
const { components, loading, updateComponent, deleteComponent, refreshComponents } =
|
||||
useComponents()
|
||||
const {
|
||||
components,
|
||||
loading,
|
||||
componentErrors,
|
||||
updateComponent,
|
||||
deleteComponent,
|
||||
refreshComponents,
|
||||
} = useComponents()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
|
@ -155,7 +162,7 @@ const ComponentManager: React.FC = () => {
|
|||
onClick={() => void handleRefresh()}
|
||||
>
|
||||
<FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} />
|
||||
Yenile
|
||||
{translate('::App.Platform.Refresh')}
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
|
|
@ -200,16 +207,8 @@ const ComponentManager: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2">
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(component.dependencies ?? '[]')
|
||||
return Array.isArray(parsed) && parsed.length > 0
|
||||
? `${parsed.join(', ')}`
|
||||
: translate('::App.DeveloperKit.Component.NoDependencies')
|
||||
} catch {
|
||||
return translate('::App.DeveloperKit.Component.NoDependencies')
|
||||
}
|
||||
})()}
|
||||
{parseComponentDependencies(component.dependencies).join(', ') ||
|
||||
translate('::App.DeveloperKit.Component.NoDependencies')}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2">
|
||||
|
|
@ -221,6 +220,15 @@ const ComponentManager: React.FC = () => {
|
|||
{component.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{componentErrors[component.name] && (
|
||||
<p
|
||||
className="text-xs text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded px-2 py-1 mb-2 break-words"
|
||||
title={componentErrors[component.name]}
|
||||
>
|
||||
{componentErrors[component.name]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sağ taraf */}
|
||||
|
|
@ -333,8 +341,8 @@ const ComponentManager: React.FC = () => {
|
|||
{!searchTerm && filterActive === 'all' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorComponentId(null)}
|
||||
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
onClick={() => setEditorComponentId(null)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
{translate('::App.DeveloperKit.Component.Empty.Initial.Action')}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
FaArrowLeft,
|
||||
FaBolt,
|
||||
FaCheckCircle,
|
||||
FaArrowDown,
|
||||
FaArrowUp,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaCode,
|
||||
|
|
@ -46,7 +48,10 @@ import {
|
|||
createDesignerId,
|
||||
createEmptyDesignerDocument,
|
||||
findDesignerNode,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerValueByPath,
|
||||
isDesignerOptionComponent,
|
||||
isDesignerTabularComponent,
|
||||
resolveDesignerResponse,
|
||||
walkDesignerNodes,
|
||||
type DesignerComponentDefinition,
|
||||
|
|
@ -95,21 +100,11 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([
|
|||
'disabled',
|
||||
'loading',
|
||||
])
|
||||
const OPTION_DATA_COMPONENT_NAMES = new Set([
|
||||
'Select',
|
||||
'AutoComplete',
|
||||
'Menu',
|
||||
'Dropdown',
|
||||
'Pagination',
|
||||
'Radio.Group',
|
||||
])
|
||||
const TABULAR_DATA_COMPONENT_NAMES = new Set(['Grid'])
|
||||
const isOptionDataComponent = (type?: string) =>
|
||||
Boolean(type && OPTION_DATA_COMPONENT_NAMES.has(type))
|
||||
const getOptionDataProperty = (type?: string) =>
|
||||
['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(type || '') ? 'items' : 'options'
|
||||
const isTabularDataComponent = (type?: string) =>
|
||||
Boolean(type && TABULAR_DATA_COMPONENT_NAMES.has(type))
|
||||
// Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is
|
||||
// covered by the static/endpoint panel by construction.
|
||||
const isOptionDataComponent = isDesignerOptionComponent
|
||||
const getOptionDataProperty = getDesignerCollectionProperty
|
||||
const isTabularDataComponent = isDesignerTabularComponent
|
||||
|
||||
const PROPERTY_EXAMPLE_VALUES: Record<string, unknown> = {
|
||||
children: 'Örnek içerik',
|
||||
|
|
@ -327,18 +322,6 @@ const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
|
|||
return result
|
||||
}
|
||||
|
||||
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
|
||||
const index = nodes.findIndex((node) => node.id === id)
|
||||
if (index >= 0) {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= nodes.length) return nodes
|
||||
const next = [...nodes]
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
return next
|
||||
}
|
||||
return nodes.map((node) => ({ ...node, children: moveInTree(node.children, id, direction) }))
|
||||
}
|
||||
|
||||
const insertRelativeToNode = (
|
||||
nodes: DesignerNode[],
|
||||
targetId: string,
|
||||
|
|
@ -357,6 +340,111 @@ const insertRelativeToNode = (
|
|||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a node one step up/down. Swapping with a sibling only works while the
|
||||
* node has one, so a node that sits alone in a container (or at the edge of one)
|
||||
* is lifted out and placed next to its parent instead — otherwise the arrows
|
||||
* would silently do nothing, which is how they used to behave.
|
||||
*/
|
||||
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
|
||||
const ancestors = findDesignerAncestors(nodes, id)
|
||||
if (!ancestors) return nodes
|
||||
|
||||
const parent = ancestors[ancestors.length - 1]
|
||||
const siblings = parent ? parent.children : nodes
|
||||
const index = siblings.findIndex((node) => node.id === id)
|
||||
if (index < 0) return nodes
|
||||
|
||||
// Tabs children live in separate tabs, so only components sharing a tab are
|
||||
// neighbours — otherwise the arrows would swap with something not on screen.
|
||||
const group =
|
||||
parent?.type === 'Tabs'
|
||||
? siblings.filter((node) => (node.slot || '') === (siblings[index].slot || ''))
|
||||
: siblings
|
||||
const groupIndex = group.findIndex((node) => node.id === id)
|
||||
const targetNode = group[groupIndex + direction]
|
||||
|
||||
if (targetNode) {
|
||||
const target = siblings.findIndex((node) => node.id === targetNode.id)
|
||||
const next = [...siblings]
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
// In a Table the visible cell comes from `slot`, not from array order, so the
|
||||
// slots have to stay with the position for the swap to be visible.
|
||||
if (parent?.type === 'Table') {
|
||||
next[index] = { ...next[index], slot: siblings[index].slot }
|
||||
next[target] = { ...next[target], slot: siblings[target].slot }
|
||||
}
|
||||
return parent ? updateNodeTree(nodes, parent.id, (node) => ({ ...node, children: next })) : next
|
||||
}
|
||||
|
||||
// Already first/last: at the root there is nowhere left to go.
|
||||
if (!parent) return nodes
|
||||
|
||||
const movingNode = { ...siblings[index], slot: undefined }
|
||||
const withoutNode = updateNodeTree(nodes, parent.id, (node) => ({
|
||||
...node,
|
||||
children: node.children.filter((child) => child.id !== id),
|
||||
}))
|
||||
return insertRelativeToNode(
|
||||
withoutNode,
|
||||
parent.id,
|
||||
movingNode,
|
||||
direction === -1 ? 'before' : 'after',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Table cells are addressed by `slot`, and children without one fall back to
|
||||
* their positional index. Materialising the effective slots keeps every existing
|
||||
* cell in place while a node is added to or removed from the table.
|
||||
*/
|
||||
const withResolvedTableSlots = (table: DesignerNode): DesignerNode => {
|
||||
const columnCount = normalizeTableDimension(table.props.columnCount, 3)
|
||||
return {
|
||||
...table,
|
||||
children: table.children.map((child, childIndex) => ({
|
||||
...child,
|
||||
slot:
|
||||
child.slot || `table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const resolveAllTableSlots = (nodes: DesignerNode[]): DesignerNode[] =>
|
||||
nodes.map((node) => {
|
||||
const resolved = { ...node, children: resolveAllTableSlots(node.children) }
|
||||
return resolved.type === 'Table' ? withResolvedTableSlots(resolved) : resolved
|
||||
})
|
||||
|
||||
/**
|
||||
* Moves an existing node into a container (or to the root when `parentId` is
|
||||
* null). Used by drag & drop so a component can leave a Table cell and be put
|
||||
* back into one.
|
||||
*/
|
||||
const moveNodeIntoContainer = (
|
||||
nodes: DesignerNode[],
|
||||
nodeId: string,
|
||||
parentId: string | null,
|
||||
slot?: string,
|
||||
): DesignerNode[] => {
|
||||
const source = findDesignerNode(nodes, nodeId)
|
||||
if (!source || parentId === nodeId) return nodes
|
||||
// Dropping a node inside its own subtree would detach that subtree.
|
||||
if (parentId && findDesignerNode(source.children, parentId)) return nodes
|
||||
|
||||
// Pin every table cell before the tree changes, so removing the node does not
|
||||
// shift the siblings that still rely on their positional fallback.
|
||||
const pinned = resolveAllTableSlots(nodes)
|
||||
const movingNode = { ...(findDesignerNode(pinned, nodeId) || source), slot }
|
||||
const remaining = removeNodeTree(pinned, nodeId)
|
||||
if (!parentId) return [...remaining, movingNode]
|
||||
|
||||
return updateNodeTree(remaining, parentId, (parent) => ({
|
||||
...parent,
|
||||
children: [...parent.children, movingNode],
|
||||
}))
|
||||
}
|
||||
|
||||
const reorderNodeTree = (
|
||||
nodes: DesignerNode[],
|
||||
sourceId: string,
|
||||
|
|
@ -714,13 +802,135 @@ const migrateGeneratedCodeToDesignerDocument = (
|
|||
return document.nodes.length ? document : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Array-of-object properties otherwise fall back to a raw JSON textarea, which is
|
||||
* unusable for anything but trivial values. Components listed here get a proper
|
||||
* row editor instead, keyed by `<component>.<property>`.
|
||||
*/
|
||||
const OBJECT_LIST_PROPERTIES: Record<
|
||||
string,
|
||||
{ itemLabel: string; fields: Array<{ name: string; label: string; placeholder?: string }> }
|
||||
> = {
|
||||
'ImageViewer.images': {
|
||||
itemLabel: 'Görsel',
|
||||
fields: [
|
||||
{ name: 'src', label: 'Görsel URL', placeholder: 'https://…' },
|
||||
{ name: 'thumbnail', label: 'Küçük görsel URL', placeholder: 'Boşsa görselin kendisi' },
|
||||
{ name: 'alt', label: 'Alternatif metin', placeholder: 'Erişilebilirlik metni' },
|
||||
{ name: 'caption', label: 'Açıklama', placeholder: 'Görsel altı yazı' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const ObjectListEditor = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
schema: (typeof OBJECT_LIST_PROPERTIES)[string]
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
}) => {
|
||||
const items = Array.isArray(value) ? (value as Array<Record<string, unknown>>) : []
|
||||
const inputClass =
|
||||
'w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
|
||||
|
||||
const replaceItem = (index: number, next: Record<string, unknown>) =>
|
||||
onChange(items.map((item, itemIndex) => (itemIndex === index ? next : item)))
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= items.length) return
|
||||
const next = [...items]
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-1.5 rounded-md border border-slate-200 p-2 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-slate-500">
|
||||
{schema.itemLabel} {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
title="Yukarı taşı"
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<FaArrowUp className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
|
||||
disabled={index === items.length - 1}
|
||||
title="Aşağı taşı"
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<FaArrowDown className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1 text-slate-400 hover:text-red-600"
|
||||
title="Sil"
|
||||
type="button"
|
||||
onClick={() => onChange(items.filter((_, i) => i !== index))}
|
||||
>
|
||||
<FaTrash className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{item.src ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-16 w-full rounded border border-slate-200 object-cover dark:border-slate-700"
|
||||
src={String(item.thumbnail || item.src)}
|
||||
/>
|
||||
) : null}
|
||||
{schema.fields.map((field) => (
|
||||
<label key={field.name} className="block">
|
||||
<span className="mb-0.5 block text-[9px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{field.label}
|
||||
</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={field.placeholder}
|
||||
type="text"
|
||||
value={String(item[field.name] ?? '')}
|
||||
onChange={(event) =>
|
||||
replaceItem(index, { ...item, [field.name]: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700"
|
||||
type="button"
|
||||
onClick={() => onChange([...items, { src: '' }])}
|
||||
>
|
||||
<FaPlus /> {schema.itemLabel} ekle
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PropertyEditor = ({
|
||||
componentType,
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
componentType?: string
|
||||
name: string
|
||||
type: string
|
||||
value: unknown
|
||||
|
|
@ -747,12 +957,16 @@ const PropertyEditor = ({
|
|||
)
|
||||
}
|
||||
if (type === 'select' && options) {
|
||||
const currentValue = String(value ?? '')
|
||||
return (
|
||||
<select
|
||||
className={inputClass}
|
||||
value={String(value ?? '')}
|
||||
value={currentValue}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{/* An empty value is not emitted, so the component keeps its own or its
|
||||
container's default — spell that out instead of showing a blank row. */}
|
||||
{!options.includes(currentValue) && <option value="">Varsayılan</option>}
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
|
|
@ -761,6 +975,10 @@ const PropertyEditor = ({
|
|||
</select>
|
||||
)
|
||||
}
|
||||
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
|
||||
if (objectListSchema) {
|
||||
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
|
||||
}
|
||||
if (type === 'object' || type === 'array') {
|
||||
return (
|
||||
<textarea
|
||||
|
|
@ -1603,6 +1821,17 @@ const VisualComponentDesigner = () => {
|
|||
[commitDocument],
|
||||
)
|
||||
|
||||
const moveNodeIntoContainerNode = useCallback(
|
||||
(nodeId: string, parentId: string | null, slot?: string) => {
|
||||
commitDocument((current) => ({
|
||||
...current,
|
||||
nodes: moveNodeIntoContainer(current.nodes, nodeId, parentId, slot),
|
||||
}))
|
||||
selectDesignerNode(nodeId)
|
||||
},
|
||||
[commitDocument, selectDesignerNode],
|
||||
)
|
||||
|
||||
const reorderNode = useCallback(
|
||||
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
|
||||
commitDocument((current) => ({
|
||||
|
|
@ -1716,12 +1945,7 @@ const VisualComponentDesigner = () => {
|
|||
if (event.shiftKey) redo()
|
||||
else undo()
|
||||
}
|
||||
if (
|
||||
event.key === 'Delete' &&
|
||||
!isPropertyEditing &&
|
||||
selectedId &&
|
||||
workspaceTab === 'design'
|
||||
) {
|
||||
if (event.key === 'Delete' && !isPropertyEditing && selectedId && workspaceTab === 'design') {
|
||||
deleteNode(selectedId)
|
||||
}
|
||||
}
|
||||
|
|
@ -2709,6 +2933,7 @@ const VisualComponentDesigner = () => {
|
|||
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
|
||||
onNodePropChange={updateNodeProp}
|
||||
onDropComponent={addComponent}
|
||||
onMoveIntoContainer={moveNodeIntoContainerNode}
|
||||
onMove={moveNode}
|
||||
onReorder={reorderNode}
|
||||
onDuplicate={duplicateNode}
|
||||
|
|
@ -3325,6 +3550,7 @@ const VisualComponentDesigner = () => {
|
|||
</p>
|
||||
)}
|
||||
<PropertyEditor
|
||||
componentType={selectedNode?.type}
|
||||
name={property.name}
|
||||
type={property.type}
|
||||
value={propertyValue}
|
||||
|
|
|
|||
Loading…
Reference in a new issue