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

4395 lines
179 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Editor from '@monaco-editor/react'
import axios from 'axios'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet'
import { Link, useParams } from 'react-router-dom'
import {
FaArrowLeft,
FaBolt,
FaCheckCircle,
FaArrowDown,
FaArrowUp,
FaChevronDown,
FaChevronRight,
FaCode,
FaDesktop,
FaDatabase,
FaEye,
FaEdit,
FaLayerGroup,
FaMobileAlt,
FaMousePointer,
FaPlus,
FaPlay,
FaRedo,
FaRegSave,
FaSearch,
FaSlidersH,
FaSync,
FaTabletAlt,
FaTrash,
FaUndo,
} from 'react-icons/fa'
import { useComponents } from '@/contexts/ComponentContext'
import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models'
import { APP_NAME } from '@/constants/app.constant'
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'
import StyleModal from '@/components/codeLayout/StyleModal'
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
DESIGNER_DATA_COMPONENT_NAMES,
DESIGNER_PRIMARY_EVENTS,
getDesignerCatalog,
getDesignerEventSnippet,
} from '@/components/visualDesigner/catalog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import {
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
getDesignerCollectionProperty,
getDesignerValueByPath,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
getSqlDataSourcePreviewKey,
getSqlDataSourceRecord,
isDesignerOptionComponent,
isDesignerTabularComponent,
isSqlDataSourceNode,
resolveDesignerResponse,
toDesignerHttpMethod,
walkDesignerNodes,
appendSqlDataSourceQueryParam,
bindSqlDataSourceUrl,
hasSqlDataSourceUrlParams,
DESIGNER_HTTP_METHODS,
SQL_DATA_SOURCE_KEY_SOURCES,
SQL_DATA_SOURCE_SLOTS,
type DesignerComponentDefinition,
type DesignerBinding,
type DesignerDataSource,
type DesignerDocument,
type DesignerHttpMethod,
type DesignerNode,
type DesignerPropertyInfo,
} from '@/components/visualDesigner/types'
import { useLocalization } from '@/utils/hooks/useLocalization'
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
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)))
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
}
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],
}))
}
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)
}
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 => {
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]
}
delete props[legacyName]
})
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,
}
})
return {
...document,
nodes: normalizeNodes(document.nodes),
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) }),
),
}
}
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 => {
const encodedDocument = code?.match(/\/\*__SOZSOFT_VISUAL_DESIGNER__(.*?)__\*\//)?.[1]
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]
}
}
for (const match of source.matchAll(
/([A-Za-z_$][\w$]*)=\{(true|false|null|-?\d+(?:\.\d+)?)\}/g,
)) {
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]) : '',
})
}
for (const match of code.matchAll(
/<UiKit\.(Select|AutoComplete|Menu|Pagination)\b([^>]*?)(?:\/>|>)/g,
)) {
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[]
addNode(
match.index,
'Table',
'ui',
{
...parseGeneratedJsxProps(match[4], componentDefaultProps),
rowCount: normalizeTableDimension(legacyItems.length, 2),
columnCount: normalizeTableDimension(legacyColumns.length, 3),
},
{},
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',
{
className:
code
.slice(Math.max(0, match.index - 250), match.index)
.match(/<div className="([^"]*)"[^>]*>\s*\{\(\(\) => \{$/)?.[1] || '',
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),
)
}
document.nodes = positionedNodes
.sort((left, right) => left.index - right.index)
.map(({ node }) => node)
return document.nodes.length ? document : null
}
/**
* Array-of-object properties otherwise fall back to a raw JSON textarea, which is
* unusable for anything but trivial values. Components listed here get a proper
* row editor instead, keyed by `<component>.<property>`.
*/
const OBJECT_LIST_PROPERTIES: Record<
string,
{ itemLabel: string; fields: Array<{ name: string; label: string; placeholder?: string }> }
> = {
'ImageViewer.images': {
itemLabel: 'Görsel',
fields: [
{ name: 'src', label: 'Görsel URL', placeholder: 'https://…' },
{ name: 'thumbnail', label: 'Küçük görsel URL', placeholder: 'Boşsa görselin kendisi' },
{ name: 'alt', label: 'Alternatif metin', placeholder: 'Erişilebilirlik metni' },
{ name: 'caption', label: 'Açıklama', placeholder: 'Görsel altı yazı' },
],
},
}
const ObjectListEditor = ({
schema,
value,
onChange,
}: {
schema: (typeof OBJECT_LIST_PROPERTIES)[string]
value: unknown
onChange: (value: unknown) => void
}) => {
const items = Array.isArray(value) ? (value as Array<Record<string, unknown>>) : []
const inputClass =
'w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
const replaceItem = (index: number, next: Record<string, unknown>) =>
onChange(items.map((item, itemIndex) => (itemIndex === index ? next : item)))
const move = (index: number, direction: -1 | 1) => {
const target = index + direction
if (target < 0 || target >= items.length) return
const next = [...items]
;[next[index], next[target]] = [next[target], next[index]]
onChange(next)
}
return (
<div className="space-y-2">
{items.map((item, index) => (
<div
key={index}
className="space-y-1.5 rounded-md border border-slate-200 p-2 dark:border-slate-700"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-slate-500">
{schema.itemLabel} {index + 1}
</span>
<div className="flex items-center gap-0.5">
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === 0}
title="Yukarı taşı"
type="button"
onClick={() => move(index, -1)}
>
<FaArrowUp className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === items.length - 1}
title="Aşağı taşı"
type="button"
onClick={() => move(index, 1)}
>
<FaArrowDown className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-red-600"
title="Sil"
type="button"
onClick={() => onChange(items.filter((_, i) => i !== index))}
>
<FaTrash className="h-2.5 w-2.5" />
</button>
</div>
</div>
{item.src ? (
<img
alt=""
className="h-16 w-full rounded border border-slate-200 object-cover dark:border-slate-700"
src={String(item.thumbnail || item.src)}
/>
) : null}
{schema.fields.map((field) => (
<label key={field.name} className="block">
<span className="mb-0.5 block text-[9px] font-medium uppercase tracking-wide text-slate-400">
{field.label}
</span>
<input
className={inputClass}
placeholder={field.placeholder}
type="text"
value={String(item[field.name] ?? '')}
onChange={(event) =>
replaceItem(index, { ...item, [field.name]: event.target.value })
}
/>
</label>
))}
</div>
))}
<button
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700"
type="button"
onClick={() => onChange([...items, { src: '' }])}
>
<FaPlus /> {schema.itemLabel} ekle
</button>
</div>
)
}
const PropertyEditor = ({
componentType,
name,
type,
value,
options,
onChange,
}: {
componentType?: string
name: string
type: string
value: unknown
options?: string[]
onChange: (value: unknown) => void
}) => {
const [styleModalOpen, setStyleModalOpen] = useState(false)
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 ?? '')
return (
<select
className={inputClass}
value={currentValue}
onChange={(event) => onChange(event.target.value)}
>
{/* An empty value is not emitted, so the component keeps its own or its
container's default — spell that out instead of showing a blank row. */}
{!options.includes(currentValue) && <option value="">Varsayılan</option>}
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
}
if (type === 'object' || type === 'array') {
return (
<textarea
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)
}
}}
/>
)
}
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) {
const currentClasses = String(value ?? '')
return (
<>
<div className="flex gap-2">
{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)}
/>
)}
<Button
icon={<FaBolt />}
size="sm"
title={`${name} stillerini StyleModal ile düzenle`}
variant="default"
onClick={() => setStyleModalOpen(true)}
/>
</div>
<StyleModal
isOpen={styleModalOpen}
mode={isCssProperty ? 'css' : 'class'}
propertyName={name}
value={currentClasses}
onChange={onChange}
onClose={() => setStyleModalOpen(false)}
/>
</>
)
}
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}
placeholder={type === 'string' ? 'Metin veya ::LanguageKey' : undefined}
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()
const { translate } = useLocalization()
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')
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)
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')
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')
}, [])
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 = []
return updater(current)
})
}, [])
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) => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
if (slot) node.slot = slot
commitDocument((current) => ({
...current,
sourceMode: 'visual',
nodes: insertNodeTree(current.nodes, node, parentId),
}))
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
)
/**
* 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],
)
const selectedNode = useMemo(
() => findDesignerNode(document.nodes, selectedId),
[document.nodes, selectedId],
)
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
/**
* 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 &&
(DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type) ||
selectedIsSqlDataSource ||
sqlScopeNode),
)
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),
)?.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 || ''
})
}, [inspectorDataSources, selectedBindingSourceId])
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)
.forEach((endpoint) => {
const method = toDesignerHttpMethod(endpoint.method)
const dataSourceKey = `${method}:${endpoint.path.trim()}:`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(source) => source.method === method && source.url.trim() === endpoint.path.trim(),
)
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,
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],
)
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)
if (existingSource) {
if (!bindsCollection) {
setDataPanelSourceId(existingSource.id)
return
}
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 &&
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 &&
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
}
if (showResult) setEndpointResultModal(null)
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'loading', message: 'İstek gönderiliyor…' },
}))
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) },
}))
}
},
[],
)
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
}
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') {
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],
)
const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({
...current,
nodes: reorderNodeTree(current.nodes, sourceId, targetId, placement),
}))
selectDesignerNode(sourceId)
},
[commitDocument, selectDesignerNode],
)
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) => {
const target = event.target
const isPropertyEditing =
target instanceof HTMLElement &&
Boolean(
target.closest(
'[data-property-panel], input, textarea, select, [contenteditable="true"], [role="textbox"], .monaco-editor',
),
)
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') {
deleteNode(selectedId)
}
}
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)
}, [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,
])
const optionDataProperty = getOptionDataProperty(selectedNode?.type)
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
? selectedNode?.bindings?.[optionDataProperty]
: undefined
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,
}
}
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]
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]
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 &&
(DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type) || Boolean(sqlScopeNode))
? 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)),
)
: []
const orderedBindableProperties = sqlScopeLocked
? [...dataBindableProperties].sort(
(left, right) => getSqlRecordFieldOrder(left.name) - getSqlRecordFieldOrder(right.name),
)
: dataBindableProperties
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.
</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>
)}
{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>
)
}
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()
const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration()
if (selectedIsCollectionComponent && selectDataMode === 'static') {
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
{renderSqlRecordValueBinding()}
</div>
)
}
if (!inspectorDataSources.length) {
return (
<div className="space-y-3">
{scopeNotice}
{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{' '}
<strong>Data</strong> çalışma alanından tanımlayabilirsiniz.
</div>
</div>
)
}
return (
<div className="space-y-3">
{scopeNotice}
{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. */}
<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'}
</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, '')
}
}
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>
)}
</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 ? (
<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) => {
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
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 && (
<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>
)}
</label>
)
})
)}
</div>
)}
{renderSqlRecordValueBinding()}
</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)}
>
<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 = (
<Helmet defaultTitle={APP_NAME} title={pageTitle} titleTemplate={`%s | ${APP_NAME}`}></Helmet>
)
if (!component) {
return (
<div className="flex min-h-[70vh] items-center justify-center text-sm text-slate-500">
{pageHelmet}
{componentLoadError || 'Bileşen yükleniyor…'}
</div>
)
}
const canvas = (
<VisualCanvas
interactive
nodes={document.nodes}
selectedId={selectedId}
dataValues={previewDataValues}
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
onDropComponent={addComponent}
onDropComponentBeside={addComponentBeside}
onMoveIntoContainer={moveNodeIntoContainerNode}
onMove={moveNode}
onReorder={reorderNode}
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]
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}
<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)
}}
>
{translate('::Reset')}
</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')
}}
>
{translate('::OpenUrl')}
</Button>
<Button
loading={isSaving}
icon={<FaRegSave />}
size="sm"
variant="solid"
onClick={() => void handleSave()}
>
{translate('::Save')}
</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 border-slate-200 bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950"
title={definition.description}
type="button"
onClick={() => addComponent(definition.name, null)}
onDragStart={(event) => {
const payload = JSON.stringify({
source: 'library',
name: definition.name,
})
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
event.dataTransfer.setData('text/plain', payload)
}}
>
<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>
{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>
)}
</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>
<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>
</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 border-slate-200 dark:border-slate-800"
>
<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">
<code>{eventName}(event)</code>
<span className="flex items-center gap-2">
<span className="text-[9px] font-normal text-slate-400">
{activeJavaScriptTarget.node.type}
</span>
<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,
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
),
)
}
>
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' && (
<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"
>
<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
}
type="button"
onClick={() => {
if (tab !== 'data' || selectedIsDataComponent) setInspectorTab(tab)
}}
>
{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>
{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
.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)
),
)
.map((property) => {
const propertyValue = selectedNode.props[property.name] ?? property.value
const helperValue = getPropertyExampleValue(property, property.value)
const snippet = getPropertySnippet(property, helperValue)
return (
<div
key={property.name}
className="group/property mb-4 rounded-md border border-slate-200 p-2 dark:border-slate-800"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>
{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-30 mt-1 hidden w-56 rounded-md border border-slate-200 bg-white p-2 text-left font-mono text-[9px] leading-4 text-sky-700 shadow-lg group-hover/snippet:block dark:border-slate-700 dark:bg-slate-950 dark:text-sky-300">
{snippet}
<span className="mt-1 block font-sans text-[9px] text-slate-400">
Tıklayınca property değerine uygulanır.
</span>
</span>
</span>
</span>
</span>
{property.description && (
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{property.description}
</p>
)}
<PropertyEditor
componentType={selectedNode?.type}
name={property.name}
type={property.type}
value={propertyValue}
options={property.options}
onChange={(value) => updateSelectedProp(property.name, value)}
/>
</div>
)
})}
</>
) : (
renderDataInspector()
)}
</>
) : (
<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>
{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.'}
</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"
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>
</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)
}
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.'
}
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')
}
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>
)}
</div>
)
}
export default VisualComponentDesigner