3424 lines
138 KiB
TypeScript
3424 lines
138 KiB
TypeScript
|
|
import Editor from '@monaco-editor/react'
|
|||
|
|
import axios from 'axios'
|
|||
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
|
import { Link, useParams } from 'react-router-dom'
|
|||
|
|
import {
|
|||
|
|
FaArrowLeft,
|
|||
|
|
FaBolt,
|
|||
|
|
FaCheckCircle,
|
|||
|
|
FaChevronDown,
|
|||
|
|
FaChevronRight,
|
|||
|
|
FaCode,
|
|||
|
|
FaDesktop,
|
|||
|
|
FaDatabase,
|
|||
|
|
FaEye,
|
|||
|
|
FaEdit,
|
|||
|
|
FaLayerGroup,
|
|||
|
|
FaMobileAlt,
|
|||
|
|
FaMousePointer,
|
|||
|
|
FaPlus,
|
|||
|
|
FaPlay,
|
|||
|
|
FaRedo,
|
|||
|
|
FaRegCopy,
|
|||
|
|
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 { 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 TailwindModal from '@/components/codeLayout/TailwindModal'
|
|||
|
|
import { useStoreState } from '@/store/store'
|
|||
|
|
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
|
|||
|
|
import {
|
|||
|
|
DESIGNER_PRIMARY_EVENTS,
|
|||
|
|
getDesignerCatalog,
|
|||
|
|
getDesignerEventSnippet,
|
|||
|
|
} from '@/components/visualDesigner/catalog'
|
|||
|
|
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
|||
|
|
import {
|
|||
|
|
createDesignerId,
|
|||
|
|
createEmptyDesignerDocument,
|
|||
|
|
findDesignerNode,
|
|||
|
|
getDesignerValueByPath,
|
|||
|
|
resolveDesignerResponse,
|
|||
|
|
walkDesignerNodes,
|
|||
|
|
type DesignerComponentDefinition,
|
|||
|
|
type DesignerBinding,
|
|||
|
|
type DesignerDataSource,
|
|||
|
|
type DesignerDocument,
|
|||
|
|
type DesignerNode,
|
|||
|
|
type DesignerPropertyInfo,
|
|||
|
|
} from '@/components/visualDesigner/types'
|
|||
|
|
|
|||
|
|
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',
|
|||
|
|
])
|
|||
|
|
const OPTION_DATA_COMPONENT_NAMES = new Set(['Select', 'AutoComplete', 'Menu'])
|
|||
|
|
const TABULAR_DATA_COMPONENT_NAMES = new Set(['Grid', 'Table'])
|
|||
|
|
const isOptionDataComponent = (type?: string) =>
|
|||
|
|
Boolean(type && OPTION_DATA_COMPONENT_NAMES.has(type))
|
|||
|
|
const getOptionDataProperty = (type?: string) => (type === 'Menu' ? 'items' : 'options')
|
|||
|
|
const isTabularDataComponent = (type?: string) =>
|
|||
|
|
Boolean(type && TABULAR_DATA_COMPONENT_NAMES.has(type))
|
|||
|
|
|
|||
|
|
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 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 moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
|
|||
|
|
const index = nodes.findIndex((node) => node.id === id)
|
|||
|
|
if (index >= 0) {
|
|||
|
|
const target = index + direction
|
|||
|
|
if (target < 0 || target >= nodes.length) return nodes
|
|||
|
|
const next = [...nodes]
|
|||
|
|
;[next[index], next[target]] = [next[target], next[index]]
|
|||
|
|
return next
|
|||
|
|
}
|
|||
|
|
return nodes.map((node) => ({ ...node, children: moveInTree(node.children, id, direction) }))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const 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') {
|
|||
|
|
if (
|
|||
|
|
Array.isArray(props.data) &&
|
|||
|
|
(!Array.isArray(props.items) || props.items.length === 0)
|
|||
|
|
) {
|
|||
|
|
props.items = props.data
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(props.columns) && !Array.isArray(props.dataColumns)) {
|
|||
|
|
props.dataColumns = props.columns
|
|||
|
|
.map((column) => {
|
|||
|
|
if (typeof column === 'string') return column
|
|||
|
|
if (!column || typeof column !== 'object' || Array.isArray(column)) return ''
|
|||
|
|
const record = column as Record<string, unknown>
|
|||
|
|
return String(record.dataIndex ?? record.key ?? '')
|
|||
|
|
})
|
|||
|
|
.filter(Boolean)
|
|||
|
|
}
|
|||
|
|
delete props.data
|
|||
|
|
delete props.columns
|
|||
|
|
if (!bindings.items && bindings.data) bindings.items = bindings.data
|
|||
|
|
delete bindings.data
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
...node,
|
|||
|
|
props,
|
|||
|
|
events,
|
|||
|
|
bindings,
|
|||
|
|
children: normalizeNodes(node.children || []),
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
return {
|
|||
|
|
...document,
|
|||
|
|
nodes: normalizeNodes(document.nodes),
|
|||
|
|
dataSources: Array.isArray(document.dataSources) ? document.dataSources : [],
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 {
|
|||
|
|
addNode(
|
|||
|
|
match.index,
|
|||
|
|
'Table',
|
|||
|
|
'ui',
|
|||
|
|
{
|
|||
|
|
...parseGeneratedJsxProps(match[4], componentDefaultProps),
|
|||
|
|
items: JSON.parse(match[2]),
|
|||
|
|
dataColumns: JSON.parse(match[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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PropertyEditor = ({
|
|||
|
|
name,
|
|||
|
|
type,
|
|||
|
|
value,
|
|||
|
|
options,
|
|||
|
|
onChange,
|
|||
|
|
}: {
|
|||
|
|
name: string
|
|||
|
|
type: string
|
|||
|
|
value: unknown
|
|||
|
|
options?: string[]
|
|||
|
|
onChange: (value: unknown) => void
|
|||
|
|
}) => {
|
|||
|
|
const [tailwindModalOpen, setTailwindModalOpen] = 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) {
|
|||
|
|
return (
|
|||
|
|
<select
|
|||
|
|
className={inputClass}
|
|||
|
|
value={String(value ?? '')}
|
|||
|
|
onChange={(event) => onChange(event.target.value)}
|
|||
|
|
>
|
|||
|
|
{options.map((option) => (
|
|||
|
|
<option key={option} value={option}>
|
|||
|
|
{option}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if (name === 'className' || name === 'class' || name === 'css') {
|
|||
|
|
const currentClasses = String(value ?? '')
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<input
|
|||
|
|
className={inputClass}
|
|||
|
|
type="text"
|
|||
|
|
value={currentClasses}
|
|||
|
|
onChange={(event) => onChange(event.target.value)}
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
icon={<FaBolt />}
|
|||
|
|
size="sm"
|
|||
|
|
title="Tailwind sınıflarını seç"
|
|||
|
|
variant="default"
|
|||
|
|
onClick={() => setTailwindModalOpen(true)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<TailwindModal
|
|||
|
|
currentValue={currentClasses}
|
|||
|
|
isOpen={tailwindModalOpen}
|
|||
|
|
onClose={() => setTailwindModalOpen(false)}
|
|||
|
|
onSelectClass={(className) => {
|
|||
|
|
if (!className) {
|
|||
|
|
onChange('')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const classes = currentClasses.split(/\s+/).filter(Boolean)
|
|||
|
|
onChange(
|
|||
|
|
classes.includes(className)
|
|||
|
|
? classes.filter((item) => item !== className).join(' ')
|
|||
|
|
: [...classes, className].join(' '),
|
|||
|
|
)
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
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}
|
|||
|
|
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 { components, updateComponent, renderComponent } = useComponents()
|
|||
|
|
const appThemeMode = useStoreState((state) => state.theme.mode)
|
|||
|
|
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 initialDataSourcesToTestRef = useRef<Set<string>>(new Set())
|
|||
|
|
|
|||
|
|
const component = componentDetails?.id === id ? componentDetails : undefined
|
|||
|
|
const name = component?.name || 'VisualComponent'
|
|||
|
|
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])
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
initialDataSourcesToTestRef.current.clear()
|
|||
|
|
|
|||
|
|
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')
|
|||
|
|
initialDataSourcesToTestRef.current = new Set(
|
|||
|
|
initialDocument.dataSources.map((source) => source.id),
|
|||
|
|
)
|
|||
|
|
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) => {
|
|||
|
|
const definition = catalogByName.get(definitionName)
|
|||
|
|
if (!definition) return
|
|||
|
|
const node = definitionToNode(definition)
|
|||
|
|
commitDocument((current) => ({
|
|||
|
|
...current,
|
|||
|
|
sourceMode: 'visual',
|
|||
|
|
nodes: insertNodeTree(current.nodes, node, parentId),
|
|||
|
|
}))
|
|||
|
|
setSelectedId(node.id)
|
|||
|
|
setWorkspaceTab('design')
|
|||
|
|
},
|
|||
|
|
[catalogByName, commitDocument],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const selectedNode = useMemo(
|
|||
|
|
() => findDesignerNode(document.nodes, selectedId),
|
|||
|
|
[document.nodes, selectedId],
|
|||
|
|
)
|
|||
|
|
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
|
|||
|
|
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) =>
|
|||
|
|
document.dataSources.some((source) => source.id === binding.sourceId),
|
|||
|
|
)?.sourceId
|
|||
|
|
: undefined
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
setDataPanelSourceId((current) => {
|
|||
|
|
if (selectedBindingSourceId) return selectedBindingSourceId
|
|||
|
|
if (document.dataSources.some((source) => source.id === current)) return current
|
|||
|
|
return document.dataSources[0]?.id || ''
|
|||
|
|
})
|
|||
|
|
}, [document.dataSources, 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
|
|||
|
|
.filter(
|
|||
|
|
(endpoint) =>
|
|||
|
|
endpoint.isActive && endpoint.method === 'GET' && !endpoint.path.includes('{'),
|
|||
|
|
)
|
|||
|
|
.forEach((endpoint) => {
|
|||
|
|
const dataSourceKey = `GET:${endpoint.path.trim()}:`
|
|||
|
|
if (items.has(dataSourceKey)) return
|
|||
|
|
const attached = document.dataSources.some(
|
|||
|
|
(source) => source.method === 'GET' && 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: 'GET',
|
|||
|
|
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 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(),
|
|||
|
|
)
|
|||
|
|
if (existingSource) {
|
|||
|
|
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:
|
|||
|
|
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 (
|
|||
|
|
selectedId &&
|
|||
|
|
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
|
|||
|
|
) {
|
|||
|
|
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
|
|||
|
|
}
|
|||
|
|
setDataPanelSourceId(dataSource.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const testDataSource = useCallback(async (source: DesignerDataSource, showResult = false) => {
|
|||
|
|
if (showResult) setEndpointResultModal(null)
|
|||
|
|
setDataTestResults((current) => ({
|
|||
|
|
...current,
|
|||
|
|
[source.id]: { status: 'loading', message: 'İstek gönderiliyor…' },
|
|||
|
|
}))
|
|||
|
|
try {
|
|||
|
|
if (!source.url.trim()) {
|
|||
|
|
throw new Error('Endpoint URL alanını doldurun.')
|
|||
|
|
}
|
|||
|
|
if (!source.url.trim().startsWith('/api/')) {
|
|||
|
|
throw new Error('Platform endpoint adresi /api/ ile başlamalıdır.')
|
|||
|
|
}
|
|||
|
|
if (!isRunnableDataSourceUrl(source.url)) {
|
|||
|
|
throw new Error('Çağrılabilir 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}`)
|
|||
|
|
}
|
|||
|
|
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
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
if (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' },
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!id || loadedId !== id) return
|
|||
|
|
document.dataSources.forEach((source) => {
|
|||
|
|
if (!initialDataSourcesToTestRef.current.has(source.id)) return
|
|||
|
|
initialDataSourcesToTestRef.current.delete(source.id)
|
|||
|
|
if (!isRunnableDataSourceUrl(source.url)) return
|
|||
|
|
void testDataSource(source)
|
|||
|
|
})
|
|||
|
|
}, [document.dataSources, id, loadedId, testDataSource])
|
|||
|
|
|
|||
|
|
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 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) => {
|
|||
|
|
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' && 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) || []] as const).filter(
|
|||
|
|
([, definitions]) => definitions.length > 0,
|
|||
|
|
)
|
|||
|
|
}, [filteredCatalog])
|
|||
|
|
|
|||
|
|
const activeDataSource = document.dataSources.find((source) => source.id === dataPanelSourceId)
|
|||
|
|
const activeDataSample = activeDataSource ? dataSourceSamples[activeDataSource.id] : undefined
|
|||
|
|
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 ?? `Seçenek ${index + 1}`),
|
|||
|
|
value: record.value ?? 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)) {
|
|||
|
|
const source = activeDataSource || document.dataSources[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 || document.dataSources[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 selectedAncestors = useMemo(
|
|||
|
|
() => findDesignerAncestors(document.nodes, selectedId) || [],
|
|||
|
|
[document.nodes, selectedId],
|
|||
|
|
)
|
|||
|
|
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 && selectedNode.kind !== 'platform'
|
|||
|
|
? selectedDefinition.properties.filter(
|
|||
|
|
(property) =>
|
|||
|
|
property.category !== 'events' &&
|
|||
|
|
property.type !== 'function' &&
|
|||
|
|
(isTabularDataComponent(selectedNode.type)
|
|||
|
|
? property.name === 'items'
|
|||
|
|
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) || property.type === 'array'),
|
|||
|
|
)
|
|||
|
|
: []
|
|||
|
|
|
|||
|
|
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 GET endpointi 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>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectModeSelector = renderSelectDataModeSelector()
|
|||
|
|
const selectConfiguration = renderSelectDataConfiguration()
|
|||
|
|
const tabularConfiguration = renderTabularDataConfiguration()
|
|||
|
|
if (
|
|||
|
|
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)) &&
|
|||
|
|
selectDataMode === 'static'
|
|||
|
|
) {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{selectModeSelector}
|
|||
|
|
{selectConfiguration}
|
|||
|
|
{tabularConfiguration}
|
|||
|
|
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!document.dataSources.length) {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{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 endpoint bulunmuyor. Endpointleri ana{' '}
|
|||
|
|
<strong>Data</strong> çalışma alanından tanımlayabilirsiniz.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{selectModeSelector}
|
|||
|
|
<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">
|
|||
|
|
{isOptionDataComponent(selectedNode?.type) ? '1. Endpoint' : 'Endpoint'}
|
|||
|
|
</span>
|
|||
|
|
<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)
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{document.dataSources.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>
|
|||
|
|
{!dataBindableProperties.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>
|
|||
|
|
) : (
|
|||
|
|
dataBindableProperties.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,
|
|||
|
|
)
|
|||
|
|
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 && (
|
|||
|
|
<div className="mt-1.5 truncate font-mono text-[9px] text-emerald-600">
|
|||
|
|
{currentBindingSource?.name || currentBinding.sourceId}:{' '}
|
|||
|
|
{currentBinding.path || '(root)'}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</label>
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</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={() => setSelectedId(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>
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
if (!component) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex min-h-[70vh] items-center justify-center text-sm text-slate-500">
|
|||
|
|
{componentLoadError || 'Bileşen yükleniyor…'}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const canvas = (
|
|||
|
|
<VisualCanvas
|
|||
|
|
interactive
|
|||
|
|
nodes={document.nodes}
|
|||
|
|
selectedId={selectedId}
|
|||
|
|
background={appThemeMode === 'dark' ? '#0f172a' : document.canvas.background}
|
|||
|
|
dataValues={dataSourceSamples}
|
|||
|
|
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
|
|||
|
|
onSelect={(nodeId) => setSelectedId(nodeId || null)}
|
|||
|
|
onDropComponent={addComponent}
|
|||
|
|
onMove={moveNode}
|
|||
|
|
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">
|
|||
|
|
<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)
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
Sıfırla
|
|||
|
|
</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', 'noopener,noreferrer')
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
Aç
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
loading={isSaving}
|
|||
|
|
icon={<FaRegSave />}
|
|||
|
|
size="sm"
|
|||
|
|
variant="solid"
|
|||
|
|
onClick={() => void handleSave()}
|
|||
|
|
>
|
|||
|
|
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/ endpoint’leri 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">
|
|||
|
|
Endpoint ayarları component ile birlikte kaydedilir. Tekrarlı kartlar için Grid
|
|||
|
|
<code className="mx-1">items</code> alanını endpoint’e boş path ile bağlayın; Grid
|
|||
|
|
içine Card/Input bırakıp alt property path’lerinde <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
|
|||
|
|
Manager’da 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 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] ${inspectorTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500'}`}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => 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)
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
.map((property) => {
|
|||
|
|
const propertyValue =
|
|||
|
|
selectedNode.props[property.name] ?? property.value
|
|||
|
|
const snippet = getPropertySnippet(property, propertyValue)
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={property.name}
|
|||
|
|
className="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="text-[9px] font-normal text-slate-400">
|
|||
|
|
{property.tsType || property.type}
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
{property.description && (
|
|||
|
|
<p className="mb-2 text-[10px] leading-4 text-slate-400">
|
|||
|
|
{property.description}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
<PropertyEditor
|
|||
|
|
name={property.name}
|
|||
|
|
type={property.type}
|
|||
|
|
value={propertyValue}
|
|||
|
|
options={property.options}
|
|||
|
|
onChange={(value) => updateSelectedProp(property.name, value)}
|
|||
|
|
/>
|
|||
|
|
<details className="mt-2 rounded border border-slate-200 bg-slate-50 text-[10px] dark:border-slate-700 dark:bg-slate-950">
|
|||
|
|
<summary className="cursor-pointer select-none px-2 py-1.5 font-semibold text-slate-400">
|
|||
|
|
Snippet
|
|||
|
|
</summary>
|
|||
|
|
<div className="flex items-start gap-1 border-t border-slate-200 px-2 py-1.5 dark:border-slate-700">
|
|||
|
|
<code className="min-w-0 flex-1 break-all leading-4 text-sky-700 dark:text-sky-300">
|
|||
|
|
{snippet}
|
|||
|
|
</code>
|
|||
|
|
<button
|
|||
|
|
className="shrink-0 rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-sky-600 dark:hover:bg-slate-800"
|
|||
|
|
title="Snippet'ı kopyala"
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => void navigator.clipboard?.writeText(snippet)}
|
|||
|
|
>
|
|||
|
|
<FaRegCopy />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</details>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
: renderDataInspector()}
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<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">
|
|||
|
|
Property’leri görmek için canvas’tan bir bileşen seçin.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<label className="block">
|
|||
|
|
<span className="mb-1.5 block text-[11px] font-medium text-slate-600 dark:text-slate-300">
|
|||
|
|
Canvas background
|
|||
|
|
</span>
|
|||
|
|
<input
|
|||
|
|
className="h-9 w-full rounded border border-slate-300"
|
|||
|
|
type="color"
|
|||
|
|
value={document.canvas.background}
|
|||
|
|
onChange={(event) =>
|
|||
|
|
commitDocument((current) => ({
|
|||
|
|
...current,
|
|||
|
|
canvas: { ...current.canvas, background: event.target.value },
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
</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 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>
|
|||
|
|
<input
|
|||
|
|
readOnly
|
|||
|
|
className="w-full rounded-md border border-slate-300 bg-slate-100 px-3 py-2 text-xs dark:border-slate-700 dark:bg-slate-800"
|
|||
|
|
value={catalogSourceEditor.draft.method}
|
|||
|
|
/>
|
|||
|
|
</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={!isRunnableDataSourceUrl(catalogSourceEditor.draft.url)}
|
|||
|
|
icon={<FaPlay />}
|
|||
|
|
loading={catalogSourceTestResult?.status === 'loading'}
|
|||
|
|
size="sm"
|
|||
|
|
variant="default"
|
|||
|
|
onClick={() => void testCatalogSource()}
|
|||
|
|
>
|
|||
|
|
Çalıştır
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
disabled={
|
|||
|
|
!catalogSourceEditor.draft.name.trim() ||
|
|||
|
|
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url) ||
|
|||
|
|
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
|