sozsoft-platform/ui/src/views/developerKit/VisualComponentDesigner.tsx

4884 lines
203 KiB
TypeScript
Raw Normal View History

2026-08-05 20:51:43 +00:00
import Editor from '@monaco-editor/react'
import axios from 'axios'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import PageTitle from '@/components/shared/PageTitle'
2026-08-05 20:51:43 +00:00
import { Link, useParams } from 'react-router-dom'
import {
FaArrowLeft,
FaBolt,
FaCheckCircle,
FaArrowDown,
FaArrowUp,
2026-08-05 20:51:43 +00:00
FaChevronDown,
FaChevronRight,
FaCode,
FaDesktop,
FaDatabase,
FaEye,
FaEdit,
FaLayerGroup,
FaMobileAlt,
FaMousePointer,
FaPlus,
FaPlay,
FaRedo,
FaRegSave,
FaSearch,
FaSlidersH,
FaSync,
FaTabletAlt,
FaTimes,
2026-08-05 20:51:43 +00:00
FaTrash,
FaUndo,
} from 'react-icons/fa'
import { useComponents } from '@/contexts/ComponentContext'
import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models'
import { ROUTES_ENUM } from '@/routes/route.constant'
import apiService from '@/services/api.service'
import { developerKitService } from '@/services/developerKit.service'
import { Button, Notification, toast } from '@/components/ui'
2026-08-06 13:17:59 +00:00
import StyleModal from '@/components/codeLayout/StyleModal'
2026-08-05 20:51:43 +00:00
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
beginDesignerDragSoon,
endDesignerDrag,
useDesignerDrag,
} from '@/components/visualDesigner/designerDrag'
2026-08-05 20:51:43 +00:00
import {
DESIGNER_PRIMARY_EVENTS,
DESIGNER_SCRIPT_RECIPES,
DESIGNER_SCRIPT_RECIPE_GROUPS,
2026-08-05 20:51:43 +00:00
getDesignerCatalog,
getDesignerEventSnippet,
} from '@/components/visualDesigner/catalog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
2026-08-05 20:51:43 +00:00
import {
collectDesignerRefs,
getSqlDataSourceEvent,
2026-08-05 20:51:43 +00:00
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
isValidDesignerRef,
renameDesignerRefInNodes,
renameDesignerRefInScript,
withDesignerRefs,
getDesignerBindingColumns,
getDesignerCollectionProperty,
2026-08-05 20:51:43 +00:00
getDesignerValueByPath,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
getSqlDataSourcePreviewKey,
getSqlDataSourceRecord,
isDesignerOptionComponent,
isDesignerTabularComponent,
isSqlDataSourceNode,
2026-08-05 20:51:43 +00:00
resolveDesignerResponse,
toDesignerHttpMethod,
2026-08-05 20:51:43 +00:00
walkDesignerNodes,
appendSqlDataSourceQueryParam,
bindSqlDataSourceUrl,
hasSqlDataSourceUrlParams,
DESIGNER_HTTP_METHODS,
SQL_DATA_SOURCE_KEY_SOURCES,
SQL_DATA_SOURCE_SLOTS,
2026-08-05 20:51:43 +00:00
type DesignerComponentDefinition,
type DesignerBinding,
type DesignerDataSource,
type DesignerDocument,
type DesignerHttpMethod,
2026-08-05 20:51:43 +00:00
type DesignerNode,
type DesignerPropertyInfo,
} from '@/components/visualDesigner/types'
2026-08-06 13:17:59 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
2026-08-05 20:51:43 +00:00
type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript'
type InspectorTab = 'properties' | 'data' | 'tree'
type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string }
type EndpointResultModal = { source: DesignerDataSource; result: unknown }
type DataField = { path: string; type: string; preview: string }
type StaticSelectOption = { label: string; value: unknown }
type DataSourceCatalogItem = {
attached: boolean
key: string
origin: string
originType: 'current' | 'component' | 'crud'
ownerComponentId?: string
source: DesignerDataSource
}
const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'items',
'data',
'dataSource',
'options',
'value',
'checked',
'children',
'content',
'text',
'label',
'title',
'header',
'headerExtra',
'footer',
'description',
'placeholder',
'src',
'alt',
'disabled',
'loading',
])
/**
* Properties worth binding to a SqlDataSource record column, most used first.
* A component exposes dozens of props; offering every one of them turns the
* panel into noise, so only the ones that actually carry record data are listed.
*/
const SQL_RECORD_FIELD_PROPERTIES = [
'value',
'checked',
'children',
'text',
'label',
'title',
'placeholder',
'src',
'url',
'href',
'alt',
'content',
'description',
'header',
'footer',
'defaultValue',
'disabled',
]
const getSqlRecordFieldOrder = (propertyName: string) =>
SQL_RECORD_FIELD_PROPERTIES.indexOf(propertyName)
// Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is
// covered by the static/endpoint panel by construction.
const isOptionDataComponent = isDesignerOptionComponent
const getOptionDataProperty = getDesignerCollectionProperty
const isTabularDataComponent = isDesignerTabularComponent
/**
* The inspector's Data tab follows the toolbox category, not the collection
* registry: everything filed under `data` is expected to be data driven, even
* when it carries no collection property of its own.
*/
const isDataCategoryComponent = (definition?: DesignerComponentDefinition) =>
definition?.toolboxGroup === 'data'
2026-08-05 20:51:43 +00:00
const PROPERTY_EXAMPLE_VALUES: Record<string, unknown> = {
children: 'Örnek içerik',
className: 'p-4 rounded-lg shadow-sm',
id: 'component-id',
label: 'Örnek başlık',
name: 'exampleName',
placeholder: 'Bir değer girin…',
src: '/img/example.png',
title: 'Örnek başlık',
url: '/api/app/example',
}
const getPropertyExampleValue = (
property: DesignerPropertyInfo,
currentValue: unknown,
): unknown => {
const hasCurrentValue =
currentValue !== undefined &&
currentValue !== null &&
currentValue !== '' &&
(!Array.isArray(currentValue) || currentValue.length > 0) &&
(typeof currentValue !== 'object' ||
Array.isArray(currentValue) ||
Object.keys(currentValue as Record<string, unknown>).length > 0)
if (hasCurrentValue || typeof currentValue === 'boolean' || typeof currentValue === 'number') {
return currentValue
}
if (property.options?.length) return property.options[0]
if (PROPERTY_EXAMPLE_VALUES[property.name] !== undefined) {
return PROPERTY_EXAMPLE_VALUES[property.name]
}
if (property.name.toLowerCase().includes('color')) return '#0ea5e9'
if (property.type === 'boolean') return true
if (property.type === 'number') return 16
if (property.type === 'array') return [{ label: 'Örnek', value: 'example' }]
if (property.type === 'object') {
return property.name === 'style' ? { padding: 16, borderRadius: 8 } : { key: 'value' }
}
return 'Örnek değer'
}
const getPropertySnippet = (property: DesignerPropertyInfo, currentValue: unknown) => {
const exampleValue = getPropertyExampleValue(property, currentValue)
return typeof exampleValue === 'string'
? `${property.name}=${JSON.stringify(exampleValue)}`
: `${property.name}={${JSON.stringify(exampleValue)}}`
}
const getDataValueType = (value: unknown) => {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value
}
const getDataPreview = (value: unknown) => {
const text =
typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value ?? 'undefined'))
return text.length > 48 ? `${text.slice(0, 45)}` : text
}
const discoverDataFields = (sample: unknown): DataField[] => {
const fields: DataField[] = []
const root = Array.isArray(sample) ? sample[0] : sample
const visit = (value: unknown, path: string, depth: number) => {
if (!path || fields.length >= 100) {
if (!path && value && typeof value === 'object' && !Array.isArray(value)) {
Object.entries(value as Record<string, unknown>).forEach(([key, child]) =>
visit(child, key, depth),
)
}
return
}
const valueType = getDataValueType(value)
if (valueType === 'object' && depth < 4) {
Object.entries(value as Record<string, unknown>).forEach(([key, child]) =>
visit(child, `${path}.${key}`, depth + 1),
)
return
}
fields.push({ path, type: valueType, preview: getDataPreview(value) })
if (Array.isArray(value) && value[0] && typeof value[0] === 'object' && depth < 4) {
Object.entries(value[0] as Record<string, unknown>).forEach(([key, child]) =>
visit(child, `${path}.${key}`, depth + 1),
)
}
}
visit(root, '', 0)
return fields
}
const findDesignerAncestors = (
nodes: DesignerNode[],
id: string | null,
ancestors: DesignerNode[] = [],
): DesignerNode[] | null => {
if (!id) return null
for (const node of nodes) {
if (node.id === id) return ancestors
const found = findDesignerAncestors(node.children, id, [...ancestors, node])
if (found) return found
}
return null
}
const getSaveErrorMessage = (error: unknown) => {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error.message : 'Bileşen kaydedilemedi.'
}
const responseData = error.response?.data as
| { error?: { message?: string; details?: string }; message?: string }
| undefined
return (
responseData?.error?.details ||
responseData?.error?.message ||
responseData?.message ||
error.message ||
'Bileşen kaydedilemedi.'
)
}
const isRunnableDataSourceUrl = (url: string) => {
const normalizedUrl = url.trim().replace(/\/+$/, '')
return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app'
}
const TOOLBOX_GROUP_LABELS: Record<DesignerComponentDefinition['toolboxGroup'], string> = {
layout: 'Layouts',
html: 'HTML Elements',
platform: 'Platform Views',
data: 'Data',
ui: 'Sozsoft UI',
custom: 'My Components',
}
const TOOLBOX_GROUP_ORDER: DesignerComponentDefinition['toolboxGroup'][] = [
'layout',
'html',
'platform',
'data',
'ui',
'custom',
]
const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = {
responsive: '100%',
desktop: '1180px',
tablet: '768px',
mobile: '390px',
}
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)))
2026-08-05 20:51:43 +00:00
const updateNodeTree = (
nodes: DesignerNode[],
id: string,
updater: (node: DesignerNode) => DesignerNode,
): DesignerNode[] =>
nodes.map((node) => {
if (node.id === id) return updater(node)
return { ...node, children: updateNodeTree(node.children, id, updater) }
})
const removeNodeTree = (nodes: DesignerNode[], id: string): DesignerNode[] =>
nodes
.filter((node) => node.id !== id)
.map((node) => ({
...node,
children: removeNodeTree(node.children, id),
}))
const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] =>
nodes.map((node) => ({
...node,
bindings: Object.fromEntries(
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
),
children: removeDataSourceBindings(node.children, sourceId),
}))
const insertNodeTree = (
nodes: DesignerNode[],
nodeToInsert: DesignerNode,
parentId: string | null,
): DesignerNode[] => {
if (!parentId) return [...nodes, nodeToInsert]
return updateNodeTree(nodes, parentId, (node) => ({
...node,
children: [...node.children, nodeToInsert],
}))
}
const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
const result: DesignerNode[] = []
nodes.forEach((node) => {
result.push({ ...node, children: duplicateInTree(node.children, id) })
if (node.id === id) {
const copy = clone(node)
const renewIds = (item: DesignerNode) => {
item.id = createDesignerId()
item.children.forEach(renewIds)
}
renewIds(copy)
result.push(copy)
}
})
return result
}
2026-08-06 13:17:59 +00:00
const insertRelativeToNode = (
nodes: DesignerNode[],
targetId: string,
nodeToInsert: DesignerNode,
placement: 'before' | 'after',
): DesignerNode[] => {
const targetIndex = nodes.findIndex((node) => node.id === targetId)
if (targetIndex >= 0) {
const next = [...nodes]
next.splice(targetIndex + (placement === 'after' ? 1 : 0), 0, nodeToInsert)
return next
}
return nodes.map((node) => ({
...node,
children: insertRelativeToNode(node.children, targetId, nodeToInsert, placement),
}))
}
/**
* 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],
}))
}
2026-08-06 13:17:59 +00:00
const reorderNodeTree = (
nodes: DesignerNode[],
sourceId: string,
targetId: string,
placement: 'before' | 'after',
): DesignerNode[] => {
const source = findDesignerNode(nodes, sourceId)
const target = findDesignerNode(nodes, targetId)
if (!source || !target || sourceId === targetId || findDesignerNode(source.children, targetId)) {
return nodes
}
const movingNode = { ...source, slot: target.slot }
return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement)
}
2026-08-05 20:51:43 +00:00
const definitionToNode = (definition: DesignerComponentDefinition): DesignerNode => {
const props: Record<string, unknown> = {}
const events: Record<string, string> = {}
definition.properties.forEach((property) => {
if (property.category === 'events' || property.type === 'function') {
events[property.name] = typeof property.value === 'string' ? property.value : ''
} else {
props[property.name] = clone(property.value)
}
})
return {
id: createDesignerId(),
type: definition.name,
kind: definition.kind,
props,
events,
bindings: {},
children: [],
}
}
const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => {
2026-08-06 13:17:59 +00:00
const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const props = { ...(node.props || {}) }
const events = { ...(node.events || {}) }
const bindings = { ...(node.bindings || {}) }
if (node.type === 'Select') {
const legacyPropAliases: Record<string, string> = {
clearable: 'isClearable',
disabled: 'isDisabled',
multiple: 'isMulti',
searchable: 'isSearchable',
}
Object.entries(legacyPropAliases).forEach(([legacyName, runtimeName]) => {
if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
props[runtimeName] = props[legacyName]
2026-08-05 20:51:43 +00:00
}
2026-08-06 13:17:59 +00:00
delete props[legacyName]
2026-08-05 20:51:43 +00:00
})
2026-08-06 13:17:59 +00:00
if (events.onInputChange === undefined && events.onSearch !== undefined) {
events.onInputChange = events.onSearch
}
delete events.onSearch
}
if (node.type === 'Menu') {
if (!Array.isArray(props.items) && Array.isArray(props.options)) {
props.items = props.options
}
if (!bindings.items && bindings.options) bindings.items = bindings.options
delete props.options
delete props.variant
delete bindings.options
}
if (node.type === 'Table') {
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
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,
}
})
2026-08-05 20:51:43 +00:00
return {
...document,
// Documents saved before refs existed get theirs here, so every component is
// addressable from a script the moment it is opened.
nodes: withDesignerRefs(normalizeNodes(document.nodes)),
2026-08-06 13:17:59 +00:00
canvas: { width: document.canvas?.width || 'responsive' },
// Documents saved before non-GET sources existed have no usable method value.
dataSources: (Array.isArray(document.dataSources) ? document.dataSources : []).map(
(source) => ({ ...source, method: toDesignerHttpMethod(source.method) }),
),
2026-08-05 20:51:43 +00:00
}
}
const parseDesignerDocument = (rawProps: string | undefined): DesignerDocument | null => {
if (!rawProps) return null
try {
const parsed = JSON.parse(rawProps)
const document = parsed?.visualDesigner
if (document?.version === 1 && Array.isArray(document.nodes)) {
return normalizeDesignerDocument(document)
}
} catch {
return null
}
return null
}
const parseDesignerDocumentFromCode = (code: string | undefined): DesignerDocument | null => {
2026-08-06 13:17:59 +00:00
const encodedDocument = code?.match(/\/\*__SOZSOFT_VISUAL_DESIGNER__(.*?)__\*\//)?.[1]
2026-08-05 20:51:43 +00:00
if (!encodedDocument) return null
try {
const document = JSON.parse(decodeURIComponent(encodedDocument)) as DesignerDocument
return document?.version === 1 && Array.isArray(document.nodes)
? normalizeDesignerDocument(document)
: null
} catch {
return null
}
}
const parseComponentDefaultProps = (code: string): Record<string, unknown> => {
const defaults: Record<string, unknown> = {}
const parameters = code.match(
/(?:const\s+[A-Za-z_$][\w$]*\s*(?::[^=]+)?=\s*|function\s+[A-Za-z_$][\w$]*\s*)\(\s*\{([\s\S]*?)\}\s*\)/,
)?.[1]
if (!parameters) return defaults
for (const match of parameters.matchAll(
/([A-Za-z_$][\w$]*)\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|true|false|null|-?\d+(?:\.\d+)?)/g,
)) {
const rawValue = match[2]
try {
defaults[match[1]] = rawValue.startsWith("'")
? rawValue.slice(1, -1).replace(/\\'/g, "'")
: JSON.parse(rawValue)
} catch {
defaults[match[1]] = rawValue
}
}
return defaults
}
const parseGeneratedJsxProps = (
source: string,
expressionValues: Record<string, unknown> = {},
): Record<string, unknown> => {
const props: Record<string, unknown> = {}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)="((?:\\.|[^"])*)"/g)) {
try {
props[match[1]] = JSON.parse(`"${match[2]}"`)
} catch {
props[match[1]] = match[2]
}
}
2026-08-06 13:17:59 +00:00
for (const match of source.matchAll(
/([A-Za-z_$][\w$]*)=\{(true|false|null|-?\d+(?:\.\d+)?)\}/g,
)) {
2026-08-05 20:51:43 +00:00
props[match[1]] =
match[2] === 'true'
? true
: match[2] === 'false'
? false
: match[2] === 'null'
? null
: Number(match[2])
}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)=\{([A-Za-z_$][\w$]*)\}/g)) {
if (Object.prototype.hasOwnProperty.call(expressionValues, match[2])) {
props[match[1]] = expressionValues[match[2]]
}
}
return props
}
const migrateGeneratedCodeToDesignerDocument = (
code: string | undefined,
definitions: Map<string, DesignerComponentDefinition>,
): DesignerDocument | null => {
if (!code || !code.includes('export default')) return null
const document = createEmptyDesignerDocument('visual')
const componentDefaultProps = parseComponentDefaultProps(code)
const positionedNodes: Array<{ index: number; node: DesignerNode }> = []
const addNode = (
index: number,
type: string,
kind: DesignerNode['kind'],
props: Record<string, unknown> = {},
bindings: DesignerNode['bindings'] = {},
id = createDesignerId(),
) =>
positionedNodes.push({
index,
node: { id, type, kind, props, bindings, events: {}, children: [] },
})
for (const stateMatch of code.matchAll(
/const \[data_([A-Za-z0-9_$]+), setData_\1\] = React\.useState\(null\)([\s\S]*?)(?=\n\s*const \[data_|\n\s*const [A-Za-z_$][\w$]* = \(\{|$)/g,
)) {
const sourceId = stateMatch[1]
const block = stateMatch[2]
const urlMatch = block.match(/fetchData\(\{\s*url:\s*("(?:\\.|[^"])*")/)
if (!urlMatch) continue
const responsePathMatch = block.match(
new RegExp(`setData_${sourceId}\\(getByPath\\(response\\.data,\\s*("(?:\\\\.|[^"])*")`),
)
document.dataSources.push({
id: sourceId,
name: sourceId.replace(/^source_/, '').replace(/_/g, ' '),
method: 'GET',
url: JSON.parse(urlMatch[1]),
responsePath: responsePathMatch ? JSON.parse(responsePathMatch[1]) : '',
})
}
2026-08-06 13:17:59 +00:00
for (const match of code.matchAll(
/<UiKit\.(Select|AutoComplete|Menu|Pagination)\b([^>]*?)(?:\/>|>)/g,
)) {
2026-08-05 20:51:43 +00:00
const type = match[1]
const attributes = match[2]
const props = parseGeneratedJsxProps(attributes, componentDefaultProps)
const bindings: DesignerNode['bindings'] = {}
if (type === 'Select' || type === 'AutoComplete' || type === 'Menu') {
const optionsMatch = attributes.match(
/options=\{toSelectOptions\(getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\),\s*("(?:\\.|[^"])*"),\s*("(?:\\.|[^"])*")\)\}/,
)
if (optionsMatch) {
bindings.options = {
sourceId: optionsMatch[1],
path: JSON.parse(optionsMatch[2]),
labelPath: JSON.parse(optionsMatch[3]),
valuePath: JSON.parse(optionsMatch[4]),
}
}
}
addNode(match.index, type, 'ui', props, bindings)
}
for (const match of code.matchAll(
/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[]
2026-08-05 20:51:43 +00:00
addNode(
match.index,
'Table',
'ui',
{
...parseGeneratedJsxProps(match[4], componentDefaultProps),
rowCount: normalizeTableDimension(legacyItems.length, 2),
columnCount: normalizeTableDimension(legacyColumns.length, 3),
2026-08-05 20:51:43 +00:00
},
{},
match[1],
)
} catch {
// Ignore a table block that is not in the generated designer format.
}
}
for (const match of code.matchAll(
/const gridItems_([A-Za-z0-9_$]+)\s*=\s*([^\n]+)[\s\S]*?const gridColumns_\1\s*=\s*(\[[^\n]*\])/g,
)) {
const bindingMatch = match[2].match(/getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\)/)
const bindings: DesignerNode['bindings'] = {}
if (bindingMatch) {
bindings.items = { sourceId: bindingMatch[1], path: JSON.parse(bindingMatch[2]) }
}
try {
addNode(
match.index,
'Grid',
'ui',
{
2026-08-06 13:17:59 +00:00
className:
code
.slice(Math.max(0, match.index - 250), match.index)
.match(/<div className="([^"]*)"[^>]*>\s*\{\(\(\) => \{$/)?.[1] || '',
2026-08-05 20:51:43 +00:00
dataColumns: JSON.parse(match[3]),
compact: !code.slice(match.index, match.index + 5000).includes('px-3 py-2'),
hoverable: code.slice(match.index, match.index + 5000).includes('hover:bg-'),
overflow: code.slice(match.index, match.index + 5000).includes('overflow-auto'),
borderlessRow: !code.slice(match.index, match.index + 5000).includes('border-b'),
},
bindings,
match[1],
)
} catch {
// Ignore a grid block that is not in the generated designer format.
}
}
for (const match of code.matchAll(/<PlatformViewHost\b([^>]*?)\/>/g)) {
const props = parseGeneratedJsxProps(match[1], componentDefaultProps)
const view = String(props.view || '')
const platformTypes: Record<string, string> = {
List: 'ListView',
Edit: 'EditView',
Gantt: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
Pivot: 'PivotView',
Chart: 'ChartView',
}
addNode(match.index, platformTypes[view] || view || 'ListView', 'platform', props)
}
for (const match of code.matchAll(/<img\b([^>]*?)\/>/g)) {
addNode(match.index, 'img', 'html', parseGeneratedJsxProps(match[1], componentDefaultProps))
}
const ignoredCustomTags = new Set(['UiKit', 'PlatformViewHost', 'React', 'Fragment'])
for (const match of code.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b([^>]*?)\/>/g)) {
if (ignoredCustomTags.has(match[1]) || match[1].startsWith('UiKit')) continue
const definition = definitions.get(match[1])
addNode(
match.index,
match[1],
definition?.kind || 'custom',
parseGeneratedJsxProps(match[2], componentDefaultProps),
)
}
2026-08-06 13:17:59 +00:00
document.nodes = positionedNodes
.sort((left, right) => left.index - right.index)
.map(({ node }) => node)
2026-08-05 20:51:43 +00:00
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>
)
}
2026-08-05 20:51:43 +00:00
const PropertyEditor = ({
componentType,
2026-08-05 20:51:43 +00:00
name,
type,
value,
options,
onChange,
}: {
componentType?: string
2026-08-05 20:51:43 +00:00
name: string
type: string
value: unknown
options?: string[]
onChange: (value: unknown) => void
}) => {
2026-08-06 13:17:59 +00:00
const [styleModalOpen, setStyleModalOpen] = useState(false)
2026-08-05 20:51:43 +00:00
const inputClass =
'w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs text-slate-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
if (type === 'boolean') {
return (
<label className="flex cursor-pointer items-center justify-between rounded-md border border-slate-200 px-2.5 py-2 dark:border-slate-700">
<span className="text-xs text-slate-600 dark:text-slate-300">
{value ? 'Enabled' : 'Disabled'}
</span>
<input
checked={Boolean(value)}
className="h-4 w-4 accent-sky-600"
type="checkbox"
onChange={(event) => onChange(event.target.checked)}
/>
</label>
)
}
if (type === 'select' && options) {
const currentValue = String(value ?? '')
2026-08-05 20:51:43 +00:00
return (
<select
className={inputClass}
value={currentValue}
2026-08-05 20:51:43 +00:00
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>}
2026-08-05 20:51:43 +00:00
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
}
2026-08-05 20:51:43 +00:00
if (type === 'object' || type === 'array') {
return (
<textarea
className={`${inputClass} min-h-20 font-mono`}
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
onChange={(event) => {
try {
onChange(JSON.parse(event.target.value))
} catch {
onChange(event.target.value)
}
}}
/>
)
}
2026-08-06 13:17:59 +00:00
const normalizedPropertyName = name.toLowerCase()
const isCssProperty = type === 'string' && normalizedPropertyName.includes('css')
const isClassProperty =
type === 'string' &&
(normalizedPropertyName === 'class' ||
normalizedPropertyName.endsWith('class') ||
normalizedPropertyName.endsWith('classname'))
if (isClassProperty || isCssProperty) {
2026-08-05 20:51:43 +00:00
const currentClasses = String(value ?? '')
return (
<>
<div className="flex gap-2">
2026-08-06 13:17:59 +00:00
{isCssProperty ? (
<textarea
className={`${inputClass} min-h-20 font-mono`}
placeholder="CSS kodunu yazın…"
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<input
className={`${inputClass} font-mono`}
placeholder="CSS veya Tailwind class adlarını yazın…"
type="text"
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
)}
2026-08-05 20:51:43 +00:00
<Button
icon={<FaBolt />}
size="sm"
2026-08-06 13:17:59 +00:00
title={`${name} stillerini StyleModal ile düzenle`}
2026-08-05 20:51:43 +00:00
variant="default"
2026-08-06 13:17:59 +00:00
onClick={() => setStyleModalOpen(true)}
2026-08-05 20:51:43 +00:00
/>
</div>
2026-08-06 13:17:59 +00:00
<StyleModal
isOpen={styleModalOpen}
mode={isCssProperty ? 'css' : 'class'}
propertyName={name}
value={currentClasses}
onChange={onChange}
onClose={() => setStyleModalOpen(false)}
2026-08-05 20:51:43 +00:00
/>
</>
)
}
const multiline = name === 'children' || name === 'html' || String(value ?? '').length > 80
if (multiline) {
return (
<textarea
className={`${inputClass} min-h-20`}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
/>
)
}
return (
<input
className={inputClass}
2026-08-06 13:17:59 +00:00
placeholder={type === 'string' ? 'Metin veya ::LanguageKey' : undefined}
2026-08-05 20:51:43 +00:00
type={type === 'number' ? 'number' : name.toLowerCase().includes('color') ? 'color' : 'text'}
value={String(value ?? '')}
onChange={(event) =>
onChange(type === 'number' ? Number(event.target.value) : event.target.value)
}
/>
)
}
const VisualComponentDesigner = () => {
const { id } = useParams()
2026-08-06 13:17:59 +00:00
const { translate } = useLocalization()
2026-08-05 20:51:43 +00:00
const { components, updateComponent, renderComponent } = useComponents()
const [componentDetails, setComponentDetails] = useState<CustomComponent | null>(null)
const [componentLoadError, setComponentLoadError] = useState('')
const [loadedId, setLoadedId] = useState<string | null>(null)
const [document, setDocument] = useState<DesignerDocument>(() => createEmptyDesignerDocument())
const [manualCode, setManualCode] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('design')
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('properties')
// Editing buffer of the selected component's ref; committed on blur/Enter so a
// half typed name never lands in the document.
const [refDraft, setRefDraft] = useState('')
const [refError, setRefError] = useState('')
const [scriptApiOpen, setScriptApiOpen] = useState(false)
const [scriptApiSearch, setScriptApiSearch] = useState('')
/** Last focused event editor, so a Script API example lands in the right one. */
const [focusedEventName, setFocusedEventName] = useState('')
2026-08-05 20:51:43 +00:00
const [search, setSearch] = useState('')
const [expandedToolboxGroups, setExpandedToolboxGroups] = useState<
Record<DesignerComponentDefinition['toolboxGroup'], boolean>
>({ layout: true, html: true, platform: true, data: true, ui: true, custom: true })
const [isSaving, setIsSaving] = useState(false)
const [savedCode, setSavedCode] = useState('')
const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('')
const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({})
const [dataSourceSamples, setDataSourceSamples] = useState<Record<string, unknown>>({})
const [dataPanelSourceId, setDataPanelSourceId] = useState('')
const [selectDataModes, setSelectDataModes] = useState<Record<string, 'static' | 'endpoint'>>({})
const [staticCollectionDrafts, setStaticCollectionDrafts] = useState<Record<string, string>>({})
const [staticCollectionErrors, setStaticCollectionErrors] = useState<Record<string, string>>({})
const [generatedEndpoints, setGeneratedEndpoints] = useState<CrudEndpoint[]>([])
const [endpointCatalogLoading, setEndpointCatalogLoading] = useState(true)
const [endpointCatalogError, setEndpointCatalogError] = useState('')
const [catalogSourceEditor, setCatalogSourceEditor] = useState<{
item: DataSourceCatalogItem | null
draft: DesignerDataSource
} | null>(null)
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>(
null,
)
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
const activeDrag = useDesignerDrag()
/** Kural tabanlı script builder'ın düzenlediği event. */
const [scriptBuilderTarget, setScriptBuilderTarget] = useState<{
nodeId: string
eventName: string
} | null>(null)
2026-08-05 20:51:43 +00:00
const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
const undoStack = useRef<DesignerDocument[]>([])
const redoStack = useRef<DesignerDocument[]>([])
const component = componentDetails?.id === id ? componentDetails : undefined
const name = component?.name || 'VisualComponent'
// The tab shows what is being edited: the human readable description when the
// component carries one, the technical name otherwise.
const pageTitle =
component?.description?.trim() || component?.name || translate('::App.DeveloperKit.Components')
2026-08-05 20:51:43 +00:00
const customNames = useMemo(
() => components.filter((item) => item.id !== id && item.isActive).map((item) => item.name),
[components, id],
)
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')
}, [])
2026-08-05 20:51:43 +00:00
useEffect(() => {
let cancelled = false
setEndpointCatalogLoading(true)
setEndpointCatalogError('')
developerKitService
.getGeneratedListEndpoints()
.then((result) => {
if (!cancelled) setGeneratedEndpoints(result.items || [])
})
.catch((error) => {
if (!cancelled) setEndpointCatalogError(getSaveErrorMessage(error))
})
.finally(() => {
if (!cancelled) setEndpointCatalogLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
setComponentDetails(null)
setComponentLoadError('')
setLoadedId(null)
setSelectedId(null)
if (!id) return () => undefined
const loadComponentDetails = async () => {
try {
const currentComponent = await developerKitService.getCustomComponent(id)
if (!cancelled) setComponentDetails(currentComponent)
} catch (error) {
if (cancelled) return
setComponentLoadError(getSaveErrorMessage(error))
}
}
void loadComponentDetails()
return () => {
cancelled = true
}
}, [id])
useEffect(() => {
if (!id || !component || loadedId === id) return
const propsDocument = parseDesignerDocument(component.props)
const hasStoredDesignerNodes = Boolean(propsDocument?.nodes.length)
const codeBackupDocument = hasStoredDesignerNodes
? null
: parseDesignerDocumentFromCode(component.code)
const migratedCodeDocument =
hasStoredDesignerNodes || codeBackupDocument
? null
: migrateGeneratedCodeToDesignerDocument(component.code, catalogByName)
const visualDocument = hasStoredDesignerNodes
? propsDocument
: codeBackupDocument || migratedCodeDocument || propsDocument
const initialDocument = visualDocument || createEmptyDesignerDocument('code')
setDocument(initialDocument)
setDataTestResults({})
setDataSourceSamples({})
setManualCode(component.code || '')
setSavedCode(component.code || '')
setSavedDesignerFingerprint(JSON.stringify(initialDocument))
setWorkspaceTab('design')
setLoadedId(id)
undoStack.current = []
redoStack.current = []
}, [catalogByName, component, id, loadedId])
const generatedCode = useMemo(() => generateDesignerCode(name, document), [document, name])
const effectiveCode = document.sourceMode === 'visual' ? generatedCode : manualCode
const isDirty =
effectiveCode.trim() !== savedCode.trim() ||
JSON.stringify(document) !== savedDesignerFingerprint
const commitDocument = useCallback((updater: (current: DesignerDocument) => DesignerDocument) => {
setDocument((current) => {
undoStack.current.push(clone(current))
if (undoStack.current.length > 80) undoStack.current.shift()
redoStack.current = []
const next = updater(current)
// Single choke point for ref assignment: whichever way a node reaches the
// document — toolbox drop, duplicate, drag or paste — it leaves here with a
// unique handle that the event scripts can address.
return { ...next, nodes: withDesignerRefs(next.nodes) }
2026-08-05 20:51:43 +00:00
})
}, [])
const undo = useCallback(() => {
const previous = undoStack.current.pop()
if (!previous) return
redoStack.current.push(clone(document))
setDocument(previous)
}, [document])
const redo = useCallback(() => {
const next = redoStack.current.pop()
if (!next) return
undoStack.current.push(clone(document))
setDocument(next)
}, [document])
const addComponent = useCallback(
(definitionName: string, parentId: string | null = null, slot?: string) => {
2026-08-05 20:51:43 +00:00
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
if (slot) node.slot = slot
2026-08-05 20:51:43 +00:00
commitDocument((current) => ({
...current,
sourceMode: 'visual',
nodes: insertNodeTree(current.nodes, node, parentId),
}))
selectDesignerNode(node.id)
2026-08-05 20:51:43 +00:00
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
2026-08-05 20:51:43 +00:00
)
/**
* Toolbox drop next to an existing component rather than inside it the only
* way to place something beside a node that cannot host children.
*/
const addComponentBeside = useCallback(
(definitionName: string, targetId: string, placement: 'before' | 'after') => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
commitDocument((current) => {
// Table cells and tab panes address their children by slot, so the new
// node has to join the one it was dropped next to.
const target = findDesignerNode(current.nodes, targetId)
if (target?.slot) node.slot = target.slot
return {
...current,
sourceMode: 'visual',
nodes: insertRelativeToNode(current.nodes, targetId, node, placement),
}
})
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
)
2026-08-05 20:51:43 +00:00
const selectedNode = useMemo(
() => findDesignerNode(document.nodes, selectedId),
[document.nodes, selectedId],
)
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
/** Every addressable component of the page, keyed by ref. */
const documentRefs = useMemo(() => collectDesignerRefs(document.nodes), [document.nodes])
useEffect(() => {
setRefDraft(selectedNode?.ref || '')
setRefError('')
}, [selectedNode?.id, selectedNode?.ref])
/**
* Renames the selected component's handle and rewrites the scripts that use
* it, so `refs.button1` does not silently stop resolving once the ref is given
* a meaningful name.
*/
const commitRefRename = () => {
if (!selectedNode) return
const previous = selectedNode.ref || ''
const next = refDraft.trim()
if (!next || next === previous) {
setRefDraft(previous)
setRefError('')
return
}
if (!isValidDesignerRef(next)) {
setRefError('Ref bir JavaScript adı olmalıdır: harf ile başlamalı, boşluk içermemelidir.')
return
}
if (documentRefs.has(next)) {
setRefError('Bu ref sayfadaki başka bir komponente ait.')
return
}
setRefError('')
commitDocument((current) => ({
...current,
lifecycle: { onMount: renameDesignerRefInScript(current.lifecycle.onMount, previous, next) },
nodes: renameDesignerRefInNodes(
updateNodeTree(current.nodes, selectedNode.id, (node) => ({ ...node, ref: next })),
previous,
next,
),
}))
}
const copyToClipboard = (text: string, message: string) => {
void navigator.clipboard?.writeText(text)
toast.push(
<Notification type="success" duration={1800}>
{message}
</Notification>,
{ placement: 'bottom-end' },
)
}
const copyRefName = (ref: string) => copyToClipboard(`refs.${ref}`, `refs.${ref} panoya kopyalandı.`)
/**
* Fills the `{{ref}}` / `{{sql}}` / `{{url}}` placeholders of a Script API
* example with names that actually exist on this page, so a copied example
* runs without being edited first.
*/
const resolveRecipeCode = useCallback(
(code: string) => {
const entries = [...documentRefs.entries()]
const sqlRef = entries.find(([, node]) => isSqlDataSourceNode(node.type))?.[0]
const selectedRef =
selectedNode && !isSqlDataSourceNode(selectedNode.type) ? selectedNode.ref : undefined
const componentRef =
selectedRef ||
entries.find(([, node]) => !isSqlDataSourceNode(node.type) && node.kind === 'ui')?.[0] ||
entries.find(([, node]) => !isSqlDataSourceNode(node.type))?.[0]
const url = document.dataSources.find((source) => source.method === 'GET')?.url
return code
.replace(/\{\{ref\}\}/g, componentRef || 'button1')
.replace(/\{\{sql\}\}/g, sqlRef || 'sqlDataSource1')
.replace(/\{\{url\}\}/g, url || '/api/app/orders')
},
[document.dataSources, documentRefs, selectedNode],
)
const filteredScriptRecipes = useMemo(() => {
const query = scriptApiSearch.trim().toLocaleLowerCase('tr')
if (!query) return DESIGNER_SCRIPT_RECIPES
return DESIGNER_SCRIPT_RECIPES.filter((recipe) =>
`${recipe.group} ${recipe.title} ${recipe.description} ${recipe.code}`
.toLocaleLowerCase('tr')
.includes(query),
)
}, [scriptApiSearch])
/**
* Record of every SqlDataSource on the canvas, derived from the tested Select
* endpoint. Children bind to it through the container's node id, so it is
* merged into the canvas data values and into the binding inspector samples.
*/
const sqlDataSourceRecords = useMemo(() => {
const records: Record<string, unknown> = {}
walkDesignerNodes(document.nodes, (node) => {
if (isSqlDataSourceNode(node.type)) {
records[node.id] = getSqlDataSourceRecord(node, dataSourceSamples)
}
})
return records
}, [dataSourceSamples, document.nodes])
const previewDataValues = useMemo(
() => ({ ...dataSourceSamples, ...sqlDataSourceRecords }),
[dataSourceSamples, sqlDataSourceRecords],
)
const selectedAncestors = useMemo(
() => findDesignerAncestors(document.nodes, selectedId) || [],
[document.nodes, selectedId],
)
const selectedIsSqlDataSource = isSqlDataSourceNode(selectedNode?.type)
/** Nearest SqlDataSource above the selection; its record is bindable. */
const sqlScopeNode = useMemo(
() => [...selectedAncestors].reverse().find((node) => isSqlDataSourceNode(node.type)),
[selectedAncestors],
)
/**
* The container's record presented as a data source, so the existing endpoint
* dropdown, field discovery and binding editors work on it unchanged.
*/
const sqlScopeSource = useMemo<DesignerDataSource | null>(
() =>
sqlScopeNode
? {
id: sqlScopeNode.id,
name: `SqlDataSource kaydı · ${getSqlDataSourceKeyField(sqlScopeNode)}`,
method: 'GET',
url: '',
responsePath: '',
}
: null,
[sqlScopeNode],
)
/**
* Only a GET endpoint returns rows, so only GET can feed a property binding
* and only one that is directly callable: a `{id}` URL has no key to fill it
* with outside a SqlDataSource, so it cannot back a Grid or a Select list.
*/
const bindableDataSources = useMemo(
() =>
document.dataSources.filter(
(source) => source.method === 'GET' && !hasSqlDataSourceUrlParams(source.url),
),
[document.dataSources],
)
/** The Select slot additionally accepts GetById, whose key comes from the URL. */
const selectSlotDataSources = useMemo(
() => document.dataSources.filter((source) => source.method === 'GET'),
[document.dataSources],
)
/**
* A collection component keeps managing its own list endpoint even inside a
* SqlDataSource the option list of a Select and the record the form edits are
* two different things. Everything else inside the container is a record field
* and may only bind to the container's Select result.
*/
const selectedIsCollectionComponent =
isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)
const sqlScopeLocked = Boolean(sqlScopeNode) && !selectedIsCollectionComponent
const inspectorDataSources = useMemo(
() => (sqlScopeLocked ? (sqlScopeSource ? [sqlScopeSource] : []) : bindableDataSources),
[bindableDataSources, sqlScopeLocked, sqlScopeSource],
)
const selectedIsDataComponent = Boolean(
selectedNode &&
(isDataCategoryComponent(selectedDefinition) || selectedIsSqlDataSource || sqlScopeNode),
)
2026-08-05 20:51:43 +00:00
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
if (!selectedNode) return []
const definedProperties = selectedDefinition?.properties || []
const definedNames = new Set(definedProperties.map((property) => property.name))
const storedProperties = Object.entries(selectedNode.props)
.filter(([propertyName]) => !definedNames.has(propertyName))
.map(([propertyName, value]) => ({
name: propertyName,
type: (Array.isArray(value)
? 'array'
: value !== null && typeof value === 'object'
? 'object'
: typeof value === 'boolean'
? 'boolean'
: typeof value === 'number'
? 'number'
: 'string') as DesignerPropertyInfo['type'],
value,
category: 'properties' as const,
}))
const properties = [...definedProperties, ...storedProperties]
if (!properties.some((property) => property.name === 'className')) {
properties.push({
name: 'className',
type: 'string',
value: '',
category: 'styling',
})
}
return properties
}, [selectedDefinition, selectedNode])
const selectedBindingSourceId = selectedNode
? Object.values(selectedNode.bindings || {}).find((binding) =>
inspectorDataSources.some((source) => source.id === binding.sourceId),
2026-08-05 20:51:43 +00:00
)?.sourceId
: undefined
useEffect(() => {
setDataPanelSourceId((current) => {
if (selectedBindingSourceId) return selectedBindingSourceId
if (inspectorDataSources.some((source) => source.id === current)) return current
// Must fall back within the selectable set: a default the picker does not
// list leaves `activeDataSource` undefined while the dropdown still paints
// its first option, so the panel looks configured when nothing is selected.
return inspectorDataSources[0]?.id || ''
2026-08-05 20:51:43 +00:00
})
}, [inspectorDataSources, selectedBindingSourceId])
2026-08-05 20:51:43 +00:00
const dataSourceCatalog = useMemo<DataSourceCatalogItem[]>(() => {
const items = new Map<string, DataSourceCatalogItem>()
const catalogComponents = componentDetails
? [componentDetails, ...components.filter((storedComponent) => storedComponent.id !== id)]
: components
catalogComponents.forEach((storedComponent) => {
const storedDocument = parseDesignerDocument(storedComponent.props)
storedDocument?.dataSources.forEach((source) => {
const dataSourceKey = `${source.method}:${source.url.trim()}:${source.responsePath.trim()}`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(currentSource) =>
currentSource.method === source.method &&
currentSource.url.trim() === source.url.trim() &&
currentSource.responsePath.trim() === source.responsePath.trim(),
)
items.set(dataSourceKey, {
attached,
key: `component:${storedComponent.id}:${source.id}`,
origin: `${storedComponent.name} componenti`,
originType: 'component',
ownerComponentId: storedComponent.id,
source,
})
})
})
generatedEndpoints
// Every active endpoint is offered, including GetById: its `{id}` is filled
// by the SqlDataSource from the page URL, exactly like Update and Delete.
.filter((endpoint) => endpoint.isActive)
2026-08-05 20:51:43 +00:00
.forEach((endpoint) => {
const method = toDesignerHttpMethod(endpoint.method)
const dataSourceKey = `${method}:${endpoint.path.trim()}:`
2026-08-05 20:51:43 +00:00
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(source) => source.method === method && source.url.trim() === endpoint.path.trim(),
2026-08-05 20:51:43 +00:00
)
items.set(dataSourceKey, {
attached,
key: `generated:${endpoint.id}`,
origin: 'CRUD Endpoint Manager',
originType: 'crud',
source: {
id: `generated_${endpoint.id}`,
name: `${endpoint.entityName} · ${endpoint.operationType}`,
method,
2026-08-05 20:51:43 +00:00
url: endpoint.path,
responsePath: '',
},
})
})
return [...items.values()]
}, [componentDetails, components, document.dataSources, generatedEndpoints, id])
const reusableDataSources = useMemo(() => dataSourceCatalog, [dataSourceCatalog])
const updateSelectedProp = (propertyName: string, value: unknown) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
}
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],
)
2026-08-05 20:51:43 +00:00
const updateNodeEvent = (nodeId: string, eventName: string, value: string) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
events: { ...node.events, [eventName]: value },
})),
}))
}
const updateSelectedBinding = (propertyName: string, sourceId: string, path = '') => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
if (sourceId) bindings[propertyName] = { sourceId, path }
else delete bindings[propertyName]
return { ...node, bindings }
}),
}))
}
const updateSelectedBindingDetails = (
propertyName: string,
details: Partial<DesignerBinding>,
) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const binding = node.bindings?.[propertyName]
if (!binding) return node
return {
...node,
bindings: {
...node.bindings,
[propertyName]: { ...binding, ...details },
},
}
}),
}))
}
const addDataSource = () => {
const dataSource: DesignerDataSource = {
id: `source_${createDesignerId().slice(-8)}`,
name: `Endpoint ${document.dataSources.length + 1}`,
method: 'GET',
url: '',
responsePath: '',
}
setCatalogSourceEditor({ item: null, draft: dataSource })
setCatalogSourceTestResult(null)
}
const addReusableDataSource = (catalogItem: DataSourceCatalogItem) => {
const existingSource = document.dataSources.find(
(source) =>
source.method === catalogItem.source.method &&
source.url.trim() === catalogItem.source.url.trim() &&
source.responsePath.trim() === catalogItem.source.responsePath.trim(),
)
// Only a directly callable GET source produces rows. A write endpoint or a
// GetById is attached to a SqlDataSource command slot from the inspector.
const bindsCollection =
catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url)
2026-08-05 20:51:43 +00:00
if (existingSource) {
if (!bindsCollection) {
setDataPanelSourceId(existingSource.id)
return
}
2026-08-05 20:51:43 +00:00
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
updateSelectedBinding('items', existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
} else if (selectedId && isOptionDataComponent(selectedNode?.type)) {
updateSelectedBinding(getOptionDataProperty(selectedNode?.type), existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(existingSource.id)
return
}
const dataSource: DesignerDataSource = {
...catalogItem.source,
id: `source_${createDesignerId().slice(-8)}`,
}
commitDocument((current) => ({
...current,
dataSources: [...current.dataSources, dataSource],
nodes:
bindsCollection &&
2026-08-05 20:51:43 +00:00
selectedId &&
(isTabularDataComponent(selectedNode?.type) || isOptionDataComponent(selectedNode?.type))
? updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
[isTabularDataComponent(selectedNode?.type)
? 'items'
: getOptionDataProperty(selectedNode?.type)]: {
sourceId: dataSource.id,
path: '',
},
},
}))
: current.nodes,
}))
if (
bindsCollection &&
2026-08-05 20:51:43 +00:00
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(dataSource.id)
}
/**
* `urlOverride` carries a URL whose `{id}` was already filled in a GetById
* endpoint cannot be sampled otherwise, and without a sample the designer has
* no columns to offer the components inside the SqlDataSource.
*/
const testDataSource = useCallback(
async (source: DesignerDataSource, showResult = false, urlOverride?: string) => {
// Running a POST/PUT/DELETE endpoint would mutate real data, so write sources
// are never executed from the designer — they are only wired to a command slot.
if (source.method !== 'GET') {
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'error',
message: `${source.method} endpointleri tasarım ekranından çalıştırılmaz; SqlDataSource komutuna bağlayın.`,
},
}))
return
2026-08-05 20:51:43 +00:00
}
if (showResult) setEndpointResultModal(null)
2026-08-05 20:51:43 +00:00
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'loading', message: 'İstek gönderiliyor…' },
2026-08-05 20:51:43 +00:00
}))
const requestUrl = (urlOverride ?? source.url).trim()
try {
if (!requestUrl) {
throw new Error('Endpoint URL alanını doldurun.')
}
if (!requestUrl.startsWith('/api/')) {
throw new Error('Platform endpoint adresi /api/ ile başlamalıdır.')
}
if (!isRunnableDataSourceUrl(requestUrl)) {
throw new Error('Çağrılabilir bir endpoint URL girin.')
}
if (hasSqlDataSourceUrlParams(requestUrl)) {
throw new Error(
'Bu endpoint bir key parametresi bekliyor. SqlDataSource üzerinde önizleme key değeri girin.',
)
}
const response = await apiService.fetchData({ method: source.method, url: requestUrl })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(`Response path bulunamadı: ${source.responsePath}`)
}
const message =
typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result))
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'success', message },
}))
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
if (showResult) setEndpointResultModal({ source, result })
} catch (error) {
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'error', message: getSaveErrorMessage(error) },
}))
}
},
[],
)
2026-08-05 20:51:43 +00:00
const persistCatalogOwnerDocument = useCallback(
async (item: DataSourceCatalogItem, nextDocument: DesignerDocument) => {
if (item.originType !== 'component' || !item.ownerComponentId) {
throw new Error('CRUD Endpoint Manager kaynakları salt okunurdur.')
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
if (!owner) throw new Error('Endpointin bağlı olduğu Custom Component bulunamadı.')
let existingProps: Record<string, unknown> = {}
try {
const parsedProps = owner.props ? JSON.parse(owner.props) : {}
if (parsedProps && typeof parsedProps === 'object' && !Array.isArray(parsedProps)) {
existingProps = parsedProps
}
} catch {
existingProps = {}
}
const update = {
name: owner.name,
routePath: owner.routePath,
description: owner.description,
dependencies: owner.dependencies,
code:
nextDocument.sourceMode === 'visual'
? generateDesignerCode(owner.name, nextDocument)
: owner.code,
props: JSON.stringify({ ...existingProps, visualDesigner: nextDocument }),
isActive: owner.isActive,
}
await updateComponent(owner.id, update)
return update
},
[componentDetails, components, updateComponent],
)
const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
setCatalogSourceEditor({ item, draft: { ...item.source } })
setCatalogSourceTestResult(null)
}
const updateCatalogSourceDraft = (updates: Partial<DesignerDataSource>) => {
const sourceId = catalogSourceEditor?.draft.id
if (sourceId) {
setDataSourceSamples((samples) => {
const next = { ...samples }
delete next[sourceId]
return next
})
}
setCatalogSourceEditor((current) =>
current ? { ...current, draft: { ...current.draft, ...updates } } : current,
)
setCatalogSourceTestResult(null)
}
const testCatalogSource = async () => {
if (!catalogSourceEditor) return
const source = catalogSourceEditor.draft
if (source.method !== 'GET') {
setCatalogSourceTestResult({
status: 'error',
message: `${source.method} endpointleri veriyi değiştirdiği için tasarım ekranından çalıştırılmaz.`,
})
return
}
2026-08-05 20:51:43 +00:00
setCatalogSourceTestResult({ status: 'loading', message: 'İstek gönderiliyor…' })
try {
if (!source.name.trim()) throw new Error('Endpoint adı boş olamaz.')
if (!isRunnableDataSourceUrl(source.url)) {
throw new Error('Çağrılabilir ve /api/ ile başlayan bir endpoint URL girin.')
}
const response = await apiService.fetchData({ method: source.method, url: source.url.trim() })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(`Response path bulunamadı: ${source.responsePath}`)
}
setCatalogSourceTestResult({
status: 'success',
message:
typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result)),
})
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
setEndpointResultModal({ source, result })
} catch (error) {
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setCatalogSourceTestResult({ status: 'error', message: getSaveErrorMessage(error) })
}
}
const saveCatalogSource = async () => {
if (!catalogSourceEditor) return
const { item, draft } = catalogSourceEditor
if (!draft.name.trim()) {
setCatalogSourceTestResult({ status: 'error', message: 'Endpoint adı boş olamaz.' })
return
}
if (!isRunnableDataSourceUrl(draft.url)) {
setCatalogSourceTestResult({
status: 'error',
message: 'Çağrılabilir ve /api/ ile başlayan bir endpoint URL girin.',
})
return
}
// A write endpoint cannot be verified without side effects, so the successful
// run is only demanded from GET sources.
if (draft.method === 'GET' && catalogSourceTestResult?.status !== 'success') {
2026-08-05 20:51:43 +00:00
setCatalogSourceTestResult({
status: 'error',
message: 'Kaydetmeden önce endpointi başarıyla çalıştırın.',
})
return
}
if (!item) {
if (!id || !componentDetails) return
setIsCatalogSourceSaving(true)
try {
const storedDocument = parseDesignerDocument(componentDetails.props)
const baseDocument = storedDocument || { ...document, dataSources: [] }
const nextDocument: DesignerDocument = {
...baseDocument,
dataSources: [
...baseDocument.dataSources.filter((source) => source.id !== draft.id),
draft,
],
}
const catalogItem: DataSourceCatalogItem = {
attached: true,
key: `component:${id}:${draft.id}`,
origin: `${componentDetails.name} componenti`,
originType: 'component',
ownerComponentId: id,
source: draft,
}
const update = await persistCatalogOwnerDocument(catalogItem, nextDocument)
const localDocument: DesignerDocument = {
...document,
dataSources: [...document.dataSources.filter((source) => source.id !== draft.id), draft],
}
setDocument(localDocument)
setDataPanelSourceId(draft.id)
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
toast.push(
<Notification type="success" duration={2400}>
Endpoint eklendi ve kullanılabilir kaynaklara kaydedildi.
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({ status: 'error', message: getSaveErrorMessage(error) })
} finally {
setIsCatalogSourceSaving(false)
}
return
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
const ownerDocument = parseDesignerDocument(owner?.props)
if (!ownerDocument) {
setCatalogSourceTestResult({
status: 'error',
message: 'Custom Component tasarım şeması okunamadı.',
})
return
}
setIsCatalogSourceSaving(true)
try {
const nextDocument = {
...ownerDocument,
dataSources: ownerDocument.dataSources.map((source) =>
source.id === item.source.id ? { ...draft, id: source.id } : source,
),
}
const update = await persistCatalogOwnerDocument(item, nextDocument)
if (item.ownerComponentId === id) {
setDocument((current) => ({
...current,
dataSources: current.dataSources.map((source) =>
source.id === item.source.id ? { ...draft, id: source.id } : source,
),
}))
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
}
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
toast.push(
<Notification type="success" duration={2400}>
Endpoint kaynağı güncellendi.
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({ status: 'error', message: getSaveErrorMessage(error) })
} finally {
setIsCatalogSourceSaving(false)
}
}
const deleteCatalogSource = async (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
const ownerDocument =
item.ownerComponentId === id ? document : parseDesignerDocument(owner?.props)
if (!ownerDocument) return
if (!window.confirm(`"${item.source.name}" endpoint kaynağı silinsin mi?`)) return
try {
const nextDocument = {
...ownerDocument,
dataSources: ownerDocument.dataSources.filter((source) => source.id !== item.source.id),
nodes: removeDataSourceBindings(ownerDocument.nodes, item.source.id),
}
const update = await persistCatalogOwnerDocument(item, nextDocument)
if (item.ownerComponentId === id) {
setDocument(nextDocument)
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
}
toast.push(
<Notification type="success" duration={2400}>
Endpoint kaynağı ve bağlı property bağlantıları silindi.
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
toast.push(
<Notification type="danger" duration={4000}>
{getSaveErrorMessage(error)}
</Notification>,
{ placement: 'bottom-end' },
)
}
}
const deleteNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: removeNodeTree(current.nodes, nodeId) }))
setSelectedId((current) => (current === nodeId ? null : current))
},
[commitDocument],
)
const duplicateNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: duplicateInTree(current.nodes, nodeId) }))
},
[commitDocument],
)
const moveNode = useCallback(
(nodeId: string, direction: -1 | 1) => {
commitDocument((current) => ({
...current,
nodes: moveInTree(current.nodes, nodeId, direction),
}))
},
[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],
)
2026-08-06 13:17:59 +00:00
const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({
...current,
nodes: reorderNodeTree(current.nodes, sourceId, targetId, placement),
}))
selectDesignerNode(sourceId)
},
[commitDocument, selectDesignerNode],
)
2026-08-05 20:51:43 +00:00
const collectDependencies = useCallback(() => {
const names = new Set<string>()
walkDesignerNodes(document.nodes, (node) => {
if (node.kind === 'custom') names.add(node.type)
})
return [...names]
}, [document.nodes])
const handleSave = useCallback(
async (successMessage = 'Bileşen ve tasarım şeması kaydedildi.') => {
if (!id || !component) return
setIsSaving(true)
try {
const invalidDataSource = document.dataSources.find(
(source) => source.url.trim() && !source.url.trim().startsWith('/api/'),
)
if (invalidDataSource) {
throw new Error(
`${invalidDataSource.name}: Platform endpoint adresi /api/ ile başlamalıdır.`,
)
}
let existingProps: Record<string, unknown> = {}
try {
const parsed = component.props ? JSON.parse(component.props) : {}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) existingProps = parsed
} catch {
existingProps = {}
}
const codeToSave = document.sourceMode === 'visual' ? generatedCode : manualCode
const dependenciesToSave = JSON.stringify(
document.sourceMode === 'visual'
? collectDependencies()
: (() => {
try {
return JSON.parse(component.dependencies || '[]')
} catch {
return []
}
})(),
)
const propsToSave =
document.sourceMode === 'visual'
? JSON.stringify({ ...existingProps, visualDesigner: document })
: component.props
const update = {
name: component.name,
routePath: component.routePath,
description: component.description,
dependencies: dependenciesToSave,
code: codeToSave.trim(),
props: propsToSave,
isActive: component.isActive,
}
await updateComponent(id, update)
setComponentDetails((current) =>
current?.id === id
? { ...current, ...update, lastModificationTime: new Date().toISOString() }
: current,
)
setSavedCode(codeToSave)
setSavedDesignerFingerprint(JSON.stringify(document))
toast.push(
<Notification type="success" duration={2400}>
{successMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
console.error('Visual component save failed:', error)
const errorMessage = getSaveErrorMessage(error)
toast.push(
<Notification type="danger" duration={4000}>
{errorMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsSaving(false)
}
},
[collectDependencies, component, document, generatedCode, id, manualCode, updateComponent],
)
useEffect(() => {
const keyHandler = (event: KeyboardEvent) => {
2026-08-06 13:17:59 +00:00
const target = event.target
const isPropertyEditing =
target instanceof HTMLElement &&
Boolean(
target.closest(
'[data-property-panel], input, textarea, select, [contenteditable="true"], [role="textbox"], .monaco-editor',
),
)
2026-08-05 20:51:43 +00:00
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void handleSave()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) redo()
else undo()
}
if (event.key === 'Delete' && !isPropertyEditing && selectedId && workspaceTab === 'design') {
2026-08-06 13:17:59 +00:00
deleteNode(selectedId)
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
setScriptApiOpen((current) => !current)
}
if (event.key === 'Escape') setScriptApiOpen(false)
2026-08-05 20:51:43 +00:00
}
window.addEventListener('keydown', keyHandler)
return () => window.removeEventListener('keydown', keyHandler)
}, [deleteNode, handleSave, redo, selectedId, undo, workspaceTab])
const filteredCatalog = useMemo(() => {
const term = search.trim().toLocaleLowerCase('tr')
return catalog.filter(
(item) =>
!term ||
`${item.name} ${item.description} ${item.category}`.toLocaleLowerCase('tr').includes(term),
)
}, [catalog, search])
const groupedCatalog = useMemo(() => {
const groups = new Map<
DesignerComponentDefinition['toolboxGroup'],
DesignerComponentDefinition[]
>(TOOLBOX_GROUP_ORDER.map((group) => [group, []]))
filteredCatalog.forEach((item) => {
groups.get(item.toolboxGroup)?.push(item)
})
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)
2026-08-05 20:51:43 +00:00
}, [filteredCatalog])
const activeDataSource = inspectorDataSources.find((source) => source.id === dataPanelSourceId)
const activeDataSample = activeDataSource ? previewDataValues[activeDataSource.id] : undefined
/**
* Reads every GET endpoint that has no sample yet: the ones stored with the
* document, and any attached later from the reusable catalog. Without this a
* freshly attached endpoint shows up as selected in the inspector while its
* column list and therefore the label/value mapping stays empty.
* A recorded test result means it already ran, so failures do not loop.
*/
useEffect(() => {
if (!id || loadedId !== id) return
// A GetById Select is sampled through the owning SqlDataSource's preview key,
// since there is no page URL to read the real key from while designing.
const previewUrls = new Map<string, string>()
walkDesignerNodes(document.nodes, (node) => {
if (!isSqlDataSourceNode(node.type)) return
const previewKey = getSqlDataSourcePreviewKey(node)
if (!previewKey) return
const source = document.dataSources.find(
(item) => item.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
)
if (!source) return
const parameterName = getSqlDataSourceKeyParam(node)
const bound = bindSqlDataSourceUrl(source.url, { [parameterName]: previewKey }, parameterName)
previewUrls.set(
source.id,
bound.keyBound
? bound.url
: appendSqlDataSourceQueryParam(bound.url, parameterName, previewKey),
)
})
document.dataSources.forEach((source) => {
if (source.method !== 'GET') return
if (dataSourceSamples[source.id] !== undefined || dataTestResults[source.id]) return
const requestUrl = previewUrls.get(source.id) ?? source.url
// An unresolved `{id}` would just 404; it waits for a preview key instead.
if (hasSqlDataSourceUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
void testDataSource(source, false, requestUrl)
})
}, [
dataSourceSamples,
dataTestResults,
document.dataSources,
document.nodes,
id,
loadedId,
testDataSource,
])
2026-08-05 20:51:43 +00:00
const optionDataProperty = getOptionDataProperty(selectedNode?.type)
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
? selectedNode?.bindings?.[optionDataProperty]
: undefined
const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding)
2026-08-05 20:51:43 +00:00
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
const selectDataMode =
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
? selectDataModes[selectedId] ||
(selectOptionsBinding?.sourceId || tabularItemsBinding?.sourceId ? 'endpoint' : 'static')
: 'static'
const staticSelectOptions: StaticSelectOption[] =
isOptionDataComponent(selectedNode?.type) &&
Array.isArray(selectedNode?.props[optionDataProperty])
? (selectedNode.props[optionDataProperty] as unknown[]).map((option, index) => {
if (option && typeof option === 'object' && !Array.isArray(option)) {
const record = option as Record<string, unknown>
return {
label: String(
record.label ?? record.children ?? record.title ?? `Seçenek ${index + 1}`,
),
value: record.value ?? record.eventKey ?? record.key ?? index + 1,
2026-08-05 20:51:43 +00:00
}
}
return { label: String(option ?? ''), value: option ?? index + 1 }
})
: []
const selectCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const tabularCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const selectCollectionSample = useMemo(() => {
if (activeDataSample === undefined) return undefined
const path = selectOptionsBinding?.path || ''
const value = getDesignerValueByPath(activeDataSample, path)
return Array.isArray(value) ? value : undefined
}, [activeDataSample, selectOptionsBinding?.path])
const selectColumnFields = useMemo(
() =>
selectCollectionSample === undefined
? []
: discoverDataFields(selectCollectionSample).filter(
(field) => !['array', 'object'].includes(field.type),
),
[selectCollectionSample],
)
const setSelectDataMode = (mode: 'static' | 'endpoint') => {
if (
!selectedId ||
(!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
)
return
setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
if (isTabularDataComponent(selectedNode?.type)) {
// Only a source the picker lists can be bound; a POST/PUT entry or the
// SqlDataSource record would silently produce an unusable collection.
const source = activeDataSource || inspectorDataSources[0]
2026-08-05 20:51:43 +00:00
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
const props = { ...node.props }
delete props.dataColumns
if (mode === 'endpoint' && source) {
bindings.items = { sourceId: source.id, path: '' }
} else {
delete bindings.items
}
return { ...node, bindings, props }
}),
}))
if (mode === 'static') {
const staticItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
setStaticCollectionDrafts((current) => ({
...current,
[selectedId]: JSON.stringify(staticItems, null, 2),
}))
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
} else if (source && dataSourceSamples[source.id] === undefined) {
void testDataSource(source)
}
return
}
if (mode === 'static') {
updateSelectedBinding(optionDataProperty, '')
return
}
const source = activeDataSource || inspectorDataSources[0]
2026-08-05 20:51:43 +00:00
if (source) {
updateSelectedBinding(optionDataProperty, source.id, '')
if (dataSourceSamples[source.id] === undefined) void testDataSource(source)
}
}
const updateStaticSelectOptions = (options: StaticSelectOption[]) => {
updateSelectedBinding(optionDataProperty, '')
updateSelectedProp(optionDataProperty, options)
if (selectedId) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
}
}
const updateStaticCollection = (rawValue: string) => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type)) return
setStaticCollectionDrafts((current) => ({ ...current, [selectedId]: rawValue }))
try {
const value = JSON.parse(rawValue)
if (!Array.isArray(value)) throw new Error('Statik veri bir JSON array olmalıdır.')
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
delete bindings.items
const previousFields = discoverDataFields(node.props.items)
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
const nextFields = discoverDataFields(value)
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
const props: Record<string, unknown> = { ...node.props, items: value }
if (previousFields.join('|') !== nextFields.join('|')) delete props.dataColumns
return { ...node, bindings, props }
}),
}))
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
} catch (error) {
setStaticCollectionErrors((current) => ({
...current,
[selectedId]: error instanceof Error ? error.message : 'Geçersiz JSON.',
}))
}
}
const repeatedGridAncestor = [...selectedAncestors]
.reverse()
.find((node) => node.type === 'Grid' && node.bindings?.items?.sourceId)
const isActiveRepeatedSource =
repeatedGridAncestor?.bindings.items.sourceId === activeDataSource?.id
const activeBindingSample =
isTabularDataComponent(selectedNode?.type) && selectDataMode === 'static'
? selectedNode?.props.items
: isTabularDataComponent(selectedNode?.type) &&
tabularItemsBinding?.sourceId === activeDataSource?.id &&
activeDataSample !== undefined
? getDesignerValueByPath(activeDataSample, tabularItemsBinding?.path || '')
: activeDataSample !== undefined && isActiveRepeatedSource
? getDesignerValueByPath(
activeDataSample,
repeatedGridAncestor?.bindings.items.path || '',
)
: activeDataSample
const activeDataFields = useMemo(
() => (activeBindingSample === undefined ? [] : discoverDataFields(activeBindingSample)),
[activeBindingSample],
)
const gridColumnFields = useMemo(
() => activeDataFields.filter((field) => !['array', 'object'].includes(field.type)),
[activeDataFields],
)
const configuredGridColumns =
isTabularDataComponent(selectedNode?.type) && Array.isArray(selectedNode?.props.dataColumns)
? selectedNode.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: null
const selectedGridColumns = configuredGridColumns
? configuredGridColumns.filter((column) =>
gridColumnFields.some((field) => field.path === column),
)
: gridColumnFields.map((field) => field.path)
const allGridColumnsSelected =
gridColumnFields.length > 0 && selectedGridColumns.length === gridColumnFields.length
const toggleGridColumn = (columnPath: string) => {
const nextColumns = selectedGridColumns.includes(columnPath)
? selectedGridColumns.filter((column) => column !== columnPath)
: [...selectedGridColumns, columnPath]
updateSelectedProp('dataColumns', nextColumns)
}
const toggleAllGridColumns = () => {
updateSelectedProp(
'dataColumns',
allGridColumnsSelected ? [] : gridColumnFields.map((field) => field.path),
)
}
useEffect(() => {
if (
!selectedId ||
!isTabularDataComponent(selectedNode?.type) ||
configuredGridColumns !== null ||
!gridColumnFields.length
) {
return
}
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, dataColumns: gridColumnFields.map((field) => field.path) },
})),
}))
}, [commitDocument, configuredGridColumns, gridColumnFields, selectedId, selectedNode?.type])
useEffect(() => {
const itemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
if (
!selectedId ||
!itemsBinding?.sourceId ||
itemsBinding.sourceId !== activeDataSource?.id ||
activeDataSample === undefined
) {
return
}
const currentValue = getDesignerValueByPath(activeDataSample, itemsBinding.path)
if (Array.isArray(currentValue)) return
const collectionPath = Array.isArray(activeDataSample)
? ''
: discoverDataFields(activeDataSample).find((field) => field.type === 'array')?.path
if (collectionPath === undefined || collectionPath === itemsBinding.path) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
items: { sourceId: itemsBinding.sourceId, path: collectionPath },
},
})),
}))
}, [activeDataSample, activeDataSource?.id, commitDocument, selectedId, selectedNode])
const dataBindableProperties =
selectedNode &&
selectedDefinition &&
(isDataCategoryComponent(selectedDefinition) || Boolean(sqlScopeNode))
2026-08-05 20:51:43 +00:00
? selectedDefinition.properties.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
(isTabularDataComponent(selectedNode.type)
? property.name === 'items'
: isOptionDataComponent(selectedNode.type)
? property.name === getOptionDataProperty(selectedNode.type)
: sqlScopeLocked
? // A record field only needs the props that carry data.
getSqlRecordFieldOrder(property.name) >= 0
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) ||
property.type === 'array' ||
['number', 'boolean', 'string', 'select'].includes(property.type)),
2026-08-05 20:51:43 +00:00
)
: []
const orderedBindableProperties = sqlScopeLocked
? [...dataBindableProperties].sort(
(left, right) => getSqlRecordFieldOrder(left.name) - getSqlRecordFieldOrder(right.name),
)
: dataBindableProperties
2026-08-05 20:51:43 +00:00
const isCollectionProperty = (propertyName: string, propertyType: string) =>
propertyType === 'array' || ['items', 'data', 'dataSource', 'options'].includes(propertyName)
const getBindingChoice = (propertyName: string) => {
const binding = selectedNode?.bindings?.[propertyName]
if (!binding || binding.sourceId !== activeDataSource?.id) return ''
const path = binding.path.trim()
if (!path || path === '$item' || path === '0') return '__root__'
if (isActiveRepeatedSource && path.startsWith('$item.')) return path.slice(6)
if (Array.isArray(activeBindingSample) && path.startsWith('0.')) return path.slice(2)
return path
}
const getBindingPath = (fieldPath: string, collection: boolean) => {
if (fieldPath === '__root__') {
if (isActiveRepeatedSource) return '$item'
if (Array.isArray(activeBindingSample) && !collection) return '0'
return ''
}
if (isActiveRepeatedSource) return `$item.${fieldPath}`
if (Array.isArray(activeBindingSample)) return `0.${fieldPath}`
return fieldPath
}
const renderReusableDataSources = (compact = false) => {
const visibleSources = reusableDataSources
return (
<div className="space-y-2">
{endpointCatalogLoading && !visibleSources.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs text-slate-500 dark:border-slate-700">
Kayıtlı endpointler yükleniyor
</p>
) : visibleSources.length ? (
visibleSources.map((item) => (
<div
key={item.key}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white p-2.5 dark:border-slate-800 dark:bg-slate-900"
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-slate-700 dark:text-slate-200">
{item.source.name}
</div>
<div className="truncate font-mono text-[9px] text-sky-600">
{item.source.method} {item.source.url}
</div>
{!compact && (
<div className="flex items-center gap-1.5 text-[9px] text-slate-400">
<span>{item.origin}</span>
{item.attached && (
<span className="rounded bg-emerald-100 px-1 py-0.5 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
Ekli
</span>
)}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-sky-600 px-2.5 py-1.5 text-[10px] font-semibold text-white hover:bg-sky-700"
type="button"
onClick={() => addReusableDataSource(item)}
>
Kullan
</button>
{item.originType === 'component' && (
<>
<button
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-amber-400 hover:text-amber-600 dark:border-slate-700"
title="Custom Component endpointini düzenle"
type="button"
onClick={() => openCatalogSourceEditor(item)}
>
<FaEdit />
</button>
<button
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-red-400 hover:text-red-600 dark:border-slate-700"
title="Custom Component endpointini sil"
type="button"
onClick={() => void deleteCatalogSource(item)}
>
<FaTrash />
</button>
</>
)}
</div>
</div>
))
) : (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs leading-5 text-slate-500 dark:border-slate-700">
Bu componente eklenebilecek başka bir endpoint bulunmuyor.
2026-08-05 20:51:43 +00:00
</p>
)}
{endpointCatalogError && (
<p className="rounded bg-amber-50 p-2 text-[10px] leading-4 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
CRUD endpoint kataloğu okunamadı: {endpointCatalogError}
</p>
)}
</div>
)
}
const renderSelectDataModeSelector = () => {
if (!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Veri kaynağı türü
</div>
<div className="grid grid-cols-2 gap-1 rounded-md bg-slate-100 p-1 dark:bg-slate-950">
{(['static', 'endpoint'] as const).map((mode) => (
<button
key={mode}
className={`rounded px-2 py-1.5 text-[10px] font-semibold ${
selectDataMode === mode
? 'bg-white text-sky-700 shadow-sm dark:bg-slate-800 dark:text-sky-300'
: 'text-slate-500'
}`}
type="button"
onClick={() => setSelectDataMode(mode)}
>
{mode === 'static'
? isOptionDataComponent(selectedNode?.type)
? 'Statik seçenekler'
: 'Statik veri'
: 'Endpoint'}
</button>
))}
</div>
</div>
)
}
const renderSelectDataConfiguration = () => {
if (!isOptionDataComponent(selectedNode?.type)) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
{selectDataMode === 'static' ? (
<div className="space-y-2">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Statik seçenekler
</div>
{staticSelectOptions.map((option, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-1">
<input
aria-label={`Seçenek ${index + 1} başlığı`}
className="min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="Başlık"
value={option.label}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, label: event.target.value } : current,
),
)
}
/>
<input
aria-label={`Seçenek ${index + 1} değeri`}
className="min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="Değer"
value={String(option.value ?? '')}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, value: event.target.value } : current,
),
)
}
/>
<button
aria-label={`Seçenek ${index + 1} sil`}
className="rounded px-2 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
type="button"
onClick={() =>
updateStaticSelectOptions(
staticSelectOptions.filter((_, optionIndex) => optionIndex !== index),
)
}
>
<FaTrash />
</button>
</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={() =>
updateStaticSelectOptions([
...staticSelectOptions,
{
label: `Seçenek ${staticSelectOptions.length + 1}`,
value: String(staticSelectOptions.length + 1),
},
])
}
>
<FaPlus /> Statik seçenek ekle
</button>
</div>
) : (
<div className="space-y-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
2. Label ve Value eşlemesi
</div>
<p className="text-[10px] leading-4 text-slate-500">
Ekranda görünecek label sütununu ve kaydedilecek value sütununu seçin.
</p>
{activeDataSample !== undefined && selectOptionsBinding?.sourceId && (
<>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={selectOptionsBinding.path}
onChange={(event) =>
updateSelectedBindingDetails(optionDataProperty, {
path: event.target.value,
labelPath: '',
valuePath: '',
})
}
>
{!selectCollectionPaths.length && (
<option value="">Koleksiyon bulunamadı</option>
)}
{selectCollectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || 'Cevabın tamamı (array)'}
</option>
))}
</select>
</label>
{selectCollectionSample && (
<div className="grid grid-cols-2 gap-2">
{(
[
['labelPath', 'Label sütunu'],
['valuePath', 'Value sütunu'],
] as const
).map(([key, label]) => (
<label key={key} className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
{label}
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={selectOptionsBinding[key] || ''}
onChange={(event) =>
updateSelectedBindingDetails(optionDataProperty, {
[key]: event.target.value,
})
}
>
<option value="">
{selectColumnFields.length ? 'Sütun seçin' : 'Öğenin kendisi'}
</option>
{selectColumnFields.map((field) => (
<option key={field.path} value={field.path}>
{field.path}
</option>
))}
</select>
</label>
))}
</div>
)}
{/*
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
başka bir komponente ya da SqlDataSource kaydına yazabilir.
*/}
{selectCollectionSample && selectColumnFields.length > 0 && (
<div>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold text-slate-500">
Ek sütunlar
<code className="font-normal normal-case text-sky-600">
refs.{selectedNode?.ref}.getColumn(&apos;&apos;)
</code>
</span>
<div className="flex flex-wrap items-center gap-1">
{selectOptionColumns.map((column) => (
<span
key={column}
className="flex items-center gap-1 rounded-full bg-sky-100 px-2 py-[2px] text-[10px] text-sky-700 dark:bg-sky-950 dark:text-sky-200"
>
{column}
<button
className="text-sky-400 hover:text-red-500"
title="Sütunu kaldır"
type="button"
onClick={() =>
updateSelectedBindingDetails(optionDataProperty, {
columns: selectOptionColumns.filter((item) => item !== column),
})
}
>
<FaTimes size={8} />
</button>
</span>
))}
<select
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value=""
onChange={(event) => {
const next = event.target.value
if (!next || selectOptionColumns.includes(next)) return
updateSelectedBindingDetails(optionDataProperty, {
columns: [...selectOptionColumns, next],
})
}}
>
<option value="">+ sütun ekle</option>
{selectColumnFields
.filter((field) => !selectOptionColumns.includes(field.path))
.map((field) => (
<option key={field.path} value={field.path}>
{field.path}
</option>
))}
</select>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
Ekranda yalnızca Label görünür. Buraya eklediğin sütunlar seçenekle birlikte
taşınır; script içinde{' '}
<code>refs.{selectedNode?.ref || 'select1'}.getColumn(&apos;sutun&apos;)</code>{' '}
ile okunur.
</p>
</div>
)}
2026-08-05 20:51:43 +00:00
{selectCollectionSample &&
selectColumnFields.length > 0 &&
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
<p className="rounded bg-amber-50 p-2 text-[10px] text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{selectedNode?.type} bileşeninin doğru çalışması için label ve value
sütunlarını seçin.
</p>
)}
</>
)}
{activeDataSample === undefined && (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-slate-950">
Önce yukarıdan bir endpoint seçin ve alanların okunmasını bekleyin.
</p>
)}
</div>
)}
</div>
)
}
const renderTabularDataConfiguration = () => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type) || selectDataMode !== 'static')
return null
const currentItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
const draft = staticCollectionDrafts[selectedId] ?? JSON.stringify(currentItems, null, 2)
const error = staticCollectionErrors[selectedId]
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Statik satırlar (JSON)
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
Her obje bir satırı, obje alanları da seçilebilir sütunları oluşturur.
</p>
<textarea
className="min-h-40 w-full rounded-md border border-slate-300 bg-white p-2 font-mono text-[10px] leading-4 dark:border-slate-700 dark:bg-slate-900"
spellCheck={false}
value={draft}
onChange={(event) => updateStaticCollection(event.target.value)}
/>
{error && (
<p className="mt-2 rounded bg-red-50 p-2 text-[10px] text-red-600 dark:bg-red-950">
{error}
</p>
)}
{!error && (
<p className="mt-2 text-[10px] text-emerald-600">{currentItems.length} satır hazır.</p>
)}
</div>
)
}
const renderTabularColumnConfiguration = () => {
if (!isTabularDataComponent(selectedNode?.type)) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Gösterilecek sütunlar
</span>
<button
className="text-[9px] font-semibold text-sky-600"
type="button"
onClick={toggleAllGridColumns}
>
{allGridColumnsSelected ? 'Tümünü kaldır' : 'Tümünü seç'}
</button>
</div>
{gridColumnFields.length ? (
<div className="max-h-44 space-y-1 overflow-y-auto">
{gridColumnFields.map((field) => (
<label
key={field.path}
className="flex cursor-pointer items-center gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-slate-950"
>
<input
checked={selectedGridColumns.includes(field.path)}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => toggleGridColumn(field.path)}
/>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
</label>
))}
</div>
) : (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-slate-950">
Sütunları oluşturmak için yukarıdaki statik veri alanına en az bir obje ekleyin.
</p>
)}
</div>
)
}
/**
* SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete
* slots. Each slot only accepts a data source declared with the matching HTTP
* method, which is what enables the Save/Delete buttons at runtime.
*/
const renderSqlDataSourceConfiguration = () => {
if (!selectedNode || !selectedIsSqlDataSource) return null
const selectSourceId = String(selectedNode.props.selectEndpoint || '')
const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined
const collectionPaths = selectSample
? [
'',
...discoverDataFields(selectSample)
.filter((field) => field.type === 'array')
.map((field) => field.path),
]
: ['']
const record = sqlDataSourceRecords[selectedNode.id]
const recordFields = record === undefined ? [] : discoverDataFields(record)
const selectSource = document.dataSources.find((source) => source.id === selectSourceId)
// A GetById URL cannot be sampled at design time without a stand-in key.
const selectNeedsPreviewKey = Boolean(
selectSource && hasSqlDataSourceUrlParams(selectSource.url),
)
return (
<div className="space-y-3">
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
Endpointleri <strong>Data</strong> çalışma alanında tanımlayın, burada komut yuvalarına
bağlayın. İçine bıraktığınız komponentleri seçip Data sekmesinden{' '}
<em>SqlDataSource kaydı</em> kaynağını seçerek sütuna bağlayabilirsiniz.
</div>
{SQL_DATA_SOURCE_SLOTS.map((slot) => {
const options =
slot.method === 'GET'
? selectSlotDataSources
: document.dataSources.filter((source) => source.method === slot.method)
const currentValue = String(selectedNode.props[slot.property] || '')
return (
<label
key={slot.property}
className="block rounded-lg border border-slate-200 p-3 dark:border-slate-800"
>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-400">
<span>{slot.label}</span>
{currentValue && <span className="text-emerald-600">bağlı</span>}
</span>
<p className="mb-2 text-[10px] leading-4 text-slate-500">{slot.description}</p>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={currentValue}
onChange={(event) => {
const sourceId = event.target.value
updateSelectedProp(slot.property, sourceId)
const source = document.dataSources.find((item) => item.id === sourceId)
if (
slot.method === 'GET' &&
source &&
dataSourceSamples[sourceId] === undefined
) {
void testDataSource(source)
}
}}
>
<option value="">Tanımlı değil</option>
{options.map((source) => (
<option key={source.id} value={source.id}>
{source.name} · {source.url}
</option>
))}
</select>
{!options.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
{slot.method} metoduyla tanımlı endpoint yok. Data çalışma alanından ekleyin.
</p>
)}
</label>
)
})}
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Key field
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="id"
value={String(selectedNode.props.keyFieldName ?? '')}
onChange={(event) => updateSelectedProp('keyFieldName', event.target.value)}
/>
</label>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={String(selectedNode.props.collectionPath ?? '')}
onChange={(event) => updateSelectedProp('collectionPath', event.target.value)}
>
{collectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || 'Cevabın kendisi'}
</option>
))}
</select>
</label>
</div>
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Select key parametresi
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
Key değeri sayfa URLsinden okunur ve endpoint adresindeki <code>{'{id}'}</code> yerine
yazılır; yer tutucu yoksa query string olarak eklenir. Bir key bulunduğunda liste sonucu
da bu değere göre tek kayda indirgenir. <code>{'{id}'}</code> içeren bir GET
seçildiğinde query string otomatik kullanılır.
</p>
<select
className="mb-2 w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={getSqlDataSourceKeySource(selectedNode)}
onChange={(event) => updateSelectedProp('keySource', event.target.value)}
>
{SQL_DATA_SOURCE_KEY_SOURCES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<input
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={`Parametre adı (varsayılan: ${getSqlDataSourceKeyField(selectedNode)})`}
value={String(selectedNode.props.keyParamName ?? '')}
onChange={(event) => updateSelectedProp('keyParamName', event.target.value)}
/>
</div>
{selectNeedsPreviewKey && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Önizleme key değeri
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
Seçilen GET endpointi bir key bekliyor. Tasarım ekranında sütunları okuyabilmek için
örnek bir değer girin; çalışma zamanında bu değer yerine sayfa URLsindeki key
kullanılır.
</p>
<input
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="örn. 1b6be896-4f7f-defe-19ed-3a22e2edfc1d"
value={String(selectedNode.props.previewKeyValue ?? '')}
onChange={(event) => updateSelectedProp('previewKeyValue', event.target.value)}
/>
</div>
)}
<div className="space-y-1.5 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
{(
[
['autoLoad', 'Açılışta Select endpointini çağır', true],
['showToolbar', 'Yeni / Kaydet / Sil / Yenile butonlarını göster', true],
] as const
).map(([property, label, defaultChecked]) => (
<label key={property} className="flex cursor-pointer items-center gap-2 text-[10px]">
<input
checked={
defaultChecked
? selectedNode.props[property] !== false
: selectedNode.props[property] === true
}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) => updateSelectedProp(property, event.target.checked)}
/>
{label}
</label>
))}
</div>
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Kayıt sütunları
</div>
{recordFields.length ? (
<div className="max-h-40 space-y-1 overflow-y-auto">
{recordFields.map((field) => (
<div
key={field.path}
className="flex items-center justify-between gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-slate-950"
title={field.preview}
>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
</div>
))}
</div>
) : (
<p className="text-[10px] leading-4 text-slate-500">
Select endpointini seçip çalıştırdığınızda sütunlar burada listelenir.
</p>
)}
</div>
</div>
)
}
/**
* Banner shown for anything inside a SqlDataSource: it states which record the
* component is bound to and, when the container has no Select endpoint yet,
* why no column can be picked.
*/
const renderSqlScopeNotice = () => {
if (!sqlScopeNode) return null
const hasSelect = Boolean(getSqlDataSourceEndpointId(sqlScopeNode, 'selectEndpoint'))
const record = sqlDataSourceRecords[sqlScopeNode.id]
const hasColumns = record !== undefined && discoverDataFields(record).length > 0
if (!hasSelect) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-[10px] leading-4 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
Bu komponent bir <strong>SqlDataSource</strong> içinde. Sütunlara bağlanabilmesi için önce
SqlDataSourceu seçip Data sekmesinden <strong>Select (GET)</strong> endpointini
tanımlayın.
</div>
)
}
return (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
Bu komponent <strong>SqlDataSource</strong> kaydına bağlıdır; alanlar Select (GET)
cevabındaki sütunlardan gelir.
{!hasColumns && ' Sütunları görmek için Select endpointini bir kez çalıştırın.'}
</div>
)
}
/**
* Value binding for an option component inside a SqlDataSource. Its option list
* still comes from its own GET endpoint above; this only says which record
* column the selection reads from and writes back to.
*/
const renderSqlRecordValueBinding = () => {
if (!sqlScopeNode || !selectedNode) return null
if (!['AutoComplete', 'Radio.Group', 'Select'].includes(selectedNode.type)) return null
const record = sqlDataSourceRecords[sqlScopeNode.id]
const columns =
record === undefined
? []
: discoverDataFields(record).filter((field) => !['array', 'object'].includes(field.type))
const binding = selectedNode.bindings?.value
const current = binding?.sourceId === sqlScopeNode.id ? binding.path : ''
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Kayıt alanı (value)
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
Seçimin okunacağı ve kaydedileceği SqlDataSource sütunu.
</p>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={current}
onChange={(event) =>
updateSelectedBinding(
'value',
event.target.value ? sqlScopeNode.id : '',
event.target.value,
)
}
>
<option value="">Kayda bağlama</option>
{columns.map((field) => (
<option key={field.path} value={field.path}>
{field.path} · {field.type}
</option>
))}
</select>
{!columns.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
SqlDataSource Select endpointini tanımlayıp çalıştırın.
</p>
)}
</div>
)
}
2026-08-05 20:51:43 +00:00
const renderDataInspector = () => {
if (selectedNode?.kind === 'platform') {
return (
<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">
<strong className="block">Platform veri modeli</strong>
{selectedNode.type} verisini <code>listFormCode</code> üzerinden yönetir. Endpoint
bağlamanız gerekmez; liste ve kolon tanımı ilgili List Form kaydından gelir.
</div>
)
}
if (selectedIsSqlDataSource) return renderSqlDataSourceConfiguration()
const scopeNotice = renderSqlScopeNotice()
2026-08-05 20:51:43 +00:00
const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration()
if (selectedIsCollectionComponent && selectDataMode === 'static') {
2026-08-05 20:51:43 +00:00
return (
<div className="space-y-3">
{scopeNotice}
2026-08-05 20:51:43 +00:00
{selectModeSelector}
2026-08-06 13:17:59 +00:00
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
{renderSqlRecordValueBinding()}
2026-08-05 20:51:43 +00:00
</div>
)
}
if (!inspectorDataSources.length) {
2026-08-05 20:51:43 +00:00
return (
<div className="space-y-3">
{scopeNotice}
2026-08-05 20:51:43 +00:00
{selectModeSelector}
<div className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
Bu componentte seçilebilecek tanımlı bir GET endpointi bulunmuyor. Endpointleri ana{' '}
2026-08-05 20:51:43 +00:00
<strong>Data</strong> çalışma alanından tanımlayabilirsiniz.
</div>
</div>
)
}
return (
<div className="space-y-3">
{scopeNotice}
2026-08-05 20:51:43 +00:00
{selectModeSelector}
{/* Inside a SqlDataSource a record field has exactly one legal source, so
the picker is replaced by a read-only statement of that source. */}
2026-08-05 20:51:43 +00:00
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{sqlScopeLocked
? 'Veri kaynağı'
: isOptionDataComponent(selectedNode?.type)
? '1. Endpoint'
: 'Endpoint'}
2026-08-05 20:51:43 +00:00
</span>
{sqlScopeLocked ? (
<div className="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
{sqlScopeSource?.name}
</div>
) : (
<select
className="w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs dark:border-slate-700 dark:bg-slate-900"
value={activeDataSource?.id || ''}
onChange={(event) => {
const sourceId = event.target.value
setDataPanelSourceId(sourceId)
if (
(isOptionDataComponent(selectedNode?.type) ||
isTabularDataComponent(selectedNode?.type)) &&
selectDataMode === 'endpoint'
) {
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const props = { ...node.props }
delete props.dataColumns
return {
...node,
props,
bindings: {
...node.bindings,
items: { sourceId, path: '' },
},
}
}),
}))
} else {
updateSelectedBinding(optionDataProperty, sourceId, '')
}
2026-08-05 20:51:43 +00:00
}
const source = document.dataSources.find((item) => item.id === sourceId)
if (source && dataSourceSamples[sourceId] === undefined) {
void testDataSource(source)
}
}}
>
{/* Without this a browser paints the first option while the
value is still empty, which reads as a made choice. */}
{!activeDataSource && <option value="">Endpoint seçin</option>}
{inspectorDataSources.map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</select>
)}
2026-08-05 20:51:43 +00:00
</label>
</div>
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) &&
activeDataSample !== undefined &&
tabularItemsBinding?.sourceId && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={tabularItemsBinding.path}
onChange={(event) =>
updateSelectedBindingDetails('items', { path: event.target.value })
}
>
{!tabularCollectionPaths.length && (
<option value="">Koleksiyon bulunamadı</option>
)}
{tabularCollectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || 'Cevabın tamamı (array)'}
</option>
))}
</select>
</label>
</div>
)}
{!isOptionDataComponent(selectedNode?.type) && activeBindingSample !== undefined && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Kullanılabilir alanlar
</span>
<div className="flex items-center gap-2">
{isTabularDataComponent(selectedNode?.type) && gridColumnFields.length > 0 && (
<label className="flex cursor-pointer items-center gap-1 text-[9px] font-semibold text-sky-600">
<input
checked={allGridColumnsSelected}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={toggleAllGridColumns}
/>
{allGridColumnsSelected ? 'Tümünü kaldır' : 'Tümünü seç'}
</label>
)}
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[9px] text-emerald-700">
{isTabularDataComponent(selectedNode?.type)
? `${selectedGridColumns.length}/${gridColumnFields.length}`
: `${activeDataFields.length} alan`}
</span>
</div>
</div>
{Array.isArray(activeBindingSample) && (
<div className="mb-2 rounded bg-indigo-50 px-2 py-1.5 text-[10px] text-indigo-700 dark:bg-indigo-950 dark:text-indigo-200">
{isActiveRepeatedSource ? 'Grid satırları' : 'Koleksiyon'} ·{' '}
{activeBindingSample.length} kayıt
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto">
{activeDataFields.length ? (
activeDataFields.map((field) => (
<label
key={field.path}
className="grid cursor-pointer grid-cols-[auto_1fr_auto] gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-slate-950"
title={field.preview}
>
{isTabularDataComponent(selectedNode?.type) &&
!['array', 'object'].includes(field.type) ? (
<input
checked={selectedGridColumns.includes(field.path)}
className="mt-0.5 h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => toggleGridColumn(field.path)}
/>
) : (
<span />
)}
<code className="truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
<span className="col-span-2 col-start-2 truncate text-[9px] text-slate-400">
{field.preview}
</span>
</label>
))
) : (
<p className="text-[10px] text-slate-400">
Cevapta kolon bulunamadı; kök değer yine bağlanabilir.
</p>
)}
</div>
</div>
)}
{!isOptionDataComponent(selectedNode?.type) &&
!isTabularDataComponent(selectedNode?.type) && (
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Property bağlantıları
</div>
{!orderedBindableProperties.length ? (
2026-08-05 20:51:43 +00:00
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
Bu komponent endpoint verisi alabilecek bir property tanımlamıyor.
</p>
) : (
orderedBindableProperties.map((property) => {
2026-08-05 20:51:43 +00:00
const choice = getBindingChoice(property.name)
const collection = isCollectionProperty(property.name, property.type)
const selectableFields = collection
? activeDataFields.filter((field) => field.type === 'array')
: activeDataFields
const currentBinding = selectedNode?.bindings?.[property.name]
const currentBindingSource = document.dataSources.find(
(source) => source.id === currentBinding?.sourceId,
)
// A binding saved before the SqlDataSource lock existed can still
// point at an unrelated endpoint; the dropdown cannot show it, so
// it is called out explicitly instead of failing silently.
const foreignBinding =
sqlScopeLocked &&
Boolean(currentBinding?.sourceId) &&
currentBinding?.sourceId !== sqlScopeSource?.id
2026-08-05 20:51:43 +00:00
const knownChoice =
choice === '' ||
choice === '__root__' ||
selectableFields.some((field) => field.path === choice)
return (
<label
key={property.name}
className="mb-2 block rounded-lg 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>{property.name}</span>
<span className="text-[9px] font-normal text-slate-400">
{property.tsType || property.type}
</span>
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={choice}
onChange={(event) => {
const nextChoice = event.target.value
if (!nextChoice) {
updateSelectedBinding(property.name, '')
return
}
if (!activeDataSource) return
updateSelectedBinding(
property.name,
activeDataSource.id,
getBindingPath(nextChoice, collection),
)
}}
>
<option value="">Statik değer (bağlantı yok)</option>
{activeBindingSample !== undefined &&
(collection ||
!Array.isArray(activeBindingSample) ||
isActiveRepeatedSource) && (
<option value="__root__">
{isActiveRepeatedSource
? 'Geçerli Grid satırı'
: collection
? 'Koleksiyonun tamamı'
: 'Cevabın tamamı'}
</option>
)}
{!knownChoice && <option value={choice}>{choice} (mevcut path)</option>}
{selectableFields.map((field) => (
<option key={field.path} value={field.path}>
{field.path} · {field.type}
</option>
))}
</select>
{currentBinding?.sourceId && !foreignBinding && (
2026-08-05 20:51:43 +00:00
<div className="mt-1.5 truncate font-mono text-[9px] text-emerald-600">
{currentBindingSource?.name || currentBinding.sourceId}:{' '}
{currentBinding.path || '(root)'}
</div>
)}
{foreignBinding && (
<div className="mt-1.5 rounded bg-amber-50 p-1.5 text-[9px] leading-3 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
<span className="block truncate font-mono">
{currentBindingSource?.name || currentBinding?.sourceId}:{' '}
{currentBinding?.path || '(root)'}
</span>
<button
className="mt-1 font-semibold underline"
type="button"
onClick={() => updateSelectedBinding(property.name, '')}
>
SqlDataSource dışı bağlantı temizle
</button>
</div>
)}
2026-08-05 20:51:43 +00:00
</label>
)
})
)}
</div>
)}
{renderSqlRecordValueBinding()}
2026-08-05 20:51:43 +00:00
</div>
)
}
const renderTree = (nodes: DesignerNode[], depth = 0): React.ReactNode =>
nodes.map((node) => (
<React.Fragment key={node.id}>
<button
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={() => selectDesignerNode(node.id)}
2026-08-05 20:51:43 +00:00
>
<FaLayerGroup className="shrink-0" />
<span className="truncate">{node.type}</span>
<span className="ml-auto text-[9px] text-slate-400">{node.id.slice(-4)}</span>
</button>
{renderTree(node.children, depth + 1)}
</React.Fragment>
))
const pageHelmet = (
<PageTitle title={pageTitle} />
)
2026-08-05 20:51:43 +00:00
if (!component) {
return (
<div className="flex min-h-[70vh] items-center justify-center text-sm text-slate-500">
{pageHelmet}
2026-08-05 20:51:43 +00:00
{componentLoadError || 'Bileşen yükleniyor…'}
</div>
)
}
const canvas = (
<VisualCanvas
interactive
nodes={document.nodes}
selectedId={selectedId}
dataValues={previewDataValues}
2026-08-05 20:51:43 +00:00
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
2026-08-05 20:51:43 +00:00
onDropComponent={addComponent}
onDropComponentBeside={addComponentBeside}
onMoveIntoContainer={moveNodeIntoContainerNode}
2026-08-05 20:51:43 +00:00
onMove={moveNode}
2026-08-06 13:17:59 +00:00
onReorder={reorderNode}
2026-08-05 20:51:43 +00:00
onDuplicate={duplicateNode}
onDelete={deleteNode}
/>
)
const javascriptEventTargets: Array<{ node: DesignerNode; eventNames: string[] }> = []
walkDesignerNodes(document.nodes, (node) => {
const definition = catalogByName.get(node.type)
const primaryEvents = DESIGNER_PRIMARY_EVENTS[node.type]
const savedEventNames = Object.entries(node.events || {})
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName)
const definitionEventNames = (definition?.properties || [])
.filter((property) => property.category === 'events' || property.type === 'function')
.map((property) => property.name)
const eventNames = Array.from(
new Set([...(primaryEvents || definitionEventNames), ...savedEventNames]),
)
if (eventNames.length) javascriptEventTargets.push({ node, eventNames })
})
const activeJavaScriptTarget =
javascriptEventTargets.find((target) => target.node.id === selectedId) ||
javascriptEventTargets[0]
// A Script API example is appended to the editor the user last worked in,
// falling back to the first event of the selected component.
const activeEventName = activeJavaScriptTarget?.eventNames.includes(focusedEventName)
? focusedEventName
: activeJavaScriptTarget?.eventNames[0] || ''
const appendScriptToEvent = (snippet: string) => {
if (!activeJavaScriptTarget || !activeEventName) return
const current = activeJavaScriptTarget.node.events[activeEventName] || ''
updateNodeEvent(
activeJavaScriptTarget.node.id,
activeEventName,
current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet,
)
}
/**
* Builder context of the event being edited: the SqlDataSource that owns the
* record (the node itself, or the nearest one above it) plus the columns and
* ref names its rules can address.
*/
const scriptBuilderNode = scriptBuilderTarget
? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId)
: null
const scriptBuilderSqlNode = scriptBuilderNode
? isSqlDataSourceNode(scriptBuilderNode.type)
? scriptBuilderNode
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
.reverse()
.find((node) => isSqlDataSourceNode(node.type))
: undefined
const scriptBuilderRecordFields = scriptBuilderSqlNode
? Object.keys(
(sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {},
)
: []
2026-08-05 20:51:43 +00:00
const endpointResultText = endpointResultModal
? typeof endpointResultModal.result === 'string'
? endpointResultModal.result
: (JSON.stringify(endpointResultModal.result, null, 2) ?? String(endpointResultModal.result))
: ''
const endpointResultCount = endpointResultModal
? Array.isArray(endpointResultModal.result)
? `${endpointResultModal.result.length} kayıt`
: endpointResultModal.result && typeof endpointResultModal.result === 'object'
? `${Object.keys(endpointResultModal.result).length} alan`
: typeof endpointResultModal.result
: ''
return (
<div className="flex h-[calc(100vh-72px)] min-h-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-100 shadow-sm dark:border-slate-800 dark:bg-slate-950">
{pageHelmet}
2026-08-05 20:51:43 +00:00
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 items-center gap-3">
<Link
className="rounded-md p-2 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
title="Bileşenlere dön"
to={ROUTES_ENUM.protected.saas.developerKit.components}
>
<FaArrowLeft />
</Link>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-indigo-600 text-white">
<FaMousePointer />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-sm font-semibold text-slate-800 dark:text-white">
{name}
</h1>
{isDirty ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">
Unsaved
</span>
) : (
<FaCheckCircle className="text-emerald-500" />
)}
</div>
<p className="truncate text-[11px] text-slate-500">
Visual Component Designer · {component.routePath}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<Button
disabled={!undoStack.current.length}
icon={<FaUndo />}
size="xs"
title="Geri al (Ctrl+Z)"
variant="plain"
onClick={undo}
/>
<Button
disabled={!redoStack.current.length}
icon={<FaRedo />}
size="xs"
title="Yinele"
variant="plain"
onClick={redo}
/>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button
icon={<FaSync />}
size="sm"
title="Canvas içeriğini sıfırla"
variant="plain"
onClick={() => {
commitDocument(() => createEmptyDesignerDocument('visual'))
setSelectedId(null)
}}
>
2026-08-06 13:17:59 +00:00
{translate('::Reset')}
2026-08-05 20:51:43 +00:00
</Button>
<Button
icon={<FaEye />}
size="sm"
title="Sayfayı yeni sekmede aç"
variant="plain"
onClick={() => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank')
2026-08-05 20:51:43 +00:00
}}
>
2026-08-06 13:17:59 +00:00
{translate('::OpenUrl')}
2026-08-05 20:51:43 +00:00
</Button>
<Button
loading={isSaving}
icon={<FaRegSave />}
size="sm"
variant="solid"
onClick={() => void handleSave()}
>
2026-08-06 13:17:59 +00:00
{translate('::Save')}
2026-08-05 20:51:43 +00:00
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{workspaceTab === 'design' && (
<aside className="flex w-80 shrink-0 flex-col border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
<FaPlus /> Toolbox
</div>
<label className="relative block">
<FaSearch className="absolute left-2.5 top-2.5 text-xs text-slate-400" />
<input
className="w-full rounded-md border border-slate-200 bg-slate-50 py-2 pl-8 pr-2 text-xs outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-950"
placeholder="Bileşen ara…"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
</div>
<div className="flex-1 overflow-y-auto p-2">
{groupedCatalog.map(([group, definitions]) => (
<section key={group} className="mb-5">
<button
aria-expanded={expandedToolboxGroups[group] || Boolean(search.trim())}
className="mb-2 flex w-full items-center gap-2 rounded bg-slate-100 px-2 py-1.5 text-left text-[10px] font-bold uppercase tracking-widest text-slate-600 dark:bg-slate-800 dark:text-slate-300"
type="button"
onClick={() =>
setExpandedToolboxGroups((current) => ({
...current,
[group]: !current[group],
}))
}
>
{expandedToolboxGroups[group] || search.trim() ? (
<FaChevronDown className="shrink-0" />
) : (
<FaChevronRight className="shrink-0" />
)}
<span>{TOOLBOX_GROUP_LABELS[group]}</span>
<span className="ml-auto rounded bg-white/70 px-1.5 py-0.5 text-[9px] font-medium tracking-normal dark:bg-slate-900/60">
{definitions.length}
</span>
</button>
{(expandedToolboxGroups[group] || Boolean(search.trim())) && (
<div className="grid grid-cols-3 gap-1.5">
{definitions.map((definition) => (
<button
key={`${definition.toolboxGroup}-${definition.name}`}
draggable
className={`group min-w-0 rounded-lg border bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950 ${
activeDrag?.source === 'library' && activeDrag.name === definition.name
? 'border-sky-500 ring-2 ring-sky-300 dark:border-sky-500'
: 'border-slate-200 dark:border-slate-700'
}`}
title={
definition.acceptsChildren
? `${definition.description}\n\nİçine başka komponent alabilir.`
: definition.description
}
2026-08-05 20:51:43 +00:00
type="button"
onClick={() => addComponent(definition.name, null)}
onDragStart={(event) => {
const payload = JSON.stringify({
source: 'library',
name: definition.name,
})
event.dataTransfer.effectAllowed = 'copy'
2026-08-06 13:17:59 +00:00
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
2026-08-05 20:51:43 +00:00
event.dataTransfer.setData('text/plain', payload)
// Canvas bırakma hedeflerini vurgulayabilmek için
// sürüklemenin başladığını duyuruyoruz — senkron bir
// state güncellemesi sürüklemeyi iptal ettirebildiği
// için bir tık sonraya bırakarak.
beginDesignerDragSoon({ source: 'library', name: definition.name })
2026-08-05 20:51:43 +00:00
}}
onDragEnd={() => endDesignerDrag()}
2026-08-05 20:51:43 +00:00
>
<div className="mb-1 flex h-6 w-6 items-center justify-center rounded bg-slate-100 text-[10px] font-bold text-slate-500 group-hover:bg-sky-100 group-hover:text-sky-600 dark:bg-slate-800">
{definition.name.slice(0, 2).toUpperCase()}
</div>
<div className="truncate text-[11px] font-medium text-slate-700 dark:text-slate-200">
{definition.name}
</div>
</button>
))}
</div>
)}
</section>
))}
</div>
</aside>
)}
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 dark:border-slate-800 dark:bg-slate-900">
<div className="flex h-full items-center gap-1">
{(
[
['design', 'Design', <FaMousePointer key="design" />],
['data', 'Data', <FaDatabase key="data" />],
['code', 'React', <FaCode key="code" />],
['event', 'Event', <FaBolt key="js" />],
] as [WorkspaceTab, string, React.ReactNode][]
).map(([tab, label, icon]) => (
<button
key={tab}
className={`flex h-full items-center gap-1.5 border-b-2 px-3 text-xs font-medium ${workspaceTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-white'}`}
type="button"
onClick={() => setWorkspaceTab(tab)}
>
{icon}
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
{workspaceTab === 'design' && (
<div className="flex items-center rounded-md border border-slate-200 p-0.5 dark:border-slate-700">
{(
[
['responsive', <FaDesktop key="r" />, 'Responsive'],
['desktop', <FaDesktop key="d" />, 'Desktop'],
['tablet', <FaTabletAlt key="t" />, 'Tablet'],
['mobile', <FaMobileAlt key="m" />, 'Mobile'],
] as [DesignerDocument['canvas']['width'], React.ReactNode, string][]
).map(([width, icon, label]) => (
<button
key={width}
className={`rounded p-1.5 ${document.canvas.width === width ? 'bg-sky-100 text-sky-600 dark:bg-sky-950' : 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'}`}
title={label}
type="button"
onClick={() =>
commitDocument((current) => ({
...current,
canvas: { ...current.canvas, width },
}))
}
>
{icon}
</button>
))}
</div>
)}
{/* Reachable from every tab: the examples are just as useful while
laying out the page as they are inside the event editor. */}
<button
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium ${
scriptApiOpen
? 'border-sky-500 bg-sky-50 text-sky-600 dark:bg-sky-950'
: 'border-slate-200 text-slate-500 hover:text-slate-800 dark:border-slate-700 dark:hover:text-white'
}`}
title="Script API örnekleri (Ctrl+Shift+K)"
type="button"
onClick={() => setScriptApiOpen((current) => !current)}
>
<FaBolt />
Script API
<span className="rounded bg-sky-100 px-1 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-900 dark:text-sky-300">
{DESIGNER_SCRIPT_RECIPES.length}
</span>
</button>
</div>
2026-08-05 20:51:43 +00:00
</div>
<div className="min-h-0 flex-1 overflow-auto bg-slate-200 p-5 dark:bg-slate-950">
{workspaceTab === 'design' ? (
<div
className="mx-auto min-h-full overflow-hidden rounded-lg bg-white text-slate-900 shadow-lg transition-[width] duration-200 dark:bg-slate-900 dark:text-slate-100"
style={{ width: CANVAS_WIDTHS[document.canvas.width], maxWidth: '100%' }}
>
{canvas}
</div>
) : workspaceTab === 'data' ? (
<div className="w-full space-y-4">
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="min-w-0">
<h2 className="flex items-center gap-2 font-semibold text-slate-800 dark:text-white">
<FaDatabase className="text-sky-600" /> Data sources
</h2>
<p className="mt-1 text-xs text-slate-500">
/api/ endpointleri mevcut kullanıcının token, tenant ve rol yetkileriyle
çağrılır.
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button icon={<FaPlus />} size="sm" variant="solid" onClick={addDataSource}>
Yeni endpoint
</Button>
</div>
</div>
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
GET kaynakları sayfa ılışında okunur; POST/PUT/DELETE kaynakları yalnızca bir{' '}
<code>SqlDataSource</code> komut yuvasına bağlandığında çalışır ve tasarım
ekranından tetiklenmez.
</div>
2026-08-05 20:51:43 +00:00
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
Endpoint ayarları component ile birlikte kaydedilir. Tekrarlı kartlar için Grid
<code className="mx-1">items</code> alanını endpointe boş path ile bağlayın; Grid
içine Card/Input bırakıp alt property pathlerinde <code>$item.name</code>{' '}
kullanın. DataGridView ise endpoint yerine kendi <code>listFormCode</code> ayarını
kullanır.
</div>
{(reusableDataSources.length > 0 || endpointCatalogError) && (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3">
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
Kullanılabilir kaynaklar
</h3>
<p className="mt-1 text-[10px] text-slate-500">
Bu componentte kaydedilen, diğer componentlerden gelen veya CRUD Endpoint
Managerda tanımlı kaynaklar.
</p>
</div>
{renderReusableDataSources()}
</div>
)}
</div>
) : workspaceTab === 'code' ? (
<div className="h-full min-h-[560px] overflow-hidden rounded-lg border border-slate-700 bg-[#1e1e1e]">
<div className="flex items-center justify-between border-b border-slate-700 bg-slate-900 px-3 py-2 text-xs text-slate-300">
<span>
{document.sourceMode === 'visual'
? 'Generated React · read-only'
: 'Advanced React source'}
</span>
{document.sourceMode === 'visual' && (
<span className="text-emerald-400">Schema synchronized</span>
)}
</div>
<Editor
height="calc(100% - 36px)"
language="typescript"
options={{
automaticLayout: true,
fontSize: 13,
minimap: { enabled: false },
readOnly: document.sourceMode === 'visual',
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.sourceMode === 'visual' ? generatedCode : manualCode}
onChange={(value) => document.sourceMode === 'code' && setManualCode(value || '')}
/>
</div>
) : (
<div className="flex min-h-[620px] w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="w-72 shrink-0 border-r border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-3">
<h2 className="flex items-center gap-2 font-semibold text-slate-800 dark:text-white">
<FaBolt className="text-amber-500" /> Event workspace
</h2>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
Sayfadaki güncel component eventlerini tek yerden yönetin.
</p>
</div>
<div className="space-y-1">
{javascriptEventTargets.length ? (
javascriptEventTargets.map((target) => (
<button
key={target.node.id}
className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left transition ${activeJavaScriptTarget?.node.id === target.node.id ? 'border-amber-400 bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200' : 'border-transparent text-slate-600 hover:border-slate-200 hover:bg-white dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-900'}`}
type="button"
onClick={() => setSelectedId(target.node.id)}
>
<FaBolt className="shrink-0 text-[10px]" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold">
{target.node.type}
</span>
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-[9px] dark:bg-slate-800">
{target.eventNames.length}
</span>
</button>
))
) : (
<p className="rounded-md border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
Event destekleyen bir component eklediğinizde burada görünecek.
</p>
)}
</div>
{/* Every component on the page is addressable from any script,
which is what makes cross component behaviour possible. */}
<div className="mt-4 border-t border-slate-200 pt-3 dark:border-slate-800">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Referanslar
</h3>
<p className="mb-2 mt-1 text-[10px] leading-4 text-slate-500">
Script içinden <code className="text-sky-600">refs.ad</code> ile erişin:{' '}
<code>setValue</code>, <code>setVisible</code>, <code>setEnabled</code>,{' '}
<code>setReadOnly</code>, <code>setText</code>, <code>setProps</code>,{' '}
<code>getValue</code>.
</p>
<div className="max-h-64 space-y-1 overflow-y-auto">
{[...documentRefs.entries()].map(([ref, node]) => (
<button
key={ref}
className="flex w-full items-center gap-2 rounded border border-transparent px-2 py-1 text-left hover:border-slate-200 hover:bg-white dark:hover:border-slate-700 dark:hover:bg-slate-900"
title="refs adını panoya kopyala"
type="button"
onClick={() => copyRefName(ref)}
>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-600">
{ref}
</code>
<span className="shrink-0 text-[9px] text-slate-400">{node.type}</span>
</button>
))}
{!documentRefs.size && (
<p className="text-[10px] text-slate-400">Henüz komponent eklenmedi.</p>
)}
</div>
</div>
2026-08-05 20:51:43 +00:00
</div>
<div className="min-w-0 flex-1 overflow-y-auto p-5">
<div className="mb-5 rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
Component lifecycle
</h3>
<p className="mt-0.5 text-[10px] text-slate-500">
Sayfa ilk yüklendiğinde bir kez çalışır.
</p>
</div>
<code className="text-[10px] text-sky-600">onMount()</code>
</div>
<Editor
height="150px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.lifecycle.onMount}
onChange={(value) =>
commitDocument((current) => ({
...current,
lifecycle: { onMount: value || '' },
}))
}
/>
</div>
{activeJavaScriptTarget ? (
<div>
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">
{activeJavaScriptTarget.node.type}
</h3>
<code className="text-[10px] text-slate-400">
{activeJavaScriptTarget.node.id}
</code>
</div>
<span className="rounded bg-amber-100 px-2 py-1 text-[10px] font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{activeJavaScriptTarget.eventNames.length} event
</span>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{activeJavaScriptTarget.eventNames.map((eventName) => (
<label
key={eventName}
className={`overflow-hidden rounded-lg border dark:border-slate-800 ${eventName === activeEventName ? 'border-amber-400' : 'border-slate-200'}`}
onFocus={() => setFocusedEventName(eventName)}
2026-08-05 20:51:43 +00:00
>
<span className="flex items-center justify-between bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<span
className="min-w-0 truncate"
title={getSqlDataSourceEvent(eventName)?.description}
>
<code>{eventName}(event)</code>
{getSqlDataSourceEvent(eventName) &&
isSqlDataSourceNode(activeJavaScriptTarget.node.type) && (
<span className="ml-2 font-normal text-slate-400">
{getSqlDataSourceEvent(eventName)?.label}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2">
2026-08-05 20:51:43 +00:00
<span className="text-[9px] font-normal text-slate-400">
{activeJavaScriptTarget.node.type}
</span>
<button
className="rounded bg-indigo-100 px-2 py-1 text-[9px] font-semibold text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950 dark:text-indigo-300"
title="Kural tabanlı script builder"
type="button"
onClick={() =>
setScriptBuilderTarget({
nodeId: activeJavaScriptTarget.node.id,
eventName,
})
}
>
Builder
</button>
2026-08-05 20:51:43 +00:00
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-sky-950 dark:text-sky-300"
disabled={Boolean(
activeJavaScriptTarget.node.events[eventName]?.trim(),
)}
type="button"
onClick={() =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
resolveRecipeCode(
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
),
2026-08-05 20:51:43 +00:00
),
)
}
>
Snippet ekle
</button>
</span>
</span>
<Editor
height="190px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={activeJavaScriptTarget.node.events[eventName] || ''}
onChange={(value) =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
value || '',
)
}
/>
</label>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-700">
Düzenlenecek component eventi bulunmuyor.
</div>
)}
</div>
</div>
)}
</div>
</main>
{workspaceTab === 'design' && (
2026-08-06 13:17:59 +00:00
<aside
data-property-panel
className="flex w-72 shrink-0 flex-col border-l border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
2026-08-05 20:51:43 +00:00
<div className="flex h-12 shrink-0 border-b border-slate-200 dark:border-slate-800">
{(
[
['properties', <FaSlidersH key="p" />, 'Properties'],
['data', <FaDatabase key="d" />, 'Data'],
['tree', <FaLayerGroup key="t" />, 'Tree'],
] as [InspectorTab, React.ReactNode, string][]
).map(([tab, icon, label]) => (
<button
key={tab}
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
}
2026-08-05 20:51:43 +00:00
type="button"
onClick={() => {
if (tab !== 'data' || selectedIsDataComponent) setInspectorTab(tab)
}}
2026-08-05 20:51:43 +00:00
>
{icon}
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3">
{inspectorTab === 'tree' ? (
<div>
{document.nodes.length ? (
renderTree(document.nodes)
) : (
<p className="p-3 text-xs text-slate-400">Henüz bileşen yok.</p>
)}
</div>
) : selectedNode ? (
<>
<div className="mb-4 rounded-lg bg-slate-50 p-3 dark:bg-slate-950">
<div className="text-[10px] uppercase tracking-wider text-slate-400">
Selected component
</div>
<div className="mt-1 flex items-center justify-between">
<strong className="text-sm text-slate-800 dark:text-white">
{selectedNode.type}
</strong>
<button
className="rounded p-1.5 text-red-500 hover:bg-red-50"
title="Bileşeni sil"
type="button"
onClick={() => deleteNode(selectedNode.id)}
>
<FaTrash />
</button>
</div>
<code className="text-[10px] text-slate-400">{selectedNode.id}</code>
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-slate-800">
<span className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Ref
<button
className="font-mono text-[10px] font-normal normal-case tracking-normal text-sky-600 hover:underline"
title="Panoya kopyala"
type="button"
onClick={() => copyRefName(selectedNode.ref || '')}
>
refs.{selectedNode.ref}
</button>
</span>
<input
className="mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
spellCheck={false}
value={refDraft}
onBlur={commitRefRename}
onChange={(event) => setRefDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
{refError ? (
<p className="mt-1 text-[10px] leading-4 text-red-500">{refError}</p>
) : (
<p className="mt-1 text-[10px] leading-4 text-slate-400">
Event scriptlerinden bu isimle erişilir. Yeniden adlandırdığınızda mevcut
scriptler otomatik güncellenir.
</p>
)}
</div>
2026-08-05 20:51:43 +00:00
{selectedDefinition?.interfaceName && (
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-slate-800">
<div className="text-[10px] font-semibold text-sky-600">
{selectedDefinition.interfaceName}
</div>
<div
className="truncate text-[9px] text-slate-400"
title={selectedDefinition.sourceFile}
>
{selectedDefinition.sourceFile}
</div>
</div>
)}
</div>
{inspectorTab === 'properties' ? (
<>
{selectedProperties
2026-08-05 20:51:43 +00:00
.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
!(
isOptionDataComponent(selectedNode.type) &&
property.name === getOptionDataProperty(selectedNode.type)
) &&
!(
isTabularDataComponent(selectedNode.type) &&
['items', 'data', 'columns', 'dataColumns'].includes(property.name)
) &&
// Command slots and the key/collection settings are
// owned by the SqlDataSource panel in the Data tab.
!(
selectedIsSqlDataSource &&
[
...SQL_DATA_SOURCE_SLOTS.map((slot) => slot.property),
'keyFieldName',
'collectionPath',
'keySource',
'keyParamName',
'previewKeyValue',
'autoLoad',
'showToolbar',
'showNavigation',
'filterByUrlKey',
].includes(property.name)
2026-08-05 20:51:43 +00:00
),
)
.map((property) => {
2026-08-06 13:17:59 +00:00
const propertyValue = selectedNode.props[property.name] ?? property.value
const helperValue = getPropertyExampleValue(property, property.value)
const snippet = getPropertySnippet(property, helperValue)
2026-08-05 20:51:43 +00:00
return (
<div
key={property.name}
className="group/property mb-4 rounded-md border border-slate-200 p-2 dark:border-slate-800"
2026-08-05 20:51:43 +00:00
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>
{property.name}
{property.required ? (
<b className="ml-0.5 text-red-500">*</b>
) : null}
</span>
<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-20 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>
2026-08-05 20:51:43 +00:00
</span>
</span>
{property.description && (
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{property.description}
</p>
)}
<PropertyEditor
componentType={selectedNode?.type}
2026-08-05 20:51:43 +00:00
name={property.name}
type={property.type}
value={propertyValue}
options={property.options}
onChange={(value) => updateSelectedProp(property.name, value)}
/>
</div>
)
})}
</>
) : (
renderDataInspector()
)}
2026-08-05 20:51:43 +00:00
</>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-slate-300 p-5 text-center dark:border-slate-700">
<FaMousePointer className="mx-auto mb-2 text-slate-400" />
<p className="text-xs text-slate-500">
Propertyleri görmek için canvastan bir bileşen seçin.
</p>
</div>
</div>
)}
</div>
<div className="border-t border-slate-200 bg-slate-50 px-3 py-2 text-[10px] text-slate-500 dark:border-slate-800 dark:bg-slate-950">
<span className="font-semibold">{document.nodes.length}</span> root component ·{' '}
<span className="font-semibold">Ctrl+S</span> save ·{' '}
<span className="font-semibold">Del</span> delete
</div>
</aside>
)}
</div>
{/* Everything an event script can do, as copy/paste examples filled in
with this page's own ref names and endpoints. It is a drawer rather
than a dialog on purpose: no backdrop, so the editor underneath stays
usable while an example is being read or pasted. */}
{scriptApiOpen && (
<aside className="fixed inset-y-0 right-0 z-[90] flex w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-white">
<FaBolt className="text-amber-500" /> Script API yapabilecekleriniz
<span className="shrink-0 rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300">
{DESIGNER_SCRIPT_RECIPES.length} örnek
</span>
</h2>
<code className="mt-1 block text-[10px] text-slate-400">
event · component · props · refs · api · notify
</code>
</div>
<button
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
title="Kapat (Esc)"
type="button"
onClick={() => setScriptApiOpen(false)}
>
×
</button>
</div>
<div className="shrink-0 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
placeholder="Örnek ara: api, gizle, kaydet, ref…"
value={scriptApiSearch}
onChange={(event) => setScriptApiSearch(event.target.value)}
/>
{activeEventName ? (
<p className="mt-2 text-[10px] text-slate-500">
Ekle seçilen örneği{' '}
<code className="text-amber-600 dark:text-amber-400">{activeEventName}</code>{' '}
scriptinin sonuna yazar.
</p>
) : (
<p className="mt-2 text-[10px] text-slate-400">
Event sekmesinde bir script alanına tıklarsanız örnekleri doğrudan oraya
ekleyebilirsiniz.
</p>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
{DESIGNER_SCRIPT_RECIPE_GROUPS.map((group) => {
const recipes = filteredScriptRecipes.filter((recipe) => recipe.group === group)
if (!recipes.length) return null
return (
<div key={group} className="mb-4 last:mb-0">
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{group}
</h4>
<div className="grid gap-2">
{recipes.map((recipe) => {
const recipeCode = resolveRecipeCode(recipe.code)
return (
<div
key={recipe.title}
className="overflow-hidden rounded-md border border-slate-200 dark:border-slate-800"
>
<div className="flex items-start justify-between gap-2 px-3 pt-2">
<div className="min-w-0">
<div className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{recipe.title}
</div>
<p className="text-[10px] leading-4 text-slate-400">
{recipe.description}
</p>
</div>
<span className="flex shrink-0 items-center gap-1">
{activeEventName && (
<button
className="rounded bg-amber-100 px-2 py-1 text-[9px] font-semibold text-amber-700 hover:bg-amber-200 dark:bg-amber-950 dark:text-amber-300"
title={`${activeEventName} scriptinin sonuna ekle`}
type="button"
onClick={() => appendScriptToEvent(recipeCode)}
>
Ekle
</button>
)}
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 dark:bg-sky-950 dark:text-sky-300"
type="button"
onClick={() =>
copyToClipboard(recipeCode, 'Örnek panoya kopyalandı.')
}
>
Kopyala
</button>
</span>
</div>
<pre className="mt-2 overflow-x-auto bg-slate-950 px-3 py-2 font-mono text-[10px] leading-4 text-sky-200">
{recipeCode}
</pre>
</div>
)
})}
</div>
</div>
)
})}
{!filteredScriptRecipes.length && (
<p className="rounded-md border border-dashed border-slate-300 p-4 text-center text-xs text-slate-500 dark:border-slate-700">
Aramanızla eşleşen örnek yok.
</p>
)}
</div>
</aside>
)}
2026-08-05 20:51:43 +00:00
{catalogSourceEditor && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/70 p-4">
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-start justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-700">
<div>
<h2 className="text-sm font-semibold text-slate-800 dark:text-white">
{catalogSourceEditor.item ? 'Endpointi düzenle' : 'Yeni endpoint'}
</h2>
<p className="mt-1 text-[10px] text-slate-500">
{catalogSourceEditor.item?.origin ||
'Bu componente yeni bir GET / POST / PUT / DELETE kaynağı ekleyin.'}
2026-08-05 20:51:43 +00:00
</p>
</div>
<button
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
title="Kapat"
type="button"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
×
</button>
</div>
<div className="space-y-4 p-5">
<div className="grid gap-4 md:grid-cols-[1fr_110px]">
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Ad
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
value={catalogSourceEditor.draft.name}
onChange={(event) => updateCatalogSourceDraft({ name: event.target.value })}
/>
</label>
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Metot
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
2026-08-05 20:51:43 +00:00
value={catalogSourceEditor.draft.method}
onChange={(event) =>
updateCatalogSourceDraft({
method: event.target.value as DesignerHttpMethod,
})
}
>
{DESIGNER_HTTP_METHODS.map((method) => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
2026-08-05 20:51:43 +00:00
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Endpoint URL
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
placeholder="/api/app/customers"
value={catalogSourceEditor.draft.url}
onChange={(event) => updateCatalogSourceDraft({ url: event.target.value })}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Cevap kök yolu <span className="font-normal normal-case">(opsiyonel)</span>
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
placeholder="result.items"
value={catalogSourceEditor.draft.responsePath}
onChange={(event) =>
updateCatalogSourceDraft({ responsePath: event.target.value })
}
/>
</label>
{catalogSourceTestResult && catalogSourceTestResult.status !== 'success' && (
<div
className={`max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-md p-3 font-mono text-[11px] ${
catalogSourceTestResult.status === 'error'
? 'bg-red-950 text-red-200'
: catalogSourceTestResult.status === 'loading'
? 'bg-slate-950 text-slate-300'
: 'bg-slate-950 text-emerald-300'
}`}
>
{catalogSourceTestResult.message}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-slate-200 bg-slate-50 px-5 py-4 dark:border-slate-700 dark:bg-slate-950">
<Button
size="sm"
variant="plain"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
Vazgeç
</Button>
<Button
disabled={
catalogSourceEditor.draft.method !== 'GET' ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url)
}
2026-08-05 20:51:43 +00:00
icon={<FaPlay />}
loading={catalogSourceTestResult?.status === 'loading'}
size="sm"
title={
catalogSourceEditor.draft.method === 'GET'
? undefined
: 'Yalnızca GET endpointleri tasarım ekranından çalıştırılabilir.'
}
2026-08-05 20:51:43 +00:00
variant="default"
onClick={() => void testCatalogSource()}
>
Çalıştır
</Button>
<Button
disabled={
!catalogSourceEditor.draft.name.trim() ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url) ||
(catalogSourceEditor.draft.method === 'GET' &&
catalogSourceTestResult?.status !== 'success')
2026-08-05 20:51:43 +00:00
}
icon={<FaRegSave />}
loading={isCatalogSourceSaving}
size="sm"
variant="solid"
onClick={() => void saveCatalogSource()}
>
{catalogSourceEditor.item ? 'Değişiklikleri kaydet' : 'Endpointi ekle'}
</Button>
</div>
</div>
</div>
)}
{endpointResultModal && (
<div
aria-label="Endpoint sonucu"
aria-modal="true"
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/75 p-4"
role="dialog"
onMouseDown={(event) => {
if (event.currentTarget === event.target) setEndpointResultModal(null)
}}
>
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
<div className="flex items-start justify-between border-b border-slate-700 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<FaDatabase className="text-sky-400" />
<h2 className="truncate text-sm font-semibold text-white">
{endpointResultModal.source.name}
</h2>
<span className="rounded bg-emerald-950 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
Başarılı
</span>
</div>
<div className="mt-1 break-all font-mono text-[10px] text-slate-400">
{endpointResultModal.source.method} {endpointResultModal.source.url}
</div>
</div>
<button
aria-label="Sonuç modalını kapat"
className="ml-4 rounded-md px-2 py-1 text-xl text-slate-400 hover:bg-slate-800 hover:text-white"
type="button"
onClick={() => setEndpointResultModal(null)}
>
×
</button>
</div>
<div className="flex items-center gap-3 border-b border-slate-800 bg-slate-950/60 px-5 py-2 text-[10px] text-slate-400">
<span>
Response path:{' '}
<code className="text-sky-300">
{endpointResultModal.source.responsePath || '(root)'}
</code>
</span>
<span className="rounded bg-slate-800 px-2 py-0.5 text-slate-300">
{endpointResultCount}
</span>
</div>
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-5 font-mono text-xs leading-5 text-emerald-300">
{endpointResultText}
</pre>
<div className="flex justify-end border-t border-slate-700 bg-slate-950 px-5 py-3">
<Button size="sm" variant="default" onClick={() => setEndpointResultModal(null)}>
Kapat
</Button>
</div>
</div>
</div>
)}
{scriptBuilderTarget && scriptBuilderNode && (
<DesignerScriptBuilderDialog
isOpen
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
eventInfo={getSqlDataSourceEvent(scriptBuilderTarget.eventName)}
eventName={scriptBuilderTarget.eventName}
recordFields={scriptBuilderRecordFields}
refNames={[...documentRefs.keys()]}
sqlRef={scriptBuilderSqlNode?.ref || ''}
value={scriptBuilderNode.events[scriptBuilderTarget.eventName] || ''}
onApply={(next) =>
updateNodeEvent(scriptBuilderTarget.nodeId, scriptBuilderTarget.eventName, next)
}
onClose={() => setScriptBuilderTarget(null)}
/>
)}
2026-08-05 20:51:43 +00:00
</div>
)
}
export default VisualComponentDesigner