Custom Component veritabanı yokken çağrısı

This commit is contained in:
Sedat ÖZTÜRK 2026-08-06 12:16:10 +03:00
parent 25ae424f42
commit 9a76afae24
12 changed files with 1423 additions and 409 deletions

771
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -67,6 +67,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@rolldown/plugin-babel": "^0.1.7",
"@tailwindcss/typography": "^0.5.15",
"@types/babel__standalone": "^7.1.9",
"@types/file-saver": "^2.0.7",
@ -79,7 +80,7 @@
"@types/react-modal": "^3.13.1",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "^6.0.5",
"autoprefixer": "^10.4.14",
"browserslist": "^4.25.2",
"cssnano": "^6.0.1",
@ -98,7 +99,7 @@
"prettier": "^3.1.1",
"tailwindcss": "^3.4.17",
"typescript": "5.9",
"vite": "^7.3.6",
"vite": "^8.2.0",
"vite-plugin-pwa": "^1.3.0"
},
"overrides": {

View file

@ -12,6 +12,8 @@ import {
} from './types'
export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'
const getTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
interface VisualCanvasProps {
nodes: DesignerNode[]
@ -19,10 +21,11 @@ interface VisualCanvasProps {
background: string
interactive?: boolean
onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
onMove?: (id: string, direction: -1 | 1) => void
onDuplicate?: (id: string) => void
onDelete?: (id: string) => void
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
dataValues?: Record<string, unknown>
}
@ -105,10 +108,12 @@ const toSelectOptions = (
const primitiveKeys = Object.keys(record).filter(
(key) => record[key] === null || ['string', 'number', 'boolean'].includes(typeof record[key]),
)
const labelKey = ['label', 'name', 'title', 'text', 'description'].find(
const labelKey = ['label', 'children', 'name', 'title', 'text', 'description'].find(
(key) => record[key] !== undefined,
)
const valueKey = ['value', 'eventKey', 'id', 'code', 'key'].find(
(key) => record[key] !== undefined,
)
const valueKey = ['value', 'id', 'code', 'key'].find((key) => record[key] !== undefined)
const labelValue = labelPath
? getDesignerValueByPath(record, labelPath)
: labelKey
@ -131,6 +136,7 @@ const getPreviewProps = (
node: DesignerNode,
dataValues: Record<string, unknown>,
currentItem?: unknown,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
) => {
const props: Record<string, unknown> = {}
Object.entries(node.props).forEach(([key, value]) => {
@ -160,8 +166,12 @@ const getPreviewProps = (
props[propertyName] = getBindingValue(binding, dataValues, currentItem)
}
})
if (node.type === 'Select' || node.type === 'AutoComplete' || node.type === 'Menu') {
const collectionProperty = node.type === 'Menu' ? 'items' : 'options'
if (
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Radio.Group'].includes(node.type)
) {
const collectionProperty = ['Menu', 'Dropdown', 'Radio.Group'].includes(node.type)
? 'items'
: 'options'
if (node.type === 'Select') {
const legacyAliases: Record<string, string> = {
clearable: 'isClearable',
@ -184,21 +194,71 @@ const getPreviewProps = (
)
if (node.type === 'Select' && 'value' in props) {
const options = props.options as Array<Record<string, unknown>>
props.defaultValue = options.find((option) => option.value === props.value) || null
delete props.value
const selectedValue = props.value
props.value = props.isMulti
? options.filter(
(option) => Array.isArray(selectedValue) && selectedValue.includes(option.value),
)
: options.find((option) => option.value === selectedValue) || null
}
}
if (node.type === 'Input' && 'value' in props) {
props.defaultValue = props.value
delete props.value
const chainHandler = (
eventName: string,
update: (...args: unknown[]) => void,
) => {
const storedHandler = typeof props[eventName] === 'function'
? (props[eventName] as (...args: unknown[]) => void)
: undefined
props[eventName] = (...args: unknown[]) => {
update(...args)
storedHandler?.(...args)
}
}
if (node.type === 'Checkbox' && 'checked' in props) {
props.defaultChecked = Boolean(props.checked)
delete props.checked
const updateProp = (propertyName: string, value: unknown) =>
onNodePropChange?.(node.id, propertyName, value)
const eventValue = (value: unknown) =>
value && typeof value === 'object' && 'target' in value
? (value as { target?: { value?: unknown } }).target?.value
: value
if (node.type === 'Select') {
chainHandler('onChange', (selected) =>
updateProp(
'value',
Array.isArray(selected)
? selected.map((option) =>
option && typeof option === 'object' && 'value' in option
? (option as { value: unknown }).value
: option,
)
: selected && typeof selected === 'object' && 'value' in selected
? (selected as { value: unknown }).value
: null,
),
)
} else if (node.type === 'AutoComplete') {
chainHandler('onInputChange', (value) => updateProp('value', value ?? ''))
} else if (node.type === 'Menu') {
chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')]))
} else if (node.type === 'Dropdown') {
chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? '')))
} else if (node.type === 'Radio.Group') {
chainHandler('onChange', (value) => updateProp('value', value))
} else if (node.type === 'Pagination') {
chainHandler('onChange', (value) => updateProp('currentPage', Number(value) || 1))
} else if ('checked' in props) {
chainHandler('onChange', (value) => {
const checked =
value && typeof value === 'object' && 'target' in value
? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
: Boolean(value)
updateProp('checked', checked)
})
} else if ('value' in props) {
chainHandler('onChange', (value) => updateProp('value', eventValue(value)))
}
if ('value' in props && !props.onChange) props.onChange = () => undefined
if ('checked' in props && !props.onChange) props.onChange = () => undefined
props.key = JSON.stringify([node.props, node.bindings])
props.key = node.id
return props
}
@ -339,10 +399,17 @@ const renderElement = (
dataValues: Record<string, unknown>,
currentItem: unknown,
interactive: boolean,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode,
) => {
if (node.type === 'Spacer') {
return <div aria-hidden="true" style={{ height: Number(node.props.height) || 24 }} />
return (
<div
aria-hidden="true"
className={String(node.props.className || '')}
style={{ height: Number(node.props.height) || 24 }}
/>
)
}
if (node.type === 'FlexRow') {
return (
@ -428,10 +495,6 @@ const renderElement = (
/>
)
}
if (node.kind === 'custom') {
return renderCustomComponent?.(node.type, { ...node.props, children }) || null
}
const childrenBinding = node.bindings?.children
const boundChildren = childrenBinding?.sourceId
? getBindingValue(childrenBinding, dataValues, currentItem)
@ -446,40 +509,11 @@ const renderElement = (
: typeof boundChildren === 'object'
? JSON.stringify(boundChildren)
: String(boundChildren)
const props = getPreviewProps(node, dataValues, currentItem)
if (node.type === 'Table') {
const legacyData = Array.isArray(props.data) ? props.data : []
const items =
Array.isArray(props.items) && (props.items.length > 0 || !legacyData.length)
? props.items
: legacyData
const legacyColumns = Array.isArray(node.props.columns)
? node.props.columns
.map((column) => {
if (typeof column === 'string') return column
if (!column || typeof column !== 'object' || Array.isArray(column)) return ''
const record = column as Record<string, unknown>
return String(record.dataIndex ?? record.key ?? '')
})
.filter(Boolean)
: undefined
return (
<GridDataTablePreview
borderlessRow={Boolean(node.props.borderlessRow)}
compact={Boolean(node.props.compact)}
hoverable={node.props.hoverable !== false}
items={items}
overflow={node.props.overflow !== false}
selectedColumns={
Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: legacyColumns
}
/>
)
const props = getPreviewProps(node, dataValues, currentItem, onNodePropChange)
if (node.kind === 'custom') {
return renderCustomComponent?.(node.type, { ...props, children: content }) || null
}
if (node.type === 'Table') return <>{children}</>
if (node.type === 'Menu') {
const menuProps = { ...props }
const options = Array.isArray(menuProps.items)
@ -503,6 +537,51 @@ const renderElement = (
</UiKit.Menu>
)
}
if (node.type === 'Dropdown') {
const dropdownProps = { ...props }
const options = Array.isArray(dropdownProps.items)
? (dropdownProps.items as Array<Record<string, unknown>>)
: []
delete dropdownProps.items
return (
<UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}>
{options.map((option, index) => (
<UiKit.Dropdown.Item
key={String(option.value ?? index)}
disabled={Boolean(option.disabled)}
eventKey={String(option.value ?? index)}
>
{String(option.label ?? option.value ?? `Öğe ${index + 1}`)}
</UiKit.Dropdown.Item>
))}
{content}
</UiKit.Dropdown>
)
}
if (node.type === 'Radio.Group') {
const groupProps = { ...props }
const options = Array.isArray(groupProps.items)
? (groupProps.items as Array<Record<string, unknown>>)
: []
delete groupProps.items
delete groupProps.checked
delete groupProps.defaultChecked
delete groupProps.readOnly
return (
<UiKit.Radio.Group {...(groupProps as React.ComponentProps<typeof UiKit.Radio.Group>)}>
{options.map((option, index) => (
<UiKit.Radio
key={String(option.value ?? index)}
disabled={Boolean(option.disabled)}
value={option.value ?? index}
>
{String(option.label ?? option.value ?? `Seçenek ${index + 1}`)}
</UiKit.Radio>
))}
{content}
</UiKit.Radio.Group>
)
}
if (node.type === 'checkbox') {
return <input {...(props as React.InputHTMLAttributes<HTMLInputElement>)} type="checkbox" />
}
@ -529,6 +608,7 @@ const NodeView = ({
onMove,
onDuplicate,
onDelete,
onNodePropChange,
renderCustomComponent,
dataValues,
currentItem,
@ -538,10 +618,11 @@ const NodeView = ({
selectedId: string | null
interactive: boolean
onSelect?: (id: string) => void
onDropComponent?: (definitionName: string, parentId: string | null) => void
onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
onMove?: (id: string, direction: -1 | 1) => void
onDuplicate?: (id: string) => void
onDelete?: (id: string) => void
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
dataValues: Record<string, unknown>
currentItem?: unknown
@ -557,7 +638,7 @@ const NodeView = ({
'Card',
'FormContainer',
].includes(node.type)
const itemsBinding = ['Grid', 'Table'].includes(node.type) ? node.bindings?.items : undefined
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
const boundItems = itemsBinding?.sourceId
? getBindingValue(itemsBinding, dataValues, currentItem)
: node.props.items
@ -590,11 +671,108 @@ const NodeView = ({
onMove={onMove}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
)),
)
const tableContent =
node.type === 'Table' ? (
<UiKit.Table
borderlessRow
className={`${String(node.props.className || '')} [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5`}
compact={Boolean(node.props.compact)}
hoverable={node.props.hoverable !== false}
overflow={node.props.overflow !== false}
style={{
minWidth: '100%',
tableLayout: 'fixed',
width: '100%',
}}
>
<tbody>
{Array.from({ length: getTableDimension(node.props.rowCount, 2) }, (_, rowIndex) => (
<tr key={`row_${rowIndex}`}>
{Array.from(
{ length: getTableDimension(node.props.columnCount, 3) },
(_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}`
const cellNodes = node.children.filter(
(child, childIndex) =>
(child.slot ||
`table:${Math.floor(
childIndex / getTableDimension(node.props.columnCount, 3),
)}:${childIndex % getTableDimension(node.props.columnCount, 3)}`) === slot,
)
return (
<td
key={slot}
className={
interactive
? 'border border-slate-200 align-top dark:border-slate-700'
: 'align-top'
}
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}}
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)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
}
}}
>
<div className="min-h-14 min-w-0 p-1">
{cellNodes.map((child, childIndex) => (
<NodeView
key={child.id}
node={child}
index={childIndex}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={dataValues}
currentItem={currentItem}
onSelect={onSelect}
onDropComponent={onDropComponent}
onMove={onMove}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
))}
{interactive && !cellNodes.length && (
<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">
Komponent bırakın
</div>
)}
</div>
</td>
)
},
)}
</tr>
))}
</tbody>
</UiKit.Table>
) : null
const renderedChildren =
node.type === 'Grid' && interactive && node.children.length === 0
node.type === 'Table'
? tableContent
: node.type === 'Grid' && interactive && node.children.length === 0
? repeatedItems.length
? [
<GridDataTablePreview
@ -780,6 +958,7 @@ const NodeView = ({
dataValues,
currentItem,
interactive,
onNodePropChange,
renderCustomComponent,
)}
</PreviewBoundary>
@ -805,6 +984,7 @@ const VisualCanvas = ({
onMove,
onDuplicate,
onDelete,
onNodePropChange,
renderCustomComponent,
dataValues = {},
}: VisualCanvasProps) => {
@ -843,6 +1023,7 @@ const VisualCanvas = ({
onMove={onMove}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
))}
</div>

View file

@ -18,8 +18,16 @@ interface GeneratedComponentMetadata {
}
const generatedMetadata = generatedComponentProps as Record<string, GeneratedComponentMetadata>
const CONTAINER_NAMES = new Set(['div', 'Card', 'FormContainer', 'Tabs', 'Timeline'])
const DATA_COMPONENT_NAMES = new Set(['AutoComplete', 'Grid', 'Menu', 'Pagination', 'Select', 'Table'])
const CONTAINER_NAMES = new Set(['div', 'Card', 'FormContainer', 'Table', 'Tabs', 'Timeline'])
export const DESIGNER_DATA_COMPONENT_NAMES = new Set([
'AutoComplete',
'Dropdown',
'Grid',
'Menu',
'Pagination',
'Radio.Group',
'Select',
])
const HTML_UI_DUPLICATES = new Set(['input', 'button', 'textarea', 'select', 'checkbox'])
const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
Select: {
@ -31,10 +39,14 @@ const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
},
}
export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
AutoComplete: ['onInputChange', 'onSelect'],
Button: ['onClick'],
Checkbox: ['onChange'],
Dropdown: ['onSelect'],
Input: ['onChange'],
Menu: ['onSelect'],
Pagination: ['onChange'],
'Radio.Group': ['onChange'],
Select: ['onChange'],
}
@ -56,6 +68,18 @@ console.log('Value:', value)`,
Menu: {
onSelect: `// Seçilen menü öğesinin value değeridir.
console.log('Menu value:', event)`,
},
Dropdown: {
onSelect: `// Seçilen dropdown öğesinin value değeridir.
console.log('Dropdown value:', event)`,
},
Pagination: {
onChange: `// Seçilen sayfa numarasıdır.
console.log('Page:', event)`,
},
'Radio.Group': {
onChange: `// Seçilen radio öğesinin value değeridir.
console.log('Radio value:', event)`,
},
Select: {
onChange: `// Seçilen option nesnesi; temizlendiğinde null olur.
@ -120,6 +144,26 @@ const GRID_TABLE_BEHAVIOR_PROPERTIES = [
required: false,
},
]
const TABLE_DIMENSION_PROPERTIES = [
{
name: 'rowCount',
type: 'number' as const,
value: 2,
category: 'properties' as const,
description: 'Table içindeki statik satır sayısı',
tsType: 'number',
required: false,
},
{
name: 'columnCount',
type: 'number' as const,
value: 3,
category: 'properties' as const,
description: 'Her satırdaki statik hücre sayısı',
tsType: 'number',
required: false,
},
]
const SELECT_OPTIONS_PROPERTY = {
name: 'options',
type: 'array' as const,
@ -149,7 +193,7 @@ const normalizeDefinition = (
? definition.properties.filter((property) => property.name !== 'children')
: definition.properties
let properties =
['Grid', 'Table'].includes(definition.name) &&
definition.name === 'Grid' &&
!sourceProperties.some((property) => property.name === 'items')
? [COLLECTION_ITEMS_PROPERTY, ...sourceProperties]
: sourceProperties
@ -164,6 +208,14 @@ const normalizeDefinition = (
...properties,
]
}
if (definition.name === 'Table') {
properties = [
...TABLE_DIMENSION_PROPERTIES.filter(
(candidate) => !properties.some((property) => property.name === candidate.name),
),
...properties.filter((property) => !['columns', 'data', 'items'].includes(property.name)),
]
}
return {
...definition,
@ -246,7 +298,11 @@ const normalizeUiDefinition = (
return normalizeDefinition(
definition,
'ui',
DATA_COMPONENT_NAMES.has(definition.name) ? 'data' : 'ui',
definition.name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
? 'data'
: 'ui',
)
}
@ -265,21 +321,32 @@ const normalizeUiDefinition = (
const existingProperties = new Map(curatedProperties.map((property) => [property.name, property]))
const generatedProperties = generatedPropertiesToDesigner(generated, existingProperties)
if (
['Select', 'AutoComplete', 'Menu'].includes(definition.name) &&
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Radio.Group'].includes(definition.name) &&
!generatedProperties.some(
(property) => property.name === (definition.name === 'Menu' ? 'items' : 'options'),
(property) =>
property.name ===
(['Menu', 'Dropdown', 'Radio.Group'].includes(definition.name) ? 'items' : 'options'),
)
) {
generatedProperties.unshift(
definition.name === 'Menu' ? MENU_ITEMS_PROPERTY : SELECT_OPTIONS_PROPERTY,
['Menu', 'Dropdown', 'Radio.Group'].includes(definition.name)
? MENU_ITEMS_PROPERTY
: SELECT_OPTIONS_PROPERTY,
)
}
if (
['Grid', 'Table'].includes(definition.name) &&
definition.name === 'Grid' &&
!generatedProperties.some((property) => property.name === 'items')
) {
generatedProperties.unshift(COLLECTION_ITEMS_PROPERTY)
}
if (definition.name === 'Table') {
generatedProperties.unshift(
...TABLE_DIMENSION_PROPERTIES.filter(
(candidate) => !generatedProperties.some((property) => property.name === candidate.name),
),
)
}
const generatedPropertyNames = new Set(generatedProperties.map((property) => property.name))
const properties = [
...generatedProperties,
@ -294,7 +361,12 @@ const normalizeUiDefinition = (
return {
...definition,
kind: 'ui',
toolboxGroup: DATA_COMPONENT_NAMES.has(definition.name) ? 'data' : 'ui',
toolboxGroup:
definition.name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(definition.name)
? 'data'
: 'ui',
acceptsChildren: CONTAINER_NAMES.has(definition.name),
sourceFile: generated.sourceFile,
interfaceName: generated.interfaceName,
@ -431,7 +503,7 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [
icon: 'Space',
category: 'layout',
kind: 'layout',
toolboxGroup: 'layout',
toolboxGroup: 'ui',
description: 'Bileşenler arasında boşluk',
properties: [{ name: 'height', type: 'number', value: 24, category: 'styling' }],
hooks: [],
@ -451,7 +523,12 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
icon: 'Blocks',
category: inferUiCategory(name),
kind: 'ui',
toolboxGroup: DATA_COMPONENT_NAMES.has(name) ? 'data' : 'ui',
toolboxGroup:
name === 'Table'
? 'layout'
: DESIGNER_DATA_COMPONENT_NAMES.has(name)
? 'data'
: 'ui',
description: `${metadata.interfaceName} sözleşmesini kullanan Sozsoft UI componenti`,
sourceFile: metadata.sourceFile,
interfaceName: metadata.interfaceName,

View file

@ -1,14 +1,5 @@
import { normalizeDesignerKeyList, type DesignerDocument, type DesignerNode } from './types'
const EVENT_NAMES: Record<string, string> = {
onClick: 'event',
onChange: 'event',
onFocus: 'event',
onBlur: 'event',
onMouseEnter: 'event',
onMouseLeave: 'event',
}
const safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}`
@ -32,13 +23,82 @@ const bindingExpression = (node: DesignerNode, propertyName: string, itemVariabl
return `getByPath(data_${safeIdentifier(binding.sourceId)}, ${JSON.stringify(binding.path || '')})`
}
interface RuntimeStateSpec {
propertyName: string
eventName: string
stateName: string
setterName: string
initialValue: unknown
}
const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
const configured: Record<string, { propertyName: string; eventName: string }> = {
AutoComplete: { propertyName: 'value', eventName: 'onInputChange' },
Dropdown: { propertyName: 'activeKey', eventName: 'onSelect' },
Input: { propertyName: 'value', eventName: 'onChange' },
Menu: { propertyName: 'defaultActiveKeys', eventName: 'onSelect' },
Pagination: { propertyName: 'currentPage', eventName: 'onChange' },
'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
Select: { propertyName: 'value', eventName: 'onChange' },
}
const fallback =
'checked' in node.props
? { propertyName: 'checked', eventName: 'onChange' }
: 'value' in node.props
? { propertyName: 'value', eventName: 'onChange' }
: null
const state = configured[node.type] || fallback
if (!state || node.bindings?.[state.propertyName]?.sourceId) return null
let initialValue = node.props[state.propertyName]
if (node.type === 'Select') {
const options = Array.isArray(node.props.options) ? node.props.options : []
initialValue = node.props.isMulti
? options.filter(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
Array.isArray(node.props.value) &&
node.props.value.includes((option as Record<string, unknown>).value),
)
: options.find(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
(option as Record<string, unknown>).value === node.props.value,
) || null
} else if (node.type === 'Menu') {
initialValue = normalizeDesignerKeyList(initialValue)
} else if (node.type === 'Pagination') {
initialValue = Number(initialValue) || 1
} else if (initialValue === undefined) {
initialValue = state.propertyName === 'checked' ? false : ''
}
const identifier = safeIdentifier(node.id)
return {
...state,
stateName: `runtime_${identifier}_${safeIdentifier(state.propertyName)}`,
setterName: `setRuntime_${identifier}_${safeIdentifier(state.propertyName)}`,
initialValue,
}
}
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
(['Select', 'AutoComplete'].includes(node.type) && propertyName === 'options') ||
(['Menu', 'Dropdown', 'Radio.Group'].includes(node.type) && propertyName === 'items')
const propsToCode = (node: DesignerNode, itemVariable?: string) => {
const runtimeState = getRuntimeStateSpec(node)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim())
const props = Object.entries(node.props)
.filter(
([key, value]) =>
key !== 'children' &&
key !== runtimeState?.propertyName &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
!node.bindings?.[key]?.sourceId &&
@ -46,23 +106,6 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
value !== undefined,
)
.map(([key, value]) => {
if (node.type === 'Checkbox' && key === 'checked') {
return `defaultChecked=${serializeValue(value)}`
}
if (node.type === 'Input' && key === 'value') {
return `defaultValue=${serializeValue(value)}`
}
if (node.type === 'Select' && key === 'value') {
const staticOptions = Array.isArray(node.props.options) ? node.props.options : []
const selectedOption = staticOptions.find(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
(option as Record<string, unknown>).value === value,
)
return `defaultValue=${serializeValue(selectedOption ?? null)}`
}
return `${key}=${serializeValue(value)}`
})
@ -71,7 +114,7 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
const expression = bindingExpression(node, propertyName, itemVariable)
if (expression) {
const propertyExpression =
['Select', 'AutoComplete'].includes(node.type) && propertyName === 'options'
isOptionCollectionProperty(node, propertyName)
? `toSelectOptions(${expression}, ${JSON.stringify(node.bindings.options?.labelPath || '')}, ${JSON.stringify(node.bindings.options?.valuePath || '')})`
: expression
props.push(`${propertyName}={${propertyExpression}}`)
@ -80,11 +123,17 @@ const propsToCode = (node: DesignerNode, itemVariable?: string) => {
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
Object.entries(node.events)
.filter(([, script]) => script.trim())
.forEach(([eventName]) => {
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`)
})
if (runtimeState) props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
const handlerNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName),
)
if (runtimeState) handlerNames.add(runtimeState.eventName)
handlerNames.forEach((eventName) =>
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`),
)
return props.length ? ` ${props.join(' ')}` : ''
}
@ -98,7 +147,7 @@ const indent = (text: string, level: number) =>
const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): string => {
if (node.type === 'Spacer') {
return indent(
`<div aria-hidden="true" style={{ height: ${Number(node.props.height) || 24} }} />`,
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
level,
)
}
@ -135,45 +184,32 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin
}
if (node.type === 'Table') {
const legacyData = Array.isArray(node.props.data) ? node.props.data : []
const staticItems =
Array.isArray(node.props.items) && (node.props.items.length > 0 || !legacyData.length)
? node.props.items
: legacyData
const itemsExpression =
bindingExpression(node, 'items', itemVariable) ||
bindingExpression(node, 'data', itemVariable) ||
JSON.stringify(staticItems)
const identifier = safeIdentifier(node.id)
const itemsVariable = `tableItems_${identifier}`
const columnsVariable = `tableColumns_${identifier}`
const rowVariable = `tableRow_${identifier}`
const configuredColumns = Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
: Array.isArray(node.props.columns)
? node.props.columns
.map((column) => {
if (typeof column === 'string') return column
if (!column || typeof column !== 'object' || Array.isArray(column)) return ''
const record = column as Record<string, unknown>
return String(record.dataIndex ?? record.key ?? '')
})
.filter(Boolean)
: null
const columnsExpression = configuredColumns
? JSON.stringify(configuredColumns)
: `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]`
return `${indent('{(() => {', level)}
${indent(`const ${itemsVariable} = ${itemsExpression}`, level + 1)}
${indent(`if (!Array.isArray(${itemsVariable})) return <div 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">Table items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 1)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 1)}
${indent('return (', level + 1)}
${indent(`<UiKit.Table borderlessRow={${Boolean(node.props.borderlessRow)}} compact={${Boolean(node.props.compact)}} hoverable={${node.props.hoverable !== false}} overflow={${node.props.overflow !== false}}>`, level + 2)}
${indent(`<thead><tr>{${columnsVariable}.map((column) => <th key={column}>{column === "value" ? "Value" : column}</th>)}</tr></thead>`, level + 3)}
${indent(`<tbody>{${itemsVariable}.map((${rowVariable}, rowIndex) => <tr key={rowIndex}>{${columnsVariable}.map((column) => { const value = column === "value" ? ${rowVariable} : getByPath(${rowVariable}, column); return <td key={column}>{typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? "")}</td> })}</tr>)}</tbody>`, level + 3)}
${indent('</UiKit.Table>', level + 2)}
${indent(')', level + 1)}
${indent('})()}', level)}`
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
const columnCount = Math.min(
20,
Math.max(1, Math.floor(Number(node.props.columnCount) || 3)),
)
const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}`
const cellChildren = node.children.filter(
(child, childIndex) =>
(child.slot ||
`table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`) === slot,
)
const content = cellChildren.length
? cellChildren.map((child) => nodeToCode(child, level + 3, itemVariable)).join('\n')
: indent('<span />', level + 3)
return `${indent('<td>', level + 2)}\n${content}\n${indent('</td>', level + 2)}`
}).join('\n')
return `${indent('<tr>', level + 1)}\n${cells}\n${indent('</tr>', level + 1)}`
}).join('\n')
const className = JSON.stringify(String(node.props.className || ''))
return `${indent(`<UiKit.Table className={${className} + " [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5"} borderlessRow compact={${Boolean(node.props.compact)}} hoverable={${node.props.hoverable !== false}} overflow={${node.props.overflow !== false}} style={{ minWidth: "100%", tableLayout: "fixed", width: "100%" }}>`, level)}
${indent('<tbody>', level + 1)}
${rows}
${indent('</tbody>', level + 1)}
${indent('</UiKit.Table>', level)}`
}
if (node.type === 'Grid') {
@ -302,6 +338,44 @@ ${indent(')}', level + 1)}`
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
}
if (node.type === 'Dropdown') {
const { items: staticOptions = [], ...dropdownProps } = node.props
const { items: optionsBinding, ...dropdownBindings } = node.bindings || {}
const dropdownPropsCode = propsToCode(
{ ...node, props: dropdownProps, bindings: dropdownBindings },
itemVariable,
)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: JSON.stringify(Array.isArray(staticOptions) ? staticOptions : [])
return `${indent(`<UiKit.Dropdown${dropdownPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
}
if (node.type === 'Radio.Group') {
const {
items: staticOptions = [],
checked: _checked,
defaultChecked: _defaultChecked,
readOnly: _readOnly,
...groupProps
} = node.props
const { items: optionsBinding, ...groupBindings } = node.bindings || {}
const groupPropsCode = propsToCode(
{ ...node, props: groupProps, bindings: groupBindings },
itemVariable,
)
const binding = optionsBinding?.sourceId
? bindingExpression(node, 'items', itemVariable)
: ''
const optionsExpression = binding
? `toSelectOptions(${binding}, ${JSON.stringify(optionsBinding?.labelPath || '')}, ${JSON.stringify(optionsBinding?.valuePath || '')})`
: JSON.stringify(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)}`
}
const tag =
node.type === 'checkbox'
? 'input'
@ -335,25 +409,56 @@ ${indent(')}', level + 1)}`
export const generateDesignerCode = (name: string, document: DesignerDocument) => {
const componentName = safeIdentifier(name || 'VisualComponent')
const handlers: string[] = []
const runtimeStateHooks: string[] = []
let hasSelect = false
let hasDataTable = false
const visit = (nodes: DesignerNode[]) => {
nodes.forEach((node) => {
if (node.type === 'Select' || node.type === 'AutoComplete' || node.type === 'Menu') {
if (
['Select', 'AutoComplete', 'Menu', 'Dropdown', 'Radio.Group'].includes(node.type)
) {
hasSelect = true
}
if (node.type === 'Grid' || node.type === 'Table') hasDataTable = true
Object.entries(node.events).forEach(([eventName, script]) => {
if (!script.trim()) return
if (node.type === 'Checkbox' && eventName === 'onChange') {
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = (checked, originalEvent) => {\n const event = { checked, originalEvent, target: originalEvent?.target }\n${indent(script, 2)}\n }`,
)
return
}
if (node.type === 'Grid') hasDataTable = true
const runtimeState = getRuntimeStateSpec(node)
if (runtimeState) {
runtimeStateHooks.push(
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${JSON.stringify(runtimeState.initialValue)})`,
)
}
const eventNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName),
)
if (runtimeState) eventNames.add(runtimeState.eventName)
eventNames.forEach((eventName) => {
const script = node.events[eventName]?.trim() || ''
const stateUpdate =
runtimeState?.eventName === eventName
? node.type === 'Select'
? `${runtimeState.setterName}(valueOrEvent)`
: node.type === 'AutoComplete'
? `${runtimeState.setterName}(valueOrEvent ?? "")`
: node.type === 'Menu'
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
: node.type === 'Dropdown'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Pagination'
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
: runtimeState.propertyName === 'checked'
? `${runtimeState.setterName}(typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
: node.type === 'Input' || node.kind === 'html'
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
: `${runtimeState.setterName}(valueOrEvent)`
: ''
const eventDeclaration =
node.type === 'Checkbox' && eventName === 'onChange'
? ' const event = { checked: Boolean(valueOrEvent), originalEvent, target: originalEvent?.target }'
: ' const event = valueOrEvent'
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = (${EVENT_NAMES[eventName] || 'event'}) => {\n${indent(script, 2)}\n }`,
` const handle_${safeIdentifier(node.id)}_${eventName} = (valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`,
)
})
visit(node.children)
@ -368,8 +473,8 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
return value.map((item, index) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) return { label: String(item ?? ""), value: item ?? index }
const primitiveKeys = Object.keys(item).filter((key) => item[key] === null || ["string", "number", "boolean"].includes(typeof item[key]))
const labelKey = ["label", "name", "title", "text", "description"].find((key) => item[key] !== undefined)
const valueKey = ["value", "id", "code", "key"].find((key) => item[key] !== undefined)
const labelKey = ["label", "children", "name", "title", "text", "description"].find((key) => item[key] !== undefined)
const valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined)
const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]]
const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? primitiveKeys[0]]
return { ...item, label: String(optionLabel ?? \`Seçenek \${index + 1}\`), value: optionValue ?? index }
@ -411,5 +516,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const designerBackup = encodeURIComponent(JSON.stringify(document))
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dataHooks, 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, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
}

View file

@ -22,6 +22,7 @@ export interface DesignerNode {
id: string
type: string
kind: DesignerNodeKind
slot?: string
props: Record<string, unknown>
events: Record<string, string>
bindings: Record<string, DesignerBinding>

View file

@ -16,7 +16,6 @@ import React, {
useEffect,
useCallback,
useMemo,
useRef,
} from 'react'
interface ComponentProps {
@ -33,6 +32,7 @@ interface ComponentContextType {
getComponent: (id: string) => CustomComponent | undefined
getComponentByName: (name: string) => CustomComponent | undefined
refreshComponents: () => Promise<void>
refreshComponent: (id: string) => Promise<CustomComponent | undefined>
// Manual registered components
registeredComponents: Record<string, React.ComponentType<unknown>>
registerComponent: (name: string, component: React.ComponentType<unknown>) => void
@ -45,14 +45,13 @@ interface ComponentContextType {
}
const ComponentContext = createContext<ComponentContextType | undefined>(undefined)
const COMPONENTS_CHANGED_STORAGE_KEY = 'sozsoft:custom-components:changed'
const componentsChangedChannel =
typeof BroadcastChannel === 'undefined'
? undefined
: new BroadcastChannel('sozsoft-custom-components-changed')
const notifyComponentsChanged = () => {
try {
window.localStorage.setItem(COMPONENTS_CHANGED_STORAGE_KEY, String(Date.now()))
} catch {
// Local state is already current; storage can be unavailable in restricted browsers.
}
componentsChangedChannel?.postMessage({ changedAt: Date.now() })
}
const haveSameComponentRevision = (
@ -148,7 +147,8 @@ export const useComponents = () => {
}
export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
const applicationConfig = useStoreState((state) => state.abpConfig?.config)
const extraProperties = applicationConfig?.extraProperties
const [components, setComponents] = useState<CustomComponent[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -156,50 +156,59 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const [compiledComponents, setCompiledComponents] = useState<
Record<string, React.ComponentType<ComponentProps>>
>({})
const refreshInFlightRef = useRef<Promise<void> | null>(null)
const refreshComponents = useCallback(async () => {
if (!applicationConfig) return
const refreshComponents = useCallback(() => {
if (refreshInFlightRef.current) return refreshInFlightRef.current
try {
setError(null)
const result = await developerKitService.getCustomComponents()
const nextComponents = result.items || []
setComponents((current) =>
haveSameComponentRevision(current, nextComponents) ? current : nextComponents,
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch components')
console.error('Failed to fetch components:', err)
}
}, [applicationConfig])
const request = (async () => {
try {
setError(null)
const result = await developerKitService.getCustomComponents()
const nextComponents = result.items || []
setComponents((current) =>
haveSameComponentRevision(current, nextComponents) ? current : nextComponents,
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch components')
console.error('Failed to fetch components:', err)
const fallbackComponents = extraProperties?.customComponents as CustomComponentDto[]
setComponents((current) => (current.length ? current : fallbackComponents || []))
}
})()
refreshInFlightRef.current = request
void request.finally(() => {
if (refreshInFlightRef.current === request) refreshInFlightRef.current = null
})
return request
useEffect(() => {
const initialComponents =
(extraProperties?.customComponents as CustomComponentDto[] | undefined) || []
setComponents((current) =>
haveSameComponentRevision(current, initialComponents) ? current : initialComponents,
)
}, [extraProperties])
useEffect(() => {
void refreshComponents()
}, [refreshComponents])
const refreshComponent = useCallback(
async (id: string): Promise<CustomComponent | undefined> => {
if (!applicationConfig) return undefined
try {
setError(null)
const freshComponent = await developerKitService.getCustomComponent(id)
setComponents((current) => {
const exists = current.some((component) => component.id === id)
return exists
? current.map((component) => (component.id === id ? freshComponent : component))
: [...current, freshComponent]
})
return freshComponent
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch component')
console.error('Failed to fetch component:', err)
return undefined
}
},
[applicationConfig],
)
useEffect(() => {
const handleFocus = () => void refreshComponents()
const handleStorage = (event: StorageEvent) => {
if (event.key === COMPONENTS_CHANGED_STORAGE_KEY) void refreshComponents()
}
const handleComponentsChanged = () => void refreshComponents()
window.addEventListener('focus', handleFocus)
window.addEventListener('storage', handleStorage)
return () => {
window.removeEventListener('focus', handleFocus)
window.removeEventListener('storage', handleStorage)
}
componentsChangedChannel?.addEventListener('message', handleComponentsChanged)
return () =>
componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
}, [refreshComponents])
const addComponent = useCallback(async (componentData: CreateUpdateCustomComponentDto) => {
@ -633,6 +642,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
getComponent,
getComponentByName,
refreshComponents,
refreshComponent,
registeredComponents,
registerComponent,
renderComponent,
@ -651,6 +661,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
getComponent,
getComponentByName,
refreshComponents,
refreshComponent,
registeredComponents,
registerComponent,
renderComponent,

View file

@ -29,7 +29,7 @@ const ComponentEditor: React.FC = () => {
const navigate = useNavigate()
const { translate } = useLocalization()
const { getComponent, addComponent, updateComponent } = useComponents()
const { getComponent, addComponent, updateComponent, refreshComponent } = useComponents()
const [isLoaded, setIsLoaded] = useState(false)
@ -47,8 +47,16 @@ const ComponentEditor: React.FC = () => {
// Load existing component data - sadece edit modunda
useEffect(() => {
if (isEditing && id && !isLoaded) {
const component = getComponent(id)
if (component) {
let cancelled = false
void refreshComponent(id).then((freshComponent) => {
if (cancelled) return
const component = freshComponent
if (!component) {
setIsLoaded(true)
return
}
// Parse dependencies from JSON string
let deps: string[] = []
try {
@ -68,12 +76,16 @@ const ComponentEditor: React.FC = () => {
setInitialValues(values)
setIsLoaded(true)
})
return () => {
cancelled = true
}
} else if (!isEditing && !isLoaded) {
// Yeni komponent için boş başla - TEMPLATE YOK
setIsLoaded(true)
}
}, [id, isEditing, getComponent, isLoaded])
}, [id, isEditing, isLoaded, refreshComponent])
const createDefaultVisualDocument = (componentName: string): DesignerDocument => {
const document = createEmptyDesignerDocument('visual')

View file

@ -82,11 +82,6 @@ const ComponentManager: React.FC = () => {
}
}
const handleOpenRoute = (routePath: string) => {
const normalizedPath = routePath.startsWith('/') ? routePath : `/${routePath}`
window.open(normalizedPath, '_blank', 'noopener,noreferrer')
}
return (
<div className="space-y-4">
<Helmet
@ -284,7 +279,12 @@ const ComponentManager: React.FC = () => {
color="gray-600"
disabled={!component.isActive || !component.routePath?.trim()}
title={translate('::OpenUrl')}
onClick={() => handleOpenRoute(component.routePath)}
onClick={() => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank')
}}
>
<FaRegWindowMaximize className="w-4 h-4" />
</Button>

View file

@ -19,7 +19,6 @@ import {
FaPlus,
FaPlay,
FaRedo,
FaRegCopy,
FaRegSave,
FaSearch,
FaSlidersH,
@ -38,6 +37,7 @@ import TailwindModal from '@/components/codeLayout/TailwindModal'
import { useStoreState } from '@/store/store'
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
DESIGNER_DATA_COMPONENT_NAMES,
DESIGNER_PRIMARY_EVENTS,
getDesignerCatalog,
getDesignerEventSnippet,
@ -95,11 +95,18 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'disabled',
'loading',
])
const OPTION_DATA_COMPONENT_NAMES = new Set(['Select', 'AutoComplete', 'Menu'])
const TABULAR_DATA_COMPONENT_NAMES = new Set(['Grid', 'Table'])
const OPTION_DATA_COMPONENT_NAMES = new Set([
'Select',
'AutoComplete',
'Menu',
'Dropdown',
'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) => (type === 'Menu' ? 'items' : 'options')
const getOptionDataProperty = (type?: string) =>
['Menu', 'Dropdown', 'Radio.Group'].includes(type || '') ? 'items' : 'options'
const isTabularDataComponent = (type?: string) =>
Boolean(type && TABULAR_DATA_COMPONENT_NAMES.has(type))
@ -260,6 +267,8 @@ const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = {
}
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
const normalizeTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const updateNodeTree = (
nodes: DesignerNode[],
@ -384,33 +393,49 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument
delete bindings.options
}
if (node.type === 'Table') {
if (
Array.isArray(props.data) &&
(!Array.isArray(props.items) || props.items.length === 0)
) {
props.items = props.data
}
if (Array.isArray(props.columns) && !Array.isArray(props.dataColumns)) {
props.dataColumns = props.columns
.map((column) => {
if (typeof column === 'string') return column
if (!column || typeof column !== 'object' || Array.isArray(column)) return ''
const record = column as Record<string, unknown>
return String(record.dataIndex ?? record.key ?? '')
})
.filter(Boolean)
}
const legacyRows = Array.isArray(props.items)
? props.items
: Array.isArray(props.data)
? props.data
: []
const firstLegacyRow = legacyRows.find(
(item) => item && typeof item === 'object' && !Array.isArray(item),
) as Record<string, unknown> | undefined
const legacyColumns = Array.isArray(props.dataColumns)
? props.dataColumns
: Array.isArray(props.columns)
? props.columns
: firstLegacyRow
? Object.keys(firstLegacyRow)
: []
props.rowCount = normalizeTableDimension(props.rowCount, legacyRows.length || 2)
props.columnCount = normalizeTableDimension(
props.columnCount,
legacyColumns.length || 3,
)
delete props.data
delete props.columns
if (!bindings.items && bindings.data) bindings.items = bindings.data
delete props.items
delete props.dataColumns
delete bindings.data
delete bindings.items
}
let children = normalizeNodes(node.children || [])
if (node.type === 'Table') {
const columnCount = normalizeTableDimension(props.columnCount, 3)
children = children.map((child, index) => ({
...child,
slot:
child.slot ||
`table:${Math.floor(index / columnCount)}:${index % columnCount}`,
}))
}
return {
...node,
props,
events,
bindings,
children: normalizeNodes(node.children || []),
children,
}
})
return {
@ -568,14 +593,16 @@ const migrateGeneratedCodeToDesignerDocument = (
/const tableItems_([A-Za-z0-9_$]+)\s*=\s*(\[[^\n]*\])[\s\S]*?const tableColumns_\1\s*=\s*(\[[^\n]*\])[\s\S]*?<UiKit\.Table\b([^>]*)>/g,
)) {
try {
const legacyItems = JSON.parse(match[2]) as unknown[]
const legacyColumns = JSON.parse(match[3]) as unknown[]
addNode(
match.index,
'Table',
'ui',
{
...parseGeneratedJsxProps(match[4], componentDefaultProps),
items: JSON.parse(match[2]),
dataColumns: JSON.parse(match[3]),
rowCount: normalizeTableDimension(legacyItems.length, 2),
columnCount: normalizeTableDimension(legacyColumns.length, 3),
},
{},
match[1],
@ -822,6 +849,10 @@ const VisualComponentDesigner = () => {
)
const catalog = useMemo(() => getDesignerCatalog(customNames), [customNames])
const catalogByName = useMemo(() => new Map(catalog.map((item) => [item.name, item])), [catalog])
const selectDesignerNode = useCallback((nodeId: string | null) => {
setSelectedId(nodeId || null)
setInspectorTab('properties')
}, [])
useEffect(() => {
let cancelled = false
@ -931,19 +962,20 @@ const VisualComponentDesigner = () => {
}, [document])
const addComponent = useCallback(
(definitionName: string, parentId: string | null = null) => {
(definitionName: string, parentId: string | null = null, slot?: string) => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
if (slot) node.slot = slot
commitDocument((current) => ({
...current,
sourceMode: 'visual',
nodes: insertNodeTree(current.nodes, node, parentId),
}))
setSelectedId(node.id)
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument],
[catalogByName, commitDocument, selectDesignerNode],
)
const selectedNode = useMemo(
@ -951,6 +983,9 @@ const VisualComponentDesigner = () => {
[document.nodes, selectedId],
)
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
const selectedIsDataComponent = Boolean(
selectedNode && DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type),
)
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
if (!selectedNode) return []
const definedProperties = selectedDefinition?.properties || []
@ -1066,6 +1101,19 @@ const VisualComponentDesigner = () => {
}))
}
const updateNodeProp = useCallback(
(nodeId: string, propertyName: string, value: unknown) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
},
[commitDocument],
)
const updateNodeEvent = (nodeId: string, eventName: string, value: string) => {
commitDocument((current) => ({
...current,
@ -1626,9 +1674,15 @@ const VisualComponentDesigner = () => {
filteredCatalog.forEach((item) => {
groups.get(item.toolboxGroup)?.push(item)
})
return TOOLBOX_GROUP_ORDER.map((group) => [group, groups.get(group) || []] as const).filter(
([, definitions]) => definitions.length > 0,
)
return TOOLBOX_GROUP_ORDER.map(
(group) =>
[
group,
[...(groups.get(group) || [])].sort((left, right) =>
left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' }),
),
] as const,
).filter(([, definitions]) => definitions.length > 0)
}, [filteredCatalog])
const activeDataSource = document.dataSources.find((source) => source.id === dataPanelSourceId)
@ -1653,8 +1707,10 @@ const VisualComponentDesigner = () => {
if (option && typeof option === 'object' && !Array.isArray(option)) {
const record = option as Record<string, unknown>
return {
label: String(record.label ?? `Seçenek ${index + 1}`),
value: record.value ?? index + 1,
label: String(
record.label ?? record.children ?? record.title ?? `Seçenek ${index + 1}`,
),
value: record.value ?? record.eventKey ?? record.key ?? index + 1,
}
}
return { label: String(option ?? ''), value: option ?? index + 1 }
@ -1884,14 +1940,20 @@ const VisualComponentDesigner = () => {
}, [activeDataSample, activeDataSource?.id, commitDocument, selectedId, selectedNode])
const dataBindableProperties =
selectedNode && selectedDefinition && selectedNode.kind !== 'platform'
selectedNode &&
selectedDefinition &&
DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type)
? selectedDefinition.properties.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
(isTabularDataComponent(selectedNode.type)
? property.name === 'items'
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) || property.type === 'array'),
: isOptionDataComponent(selectedNode.type)
? property.name === getOptionDataProperty(selectedNode.type)
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) ||
property.type === 'array' ||
['number', 'boolean', 'string', 'select'].includes(property.type)),
)
: []
@ -2275,7 +2337,6 @@ const VisualComponentDesigner = () => {
const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration()
const tabularConfiguration = renderTabularDataConfiguration()
if (
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)) &&
selectDataMode === 'static'
@ -2283,9 +2344,9 @@ const VisualComponentDesigner = () => {
return (
<div className="space-y-3">
{selectModeSelector}
{selectConfiguration}
{tabularConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs leading-5 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
Statik veri <strong>Properties</strong> sekmesindeki items/options alanından yönetilir.
</div>
</div>
)
}
@ -2551,7 +2612,7 @@ const VisualComponentDesigner = () => {
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${selectedId === node.id ? 'bg-sky-100 font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'}`}
style={{ paddingLeft: 8 + depth * 14 }}
type="button"
onClick={() => setSelectedId(node.id)}
onClick={() => selectDesignerNode(node.id)}
>
<FaLayerGroup className="shrink-0" />
<span className="truncate">{node.type}</span>
@ -2577,7 +2638,8 @@ const VisualComponentDesigner = () => {
background={appThemeMode === 'dark' ? '#0f172a' : document.canvas.background}
dataValues={dataSourceSamples}
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
onSelect={(nodeId) => setSelectedId(nodeId || null)}
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
onDropComponent={addComponent}
onMove={moveNode}
onDuplicate={duplicateNode}
@ -2687,7 +2749,7 @@ const VisualComponentDesigner = () => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank', 'noopener,noreferrer')
window.open(routePath, '_blank')
}}
>
@ -3074,9 +3136,17 @@ const VisualComponentDesigner = () => {
).map(([tab, icon, label]) => (
<button
key={tab}
className={`flex flex-1 items-center justify-center gap-1 border-b-2 text-[11px] ${inspectorTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500'}`}
className={`flex flex-1 items-center justify-center gap-1 border-b-2 text-[11px] ${tab === 'data' && !selectedIsDataComponent ? 'cursor-not-allowed border-transparent text-slate-300 dark:text-slate-700' : inspectorTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500'}`}
disabled={tab === 'data' && !selectedIsDataComponent}
title={
tab === 'data' && !selectedIsDataComponent
? 'Data sekmesi yalnızca Data kategorisindeki komponentlerde kullanılabilir.'
: undefined
}
type="button"
onClick={() => setInspectorTab(tab)}
onClick={() => {
if (tab !== 'data' || selectedIsDataComponent) setInspectorTab(tab)
}}
>
{icon}
{label}
@ -3126,8 +3196,18 @@ const VisualComponentDesigner = () => {
</div>
)}
</div>
{inspectorTab === 'properties'
? selectedProperties
{inspectorTab === 'properties' ? (
<>
{isOptionDataComponent(selectedNode.type) && selectDataMode === 'static'
? renderSelectDataConfiguration()
: null}
{isTabularDataComponent(selectedNode.type) && selectDataMode === 'static' ? (
<div className="mb-4 space-y-3">
{renderTabularDataConfiguration()}
{renderTabularColumnConfiguration()}
</div>
) : null}
{selectedProperties
.filter(
(property) =>
property.category !== 'events' &&
@ -3144,11 +3224,12 @@ const VisualComponentDesigner = () => {
.map((property) => {
const propertyValue =
selectedNode.props[property.name] ?? property.value
const snippet = getPropertySnippet(property, propertyValue)
const helperValue = getPropertyExampleValue(property, property.value)
const snippet = getPropertySnippet(property, helperValue)
return (
<div
key={property.name}
className="mb-4 rounded-md border border-slate-200 p-2 dark:border-slate-800"
className="group/property mb-4 rounded-md border border-slate-200 p-2 dark:border-slate-800"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>
@ -3157,8 +3238,26 @@ const VisualComponentDesigner = () => {
<b className="ml-0.5 text-red-500">*</b>
) : null}
</span>
<span className="text-[9px] font-normal text-slate-400">
{property.tsType || property.type}
<span className="flex items-center gap-1 text-[9px] font-normal text-slate-400">
<span>{property.tsType || property.type}</span>
<span className="group/snippet relative">
<button
className="rounded p-1 text-sky-500 opacity-0 transition hover:bg-sky-50 hover:text-sky-700 focus:opacity-100 group-hover/property:opacity-100 dark:hover:bg-sky-950"
title="Yardımcı değeri uygula"
type="button"
onClick={() =>
updateSelectedProp(property.name, clone(helperValue))
}
>
<FaBolt />
</button>
<span className="pointer-events-none absolute right-0 top-full z-30 mt-1 hidden w-56 rounded-md border border-slate-200 bg-white p-2 text-left font-mono text-[9px] leading-4 text-sky-700 shadow-lg group-hover/snippet:block dark:border-slate-700 dark:bg-slate-950 dark:text-sky-300">
{snippet}
<span className="mt-1 block font-sans text-[9px] text-slate-400">
Tıklayınca property değerine uygulanır.
</span>
</span>
</span>
</span>
</span>
{property.description && (
@ -3173,28 +3272,13 @@ const VisualComponentDesigner = () => {
options={property.options}
onChange={(value) => updateSelectedProp(property.name, value)}
/>
<details className="mt-2 rounded border border-slate-200 bg-slate-50 text-[10px] dark:border-slate-700 dark:bg-slate-950">
<summary className="cursor-pointer select-none px-2 py-1.5 font-semibold text-slate-400">
Snippet
</summary>
<div className="flex items-start gap-1 border-t border-slate-200 px-2 py-1.5 dark:border-slate-700">
<code className="min-w-0 flex-1 break-all leading-4 text-sky-700 dark:text-sky-300">
{snippet}
</code>
<button
className="shrink-0 rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-sky-600 dark:hover:bg-slate-800"
title="Snippet'ı kopyala"
type="button"
onClick={() => void navigator.clipboard?.writeText(snippet)}
>
<FaRegCopy />
</button>
</div>
</details>
</div>
)
})
: renderDataInspector()}
})}
</>
) : (
renderDataInspector()
)}
</>
) : (
<div className="space-y-4">

View file

@ -526,26 +526,12 @@ const TodoBoard = ({ listFormCode, searchParams, isSubForm, gridDto }: TodoBoard
useEffect(() => {
let cancelled = false
const legacyStorageKey = `todo-board:${listFormCode}:column-order`
let legacyConfig: StoredBoardColumns = { columns: [], retired: [] }
try {
const legacy = JSON.parse(localStorage.getItem(legacyStorageKey) ?? '{}')
legacyConfig = Array.isArray(legacy)
? { columns: legacy.map(String), retired: [] }
: {
columns: Array.isArray(legacy?.columns) ? legacy.columns.map(String) : [],
retired: Array.isArray(legacy?.retired) ? legacy.retired.map(String) : [],
}
} catch {
legacyConfig = { columns: [], retired: [] }
}
setColumnConfig(undefined)
if (!stateStoring?.enabled) {
const emptyConfig = { columns: [], retired: [] }
columnConfigRef.current = emptyConfig
setColumnConfig(emptyConfig)
localStorage.removeItem(legacyStorageKey)
return () => {
cancelled = true
}
@ -555,23 +541,14 @@ const TodoBoard = ({ listFormCode, searchParams, isSubForm, gridDto }: TodoBoard
.current()
.then((stored) => {
if (cancelled) return
const hasDatabaseState = Array.isArray(stored?.columns)
const config: StoredBoardColumns = hasDatabaseState
const config: StoredBoardColumns = Array.isArray(stored?.columns)
? {
columns: stored.columns.map(String),
retired: Array.isArray(stored?.retired) ? stored.retired.map(String) : [],
}
: legacyConfig
: { columns: [], retired: [] }
columnConfigRef.current = config
setColumnConfig(config)
if (!hasDatabaseState && legacyConfig.columns.length) {
customSaveStateRef
.current(config)
.then(() => localStorage.removeItem(legacyStorageKey))
.catch((error) => console.error('Todo board state migration error:', error))
} else {
localStorage.removeItem(legacyStorageKey)
}
})
.catch((error) => {
if (cancelled) return

View file

@ -107,7 +107,7 @@ export default defineConfig(async ({ mode }) => {
short_name: 'Sözsoft Platform',
theme_color: '#FF99C8',
background_color: '#f0e7db',
display: 'standalone',
display: 'browser',
icons: [
{
src: '/img/logo/logo-400.png',