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