sozsoft-platform/ui/src/views/developerKit/VisualComponentDesigner.tsx
2026-08-14 14:32:31 +03:00

5062 lines
211 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import Editor from '@monaco-editor/react'
import axios from 'axios'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import PageTitle from '@/components/shared/PageTitle'
import { Link, useParams } from 'react-router-dom'
import {
FaArrowLeft,
FaBolt,
FaCheckCircle,
FaArrowDown,
FaArrowUp,
FaChevronDown,
FaChevronRight,
FaCode,
FaDesktop,
FaDatabase,
FaEye,
FaEdit,
FaLayerGroup,
FaMobileAlt,
FaMousePointer,
FaPlus,
FaPlay,
FaRedo,
FaRegSave,
FaSearch,
FaSlidersH,
FaSync,
FaTabletAlt,
FaTimes,
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 StyleModal from '@/components/codeLayout/StyleModal'
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
beginDesignerDragSoon,
endDesignerDrag,
useDesignerDrag,
} from '@/components/visualDesigner/designerDrag'
import {
DESIGNER_PRIMARY_EVENTS,
DESIGNER_SCRIPT_RECIPES,
DESIGNER_SCRIPT_RECIPE_GROUPS,
getDesignerCatalog,
getDesignerEventSnippet,
} from '@/components/visualDesigner/catalog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
import {
collectDesignerRefs,
getSqlDataSourceEvent,
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
isValidDesignerRef,
renameDesignerRefInNodes,
renameDesignerRefInScript,
withDesignerRefs,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerValueByPath,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
getSqlDataSourcePreviewKey,
getSqlDataSourceRecord,
isDesignerOptionComponent,
isDesignerTabularComponent,
isSqlDataSourceNode,
resolveDesignerResponse,
toDesignerHttpMethod,
walkDesignerNodes,
appendSqlDataSourceQueryParam,
bindSqlDataSourceUrl,
hasSqlDataSourceUrlParams,
DESIGNER_HTTP_METHODS,
SQL_DATA_SOURCE_KEY_SOURCES,
SQL_DATA_SOURCE_SLOTS,
type DesignerComponentDefinition,
type DesignerBinding,
type DesignerDataSource,
type DesignerDocument,
type DesignerHttpMethod,
type DesignerNode,
type DesignerPropertyInfo,
} from '@/components/visualDesigner/types'
import { useLocalization } from '@/utils/hooks/useLocalization'
type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript'
type InspectorTab = 'properties' | 'data' | 'tree'
type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string }
type EndpointResultModal = { source: DesignerDataSource; result: unknown }
type DataField = { path: string; type: string; preview: string }
type StaticSelectOption = { label: string; value: unknown }
type DataSourceCatalogItem = {
attached: boolean
key: string
origin: string
originType: 'current' | 'component' | 'crud'
ownerComponentId?: string
source: DesignerDataSource
}
const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'items',
'data',
'dataSource',
'options',
'value',
'checked',
'children',
'content',
'text',
'label',
'title',
'header',
'headerExtra',
'footer',
'description',
'placeholder',
'src',
'alt',
'disabled',
'loading',
])
/**
* Properties worth binding to a SqlDataSource record column, most used first.
* A component exposes dozens of props; offering every one of them turns the
* panel into noise, so only the ones that actually carry record data are listed.
*/
const SQL_RECORD_FIELD_PROPERTIES = [
'value',
'checked',
'children',
'text',
'label',
'title',
'placeholder',
'src',
'url',
'href',
'alt',
'content',
'description',
'header',
'footer',
'defaultValue',
'disabled',
]
const getSqlRecordFieldOrder = (propertyName: string) =>
SQL_RECORD_FIELD_PROPERTIES.indexOf(propertyName)
// Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is
// covered by the static/endpoint panel by construction.
const isOptionDataComponent = isDesignerOptionComponent
const getOptionDataProperty = getDesignerCollectionProperty
const isTabularDataComponent = isDesignerTabularComponent
/**
* The inspector's Data tab follows the toolbox category, not the collection
* registry: everything filed under `data` is expected to be data driven, even
* when it carries no collection property of its own.
*/
const isDataCategoryComponent = (definition?: DesignerComponentDefinition) =>
definition?.toolboxGroup === 'data'
type TranslateFn = (key: string, params?: Record<string, string | number>) => string
const getPropertyExampleValues = (translate: TranslateFn): Record<string, unknown> => ({
children: translate('::App.DeveloperKit.ComponentDesigner.Example.Content'),
className: 'p-4 rounded-lg shadow-sm',
id: 'component-id',
label: translate('::App.DeveloperKit.ComponentDesigner.Example.Title'),
name: 'exampleName',
placeholder: translate('::App.DeveloperKit.ComponentDesigner.Example.Placeholder'),
src: '/img/example.png',
title: translate('::App.DeveloperKit.ComponentDesigner.Example.Title'),
url: '/api/app/example',
})
const getPropertyExampleValue = (
property: DesignerPropertyInfo,
currentValue: unknown,
translate: TranslateFn,
): 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]
const exampleValues = getPropertyExampleValues(translate)
if (exampleValues[property.name] !== undefined) {
return exampleValues[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: translate('::App.DeveloperKit.ComponentDesigner.Example.Label'), value: 'example' },
]
}
if (property.type === 'object') {
return property.name === 'style' ? { padding: 16, borderRadius: 8 } : { key: 'value' }
}
return translate('::App.DeveloperKit.ComponentDesigner.Example.Value')
}
const getPropertySnippet = (
property: DesignerPropertyInfo,
currentValue: unknown,
translate: TranslateFn,
) => {
const exampleValue = getPropertyExampleValue(property, currentValue, translate)
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, translate: TranslateFn) => {
if (!axios.isAxiosError(error)) {
return error instanceof Error
? error.message
: translate('::App.DeveloperKit.ComponentDesigner.SaveFailed')
}
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 ||
translate('::App.DeveloperKit.ComponentDesigner.SaveFailed')
)
}
const isRunnableDataSourceUrl = (url: string) => {
const normalizedUrl = url.trim().replace(/\/+$/, '')
return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app'
}
const TOOLBOX_GROUP_LABELS: Record<DesignerComponentDefinition['toolboxGroup'], string> = {
layout: 'Layouts',
html: 'HTML Elements',
platform: 'Platform Views',
data: 'Data',
ui: 'Sozsoft UI',
custom: 'My Components',
}
const TOOLBOX_GROUP_ORDER: DesignerComponentDefinition['toolboxGroup'][] = [
'layout',
'html',
'platform',
'data',
'ui',
'custom',
]
const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = {
responsive: '100%',
desktop: '1180px',
tablet: '768px',
mobile: '390px',
}
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
const normalizeTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const updateNodeTree = (
nodes: DesignerNode[],
id: string,
updater: (node: DesignerNode) => DesignerNode,
): DesignerNode[] =>
nodes.map((node) => {
if (node.id === id) return updater(node)
return { ...node, children: updateNodeTree(node.children, id, updater) }
})
const removeNodeTree = (nodes: DesignerNode[], id: string): DesignerNode[] =>
nodes
.filter((node) => node.id !== id)
.map((node) => ({
...node,
children: removeNodeTree(node.children, id),
}))
const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] =>
nodes.map((node) => ({
...node,
bindings: Object.fromEntries(
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
),
children: removeDataSourceBindings(node.children, sourceId),
}))
const insertNodeTree = (
nodes: DesignerNode[],
nodeToInsert: DesignerNode,
parentId: string | null,
): DesignerNode[] => {
if (!parentId) return [...nodes, nodeToInsert]
return updateNodeTree(nodes, parentId, (node) => ({
...node,
children: [...node.children, nodeToInsert],
}))
}
const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
const result: DesignerNode[] = []
nodes.forEach((node) => {
result.push({ ...node, children: duplicateInTree(node.children, id) })
if (node.id === id) {
const copy = clone(node)
const renewIds = (item: DesignerNode) => {
item.id = createDesignerId()
item.children.forEach(renewIds)
}
renewIds(copy)
result.push(copy)
}
})
return result
}
const insertRelativeToNode = (
nodes: DesignerNode[],
targetId: string,
nodeToInsert: DesignerNode,
placement: 'before' | 'after',
): DesignerNode[] => {
const targetIndex = nodes.findIndex((node) => node.id === targetId)
if (targetIndex >= 0) {
const next = [...nodes]
next.splice(targetIndex + (placement === 'after' ? 1 : 0), 0, nodeToInsert)
return next
}
return nodes.map((node) => ({
...node,
children: insertRelativeToNode(node.children, targetId, nodeToInsert, placement),
}))
}
/**
* Moves a node one step up/down. Swapping with a sibling only works while the
* node has one, so a node that sits alone in a container (or at the edge of one)
* is lifted out and placed next to its parent instead — otherwise the arrows
* would silently do nothing, which is how they used to behave.
*/
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
const ancestors = findDesignerAncestors(nodes, id)
if (!ancestors) return nodes
const parent = ancestors[ancestors.length - 1]
const siblings = parent ? parent.children : nodes
const index = siblings.findIndex((node) => node.id === id)
if (index < 0) return nodes
// Tabs children live in separate tabs, so only components sharing a tab are
// neighbours — otherwise the arrows would swap with something not on screen.
const group =
parent?.type === 'Tabs'
? siblings.filter((node) => (node.slot || '') === (siblings[index].slot || ''))
: siblings
const groupIndex = group.findIndex((node) => node.id === id)
const targetNode = group[groupIndex + direction]
if (targetNode) {
const target = siblings.findIndex((node) => node.id === targetNode.id)
const next = [...siblings]
;[next[index], next[target]] = [next[target], next[index]]
// In a Table the visible cell comes from `slot`, not from array order, so the
// slots have to stay with the position for the swap to be visible.
if (parent?.type === 'Table') {
next[index] = { ...next[index], slot: siblings[index].slot }
next[target] = { ...next[target], slot: siblings[target].slot }
}
return parent ? updateNodeTree(nodes, parent.id, (node) => ({ ...node, children: next })) : next
}
// Already first/last: at the root there is nowhere left to go.
if (!parent) return nodes
const movingNode = { ...siblings[index], slot: undefined }
const withoutNode = updateNodeTree(nodes, parent.id, (node) => ({
...node,
children: node.children.filter((child) => child.id !== id),
}))
return insertRelativeToNode(
withoutNode,
parent.id,
movingNode,
direction === -1 ? 'before' : 'after',
)
}
/**
* Table cells are addressed by `slot`, and children without one fall back to
* their positional index. Materialising the effective slots keeps every existing
* cell in place while a node is added to or removed from the table.
*/
const withResolvedTableSlots = (table: DesignerNode): DesignerNode => {
const columnCount = normalizeTableDimension(table.props.columnCount, 3)
return {
...table,
children: table.children.map((child, childIndex) => ({
...child,
slot:
child.slot || `table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`,
})),
}
}
const resolveAllTableSlots = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const resolved = { ...node, children: resolveAllTableSlots(node.children) }
return resolved.type === 'Table' ? withResolvedTableSlots(resolved) : resolved
})
/**
* Moves an existing node into a container (or to the root when `parentId` is
* null). Used by drag & drop so a component can leave a Table cell and be put
* back into one.
*/
const moveNodeIntoContainer = (
nodes: DesignerNode[],
nodeId: string,
parentId: string | null,
slot?: string,
): DesignerNode[] => {
const source = findDesignerNode(nodes, nodeId)
if (!source || parentId === nodeId) return nodes
// Dropping a node inside its own subtree would detach that subtree.
if (parentId && findDesignerNode(source.children, parentId)) return nodes
// Pin every table cell before the tree changes, so removing the node does not
// shift the siblings that still rely on their positional fallback.
const pinned = resolveAllTableSlots(nodes)
const movingNode = { ...(findDesignerNode(pinned, nodeId) || source), slot }
const remaining = removeNodeTree(pinned, nodeId)
if (!parentId) return [...remaining, movingNode]
return updateNodeTree(remaining, parentId, (parent) => ({
...parent,
children: [...parent.children, movingNode],
}))
}
const reorderNodeTree = (
nodes: DesignerNode[],
sourceId: string,
targetId: string,
placement: 'before' | 'after',
): DesignerNode[] => {
const source = findDesignerNode(nodes, sourceId)
const target = findDesignerNode(nodes, targetId)
if (!source || !target || sourceId === targetId || findDesignerNode(source.children, targetId)) {
return nodes
}
const movingNode = { ...source, slot: target.slot }
return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement)
}
const definitionToNode = (definition: DesignerComponentDefinition): DesignerNode => {
const props: Record<string, unknown> = {}
const events: Record<string, string> = {}
definition.properties.forEach((property) => {
if (property.category === 'events' || property.type === 'function') {
events[property.name] = typeof property.value === 'string' ? property.value : ''
} else {
props[property.name] = clone(property.value)
}
})
return {
id: createDesignerId(),
type: definition.name,
kind: definition.kind,
props,
events,
bindings: {},
children: [],
}
}
const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => {
const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const props = { ...(node.props || {}) }
const events = { ...(node.events || {}) }
const bindings = { ...(node.bindings || {}) }
if (node.type === 'Select') {
const legacyPropAliases: Record<string, string> = {
clearable: 'isClearable',
disabled: 'isDisabled',
multiple: 'isMulti',
searchable: 'isSearchable',
}
Object.entries(legacyPropAliases).forEach(([legacyName, runtimeName]) => {
if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
props[runtimeName] = props[legacyName]
}
delete props[legacyName]
})
if (events.onInputChange === undefined && events.onSearch !== undefined) {
events.onInputChange = events.onSearch
}
delete events.onSearch
}
if (node.type === 'Menu') {
if (!Array.isArray(props.items) && Array.isArray(props.options)) {
props.items = props.options
}
if (!bindings.items && bindings.options) bindings.items = bindings.options
delete props.options
delete props.variant
delete bindings.options
}
if (node.type === 'Table') {
const legacyRows = Array.isArray(props.items)
? props.items
: Array.isArray(props.data)
? props.data
: []
const firstLegacyRow = legacyRows.find(
(item) => item && typeof item === 'object' && !Array.isArray(item),
) as Record<string, unknown> | undefined
const legacyColumns = Array.isArray(props.dataColumns)
? props.dataColumns
: Array.isArray(props.columns)
? props.columns
: firstLegacyRow
? Object.keys(firstLegacyRow)
: []
props.rowCount = normalizeTableDimension(props.rowCount, legacyRows.length || 2)
props.columnCount = normalizeTableDimension(props.columnCount, legacyColumns.length || 3)
delete props.data
delete props.columns
delete props.items
delete props.dataColumns
delete bindings.data
delete bindings.items
}
let children = normalizeNodes(node.children || [])
if (node.type === 'Table') {
const columnCount = normalizeTableDimension(props.columnCount, 3)
children = children.map((child, index) => ({
...child,
slot: child.slot || `table:${Math.floor(index / columnCount)}:${index % columnCount}`,
}))
}
return {
...node,
props,
events,
bindings,
children,
}
})
return {
...document,
// Documents saved before refs existed get theirs here, so every component is
// addressable from a script the moment it is opened.
nodes: withDesignerRefs(normalizeNodes(document.nodes)),
canvas: { width: document.canvas?.width || 'responsive' },
// Documents saved before non-GET sources existed have no usable method value.
dataSources: (Array.isArray(document.dataSources) ? document.dataSources : []).map(
(source) => ({ ...source, method: toDesignerHttpMethod(source.method) }),
),
}
}
const parseDesignerDocument = (rawProps: string | undefined): DesignerDocument | null => {
if (!rawProps) return null
try {
const parsed = JSON.parse(rawProps)
const document = parsed?.visualDesigner
if (document?.version === 1 && Array.isArray(document.nodes)) {
return normalizeDesignerDocument(document)
}
} catch {
return null
}
return null
}
const parseDesignerDocumentFromCode = (code: string | undefined): DesignerDocument | null => {
const encodedDocument = code?.match(/\/\*__SOZSOFT_VISUAL_DESIGNER__(.*?)__\*\//)?.[1]
if (!encodedDocument) return null
try {
const document = JSON.parse(decodeURIComponent(encodedDocument)) as DesignerDocument
return document?.version === 1 && Array.isArray(document.nodes)
? normalizeDesignerDocument(document)
: null
} catch {
return null
}
}
const parseComponentDefaultProps = (code: string): Record<string, unknown> => {
const defaults: Record<string, unknown> = {}
const parameters = code.match(
/(?:const\s+[A-Za-z_$][\w$]*\s*(?::[^=]+)?=\s*|function\s+[A-Za-z_$][\w$]*\s*)\(\s*\{([\s\S]*?)\}\s*\)/,
)?.[1]
if (!parameters) return defaults
for (const match of parameters.matchAll(
/([A-Za-z_$][\w$]*)\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|true|false|null|-?\d+(?:\.\d+)?)/g,
)) {
const rawValue = match[2]
try {
defaults[match[1]] = rawValue.startsWith("'")
? rawValue.slice(1, -1).replace(/\\'/g, "'")
: JSON.parse(rawValue)
} catch {
defaults[match[1]] = rawValue
}
}
return defaults
}
const parseGeneratedJsxProps = (
source: string,
expressionValues: Record<string, unknown> = {},
): Record<string, unknown> => {
const props: Record<string, unknown> = {}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)="((?:\\.|[^"])*)"/g)) {
try {
props[match[1]] = JSON.parse(`"${match[2]}"`)
} catch {
props[match[1]] = match[2]
}
}
for (const match of source.matchAll(
/([A-Za-z_$][\w$]*)=\{(true|false|null|-?\d+(?:\.\d+)?)\}/g,
)) {
props[match[1]] =
match[2] === 'true'
? true
: match[2] === 'false'
? false
: match[2] === 'null'
? null
: Number(match[2])
}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)=\{([A-Za-z_$][\w$]*)\}/g)) {
if (Object.prototype.hasOwnProperty.call(expressionValues, match[2])) {
props[match[1]] = expressionValues[match[2]]
}
}
return props
}
const migrateGeneratedCodeToDesignerDocument = (
code: string | undefined,
definitions: Map<string, DesignerComponentDefinition>,
): DesignerDocument | null => {
if (!code || !code.includes('export default')) return null
const document = createEmptyDesignerDocument('visual')
const componentDefaultProps = parseComponentDefaultProps(code)
const positionedNodes: Array<{ index: number; node: DesignerNode }> = []
const addNode = (
index: number,
type: string,
kind: DesignerNode['kind'],
props: Record<string, unknown> = {},
bindings: DesignerNode['bindings'] = {},
id = createDesignerId(),
) =>
positionedNodes.push({
index,
node: { id, type, kind, props, bindings, events: {}, children: [] },
})
for (const stateMatch of code.matchAll(
/const \[data_([A-Za-z0-9_$]+), setData_\1\] = React\.useState\(null\)([\s\S]*?)(?=\n\s*const \[data_|\n\s*const [A-Za-z_$][\w$]* = \(\{|$)/g,
)) {
const sourceId = stateMatch[1]
const block = stateMatch[2]
const urlMatch = block.match(/fetchData\(\{\s*url:\s*("(?:\\.|[^"])*")/)
if (!urlMatch) continue
const responsePathMatch = block.match(
new RegExp(`setData_${sourceId}\\(getByPath\\(response\\.data,\\s*("(?:\\\\.|[^"])*")`),
)
document.dataSources.push({
id: sourceId,
name: sourceId.replace(/^source_/, '').replace(/_/g, ' '),
method: 'GET',
url: JSON.parse(urlMatch[1]),
responsePath: responsePathMatch ? JSON.parse(responsePathMatch[1]) : '',
})
}
for (const match of code.matchAll(
/<UiKit\.(Select|AutoComplete|Menu|Pagination)\b([^>]*?)(?:\/>|>)/g,
)) {
const type = match[1]
const attributes = match[2]
const props = parseGeneratedJsxProps(attributes, componentDefaultProps)
const bindings: DesignerNode['bindings'] = {}
if (type === 'Select' || type === 'AutoComplete' || type === 'Menu') {
const optionsMatch = attributes.match(
/options=\{toSelectOptions\(getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\),\s*("(?:\\.|[^"])*"),\s*("(?:\\.|[^"])*")\)\}/,
)
if (optionsMatch) {
bindings.options = {
sourceId: optionsMatch[1],
path: JSON.parse(optionsMatch[2]),
labelPath: JSON.parse(optionsMatch[3]),
valuePath: JSON.parse(optionsMatch[4]),
}
}
}
addNode(match.index, type, 'ui', props, bindings)
}
for (const match of code.matchAll(
/const tableItems_([A-Za-z0-9_$]+)\s*=\s*(\[[^\n]*\])[\s\S]*?const tableColumns_\1\s*=\s*(\[[^\n]*\])[\s\S]*?<UiKit\.Table\b([^>]*)>/g,
)) {
try {
const legacyItems = JSON.parse(match[2]) as unknown[]
const legacyColumns = JSON.parse(match[3]) as unknown[]
addNode(
match.index,
'Table',
'ui',
{
...parseGeneratedJsxProps(match[4], componentDefaultProps),
rowCount: normalizeTableDimension(legacyItems.length, 2),
columnCount: normalizeTableDimension(legacyColumns.length, 3),
},
{},
match[1],
)
} catch {
// Ignore a table block that is not in the generated designer format.
}
}
for (const match of code.matchAll(
/const gridItems_([A-Za-z0-9_$]+)\s*=\s*([^\n]+)[\s\S]*?const gridColumns_\1\s*=\s*(\[[^\n]*\])/g,
)) {
const bindingMatch = match[2].match(/getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\)/)
const bindings: DesignerNode['bindings'] = {}
if (bindingMatch) {
bindings.items = { sourceId: bindingMatch[1], path: JSON.parse(bindingMatch[2]) }
}
try {
addNode(
match.index,
'Grid',
'ui',
{
className:
code
.slice(Math.max(0, match.index - 250), match.index)
.match(/<div className="([^"]*)"[^>]*>\s*\{\(\(\) => \{$/)?.[1] || '',
dataColumns: JSON.parse(match[3]),
compact: !code.slice(match.index, match.index + 5000).includes('px-3 py-2'),
hoverable: code.slice(match.index, match.index + 5000).includes('hover:bg-'),
overflow: code.slice(match.index, match.index + 5000).includes('overflow-auto'),
borderlessRow: !code.slice(match.index, match.index + 5000).includes('border-b'),
},
bindings,
match[1],
)
} catch {
// Ignore a grid block that is not in the generated designer format.
}
}
for (const match of code.matchAll(/<PlatformViewHost\b([^>]*?)\/>/g)) {
const props = parseGeneratedJsxProps(match[1], componentDefaultProps)
const view = String(props.view || '')
const platformTypes: Record<string, string> = {
List: 'ListView',
Edit: 'EditView',
Gantt: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
Pivot: 'PivotView',
Chart: 'ChartView',
}
addNode(match.index, platformTypes[view] || view || 'ListView', 'platform', props)
}
for (const match of code.matchAll(/<img\b([^>]*?)\/>/g)) {
addNode(match.index, 'img', 'html', parseGeneratedJsxProps(match[1], componentDefaultProps))
}
const ignoredCustomTags = new Set(['UiKit', 'PlatformViewHost', 'React', 'Fragment'])
for (const match of code.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b([^>]*?)\/>/g)) {
if (ignoredCustomTags.has(match[1]) || match[1].startsWith('UiKit')) continue
const definition = definitions.get(match[1])
addNode(
match.index,
match[1],
definition?.kind || 'custom',
parseGeneratedJsxProps(match[2], componentDefaultProps),
)
}
document.nodes = positionedNodes
.sort((left, right) => left.index - right.index)
.map(({ node }) => node)
return document.nodes.length ? document : null
}
/**
* Array-of-object properties otherwise fall back to a raw JSON textarea, which is
* unusable for anything but trivial values. Components listed here get a proper
* row editor instead, keyed by `<component>.<property>`.
*/
const OBJECT_LIST_PROPERTIES: Record<
string,
{ itemLabel: string; fields: Array<{ name: string; label: string; placeholder?: string }> }
> = {
'ImageViewer.images': {
itemLabel: 'App.DeveloperKit.ComponentDesigner.ImageList.Item',
fields: [
{
name: 'src',
label: 'App.DeveloperKit.ComponentDesigner.ImageList.Src',
placeholder: 'https://…',
},
{
name: 'thumbnail',
label: 'App.DeveloperKit.ComponentDesigner.ImageList.Thumbnail',
placeholder: 'App.DeveloperKit.ComponentDesigner.ImageList.ThumbnailPlaceholder',
},
{
name: 'alt',
label: 'App.DeveloperKit.ComponentDesigner.ImageList.Alt',
placeholder: 'App.DeveloperKit.ComponentDesigner.ImageList.AltPlaceholder',
},
{
name: 'caption',
label: 'App.DeveloperKit.ComponentDesigner.ImageList.Caption',
placeholder: 'App.DeveloperKit.ComponentDesigner.ImageList.CaptionPlaceholder',
},
],
},
}
const ObjectListEditor = ({
schema,
value,
onChange,
}: {
schema: (typeof OBJECT_LIST_PROPERTIES)[string]
value: unknown
onChange: (value: unknown) => void
}) => {
const { translate } = useLocalization()
const items = Array.isArray(value) ? (value as Array<Record<string, unknown>>) : []
const inputClass =
'w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
const replaceItem = (index: number, next: Record<string, unknown>) =>
onChange(items.map((item, itemIndex) => (itemIndex === index ? next : item)))
const move = (index: number, direction: -1 | 1) => {
const target = index + direction
if (target < 0 || target >= items.length) return
const next = [...items]
;[next[index], next[target]] = [next[target], next[index]]
onChange(next)
}
return (
<div className="space-y-2">
{items.map((item, index) => (
<div
key={index}
className="space-y-1.5 rounded-md border border-slate-200 p-2 dark:border-slate-700"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-slate-500">
{translate('::' + schema.itemLabel)} {index + 1}
</span>
<div className="flex items-center gap-0.5">
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === 0}
title={translate('::App.Platform.MoveUp')}
type="button"
onClick={() => move(index, -1)}
>
<FaArrowUp className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === items.length - 1}
title={translate('::App.Platform.MoveDown')}
type="button"
onClick={() => move(index, 1)}
>
<FaArrowDown className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-red-600"
title={translate('::App.Platform.Delete')}
type="button"
onClick={() => onChange(items.filter((_, i) => i !== index))}
>
<FaTrash className="h-2.5 w-2.5" />
</button>
</div>
</div>
{item.src ? (
<img
alt=""
className="h-16 w-full rounded border border-slate-200 object-cover dark:border-slate-700"
src={String(item.thumbnail || item.src)}
/>
) : null}
{schema.fields.map((field) => (
<label key={field.name} className="block">
<span className="mb-0.5 block text-[9px] font-medium uppercase tracking-wide text-slate-400">
{translate('::' + field.label)}
</span>
<input
className={inputClass}
placeholder={
field.placeholder?.startsWith('App.')
? translate('::' + field.placeholder)
: field.placeholder
}
type="text"
value={String(item[field.name] ?? '')}
onChange={(event) =>
replaceItem(index, { ...item, [field.name]: event.target.value })
}
/>
</label>
))}
</div>
))}
<button
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700"
type="button"
onClick={() => onChange([...items, { src: '' }])}
>
<FaPlus /> {schema.itemLabel} ekle
</button>
</div>
)
}
const PropertyEditor = ({
componentType,
name,
type,
value,
options,
onChange,
}: {
componentType?: string
name: string
type: string
value: unknown
options?: string[]
onChange: (value: unknown) => void
}) => {
const { translate } = useLocalization()
const [styleModalOpen, setStyleModalOpen] = useState(false)
const inputClass =
'w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs text-slate-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
if (type === 'boolean') {
return (
<label className="flex cursor-pointer items-center justify-between rounded-md border border-slate-200 px-2.5 py-2 dark:border-slate-700">
<span className="text-xs text-slate-600 dark:text-slate-300">
{value ? 'Enabled' : 'Disabled'}
</span>
<input
checked={Boolean(value)}
className="h-4 w-4 accent-sky-600"
type="checkbox"
onChange={(event) => onChange(event.target.checked)}
/>
</label>
)
}
if (type === 'select' && options) {
const currentValue = String(value ?? '')
return (
<select
className={inputClass}
value={currentValue}
onChange={(event) => onChange(event.target.value)}
>
{/* An empty value is not emitted, so the component keeps its own or its
container's default — spell that out instead of showing a blank row. */}
{!options.includes(currentValue) && (
<option value="">{translate('::App.StaticLookup.Default')}</option>
)}
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
}
if (type === 'object' || type === 'array') {
return (
<textarea
className={`${inputClass} min-h-20 font-mono`}
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
onChange={(event) => {
try {
onChange(JSON.parse(event.target.value))
} catch {
onChange(event.target.value)
}
}}
/>
)
}
const normalizedPropertyName = name.toLowerCase()
const isCssProperty = type === 'string' && normalizedPropertyName.includes('css')
const isClassProperty =
type === 'string' &&
(normalizedPropertyName === 'class' ||
normalizedPropertyName.endsWith('class') ||
normalizedPropertyName.endsWith('classname'))
if (isClassProperty || isCssProperty) {
const currentClasses = String(value ?? '')
return (
<>
<div className="flex gap-2">
{isCssProperty ? (
<textarea
className={`${inputClass} min-h-20 font-mono`}
placeholder={translate('::App.DeveloperKit.ComponentDesigner.CssPlaceholder')}
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<input
className={`${inputClass} font-mono`}
placeholder={translate('::App.DeveloperKit.ComponentDesigner.ClassPlaceholder')}
type="text"
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
)}
<Button
icon={<FaBolt />}
size="sm"
title={translate('::App.DeveloperKit.ComponentDesigner.EditWithStyleModal', { name })}
variant="default"
onClick={() => setStyleModalOpen(true)}
/>
</div>
<StyleModal
isOpen={styleModalOpen}
mode={isCssProperty ? 'css' : 'class'}
propertyName={name}
value={currentClasses}
onChange={onChange}
onClose={() => setStyleModalOpen(false)}
/>
</>
)
}
const multiline = name === 'children' || name === 'html' || String(value ?? '').length > 80
if (multiline) {
return (
<textarea
className={`${inputClass} min-h-20`}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
/>
)
}
return (
<input
className={inputClass}
placeholder={type === 'string' ? 'Metin veya ::LanguageKey' : undefined}
type={type === 'number' ? 'number' : name.toLowerCase().includes('color') ? 'color' : 'text'}
value={String(value ?? '')}
onChange={(event) =>
onChange(type === 'number' ? Number(event.target.value) : event.target.value)
}
/>
)
}
const VisualComponentDesigner = () => {
const { id } = useParams()
const { translate } = useLocalization()
const { components, updateComponent, renderComponent } = useComponents()
const [componentDetails, setComponentDetails] = useState<CustomComponent | null>(null)
const [componentLoadError, setComponentLoadError] = useState('')
const [loadedId, setLoadedId] = useState<string | null>(null)
const [document, setDocument] = useState<DesignerDocument>(() => createEmptyDesignerDocument())
const [manualCode, setManualCode] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('design')
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('properties')
// Editing buffer of the selected component's ref; committed on blur/Enter so a
// half typed name never lands in the document.
const [refDraft, setRefDraft] = useState('')
const [refError, setRefError] = useState('')
const [scriptApiOpen, setScriptApiOpen] = useState(false)
const [scriptApiSearch, setScriptApiSearch] = useState('')
/** Last focused event editor, so a Script API example lands in the right one. */
const [focusedEventName, setFocusedEventName] = useState('')
const [search, setSearch] = useState('')
const [expandedToolboxGroups, setExpandedToolboxGroups] = useState<
Record<DesignerComponentDefinition['toolboxGroup'], boolean>
>({ layout: true, html: true, platform: true, data: true, ui: true, custom: true })
const [isSaving, setIsSaving] = useState(false)
const [savedCode, setSavedCode] = useState('')
const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('')
const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({})
const [dataSourceSamples, setDataSourceSamples] = useState<Record<string, unknown>>({})
const [dataPanelSourceId, setDataPanelSourceId] = useState('')
const [selectDataModes, setSelectDataModes] = useState<Record<string, 'static' | 'endpoint'>>({})
const [staticCollectionDrafts, setStaticCollectionDrafts] = useState<Record<string, string>>({})
const [staticCollectionErrors, setStaticCollectionErrors] = useState<Record<string, string>>({})
const [generatedEndpoints, setGeneratedEndpoints] = useState<CrudEndpoint[]>([])
const [endpointCatalogLoading, setEndpointCatalogLoading] = useState(true)
const [endpointCatalogError, setEndpointCatalogError] = useState('')
const [catalogSourceEditor, setCatalogSourceEditor] = useState<{
item: DataSourceCatalogItem | null
draft: DesignerDataSource
} | null>(null)
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>(
null,
)
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
const activeDrag = useDesignerDrag()
/** Kural tabanlı script builder'ın düzenlediği event. */
const [scriptBuilderTarget, setScriptBuilderTarget] = useState<{
nodeId: string
eventName: string
} | null>(null)
const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
const undoStack = useRef<DesignerDocument[]>([])
const redoStack = useRef<DesignerDocument[]>([])
const component = componentDetails?.id === id ? componentDetails : undefined
const name = component?.name || 'VisualComponent'
// The tab shows what is being edited: the human readable description when the
// component carries one, the technical name otherwise.
const pageTitle =
component?.description?.trim() || component?.name || translate('::App.DeveloperKit.Components')
const customNames = useMemo(
() => components.filter((item) => item.id !== id && item.isActive).map((item) => item.name),
[components, id],
)
const catalog = useMemo(() => getDesignerCatalog(customNames), [customNames])
const catalogByName = useMemo(() => new Map(catalog.map((item) => [item.name, item])), [catalog])
const selectDesignerNode = useCallback((nodeId: string | null) => {
setSelectedId(nodeId || null)
setInspectorTab('properties')
}, [])
useEffect(() => {
let cancelled = false
setEndpointCatalogLoading(true)
setEndpointCatalogError('')
developerKitService
.getGeneratedListEndpoints()
.then((result) => {
if (!cancelled) setGeneratedEndpoints(result.items || [])
})
.catch((error) => {
if (!cancelled) setEndpointCatalogError(getSaveErrorMessage(error, translate))
})
.finally(() => {
if (!cancelled) setEndpointCatalogLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
setComponentDetails(null)
setComponentLoadError('')
setLoadedId(null)
setSelectedId(null)
if (!id) return () => undefined
const loadComponentDetails = async () => {
try {
const currentComponent = await developerKitService.getCustomComponent(id)
if (!cancelled) setComponentDetails(currentComponent)
} catch (error) {
if (cancelled) return
setComponentLoadError(getSaveErrorMessage(error, translate))
}
}
void loadComponentDetails()
return () => {
cancelled = true
}
}, [id])
useEffect(() => {
if (!id || !component || loadedId === id) return
const propsDocument = parseDesignerDocument(component.props)
const hasStoredDesignerNodes = Boolean(propsDocument?.nodes.length)
const codeBackupDocument = hasStoredDesignerNodes
? null
: parseDesignerDocumentFromCode(component.code)
const migratedCodeDocument =
hasStoredDesignerNodes || codeBackupDocument
? null
: migrateGeneratedCodeToDesignerDocument(component.code, catalogByName)
const visualDocument = hasStoredDesignerNodes
? propsDocument
: codeBackupDocument || migratedCodeDocument || propsDocument
const initialDocument = visualDocument || createEmptyDesignerDocument('code')
setDocument(initialDocument)
setDataTestResults({})
setDataSourceSamples({})
setManualCode(component.code || '')
setSavedCode(component.code || '')
setSavedDesignerFingerprint(JSON.stringify(initialDocument))
setWorkspaceTab('design')
setLoadedId(id)
undoStack.current = []
redoStack.current = []
}, [catalogByName, component, id, loadedId])
const generatedCode = useMemo(() => generateDesignerCode(name, document), [document, name])
const effectiveCode = document.sourceMode === 'visual' ? generatedCode : manualCode
const isDirty =
effectiveCode.trim() !== savedCode.trim() ||
JSON.stringify(document) !== savedDesignerFingerprint
const commitDocument = useCallback((updater: (current: DesignerDocument) => DesignerDocument) => {
setDocument((current) => {
undoStack.current.push(clone(current))
if (undoStack.current.length > 80) undoStack.current.shift()
redoStack.current = []
const next = updater(current)
// Single choke point for ref assignment: whichever way a node reaches the
// document — toolbox drop, duplicate, drag or paste — it leaves here with a
// unique handle that the event scripts can address.
return { ...next, nodes: withDesignerRefs(next.nodes) }
})
}, [])
const undo = useCallback(() => {
const previous = undoStack.current.pop()
if (!previous) return
redoStack.current.push(clone(document))
setDocument(previous)
}, [document])
const redo = useCallback(() => {
const next = redoStack.current.pop()
if (!next) return
undoStack.current.push(clone(document))
setDocument(next)
}, [document])
const addComponent = useCallback(
(definitionName: string, parentId: string | null = null, slot?: string) => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
if (slot) node.slot = slot
commitDocument((current) => ({
...current,
sourceMode: 'visual',
nodes: insertNodeTree(current.nodes, node, parentId),
}))
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
)
/**
* Toolbox drop next to an existing component rather than inside it — the only
* way to place something beside a node that cannot host children.
*/
const addComponentBeside = useCallback(
(definitionName: string, targetId: string, placement: 'before' | 'after') => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
commitDocument((current) => {
// Table cells and tab panes address their children by slot, so the new
// node has to join the one it was dropped next to.
const target = findDesignerNode(current.nodes, targetId)
if (target?.slot) node.slot = target.slot
return {
...current,
sourceMode: 'visual',
nodes: insertRelativeToNode(current.nodes, targetId, node, placement),
}
})
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
)
const selectedNode = useMemo(
() => findDesignerNode(document.nodes, selectedId),
[document.nodes, selectedId],
)
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
/** Every addressable component of the page, keyed by ref. */
const documentRefs = useMemo(() => collectDesignerRefs(document.nodes), [document.nodes])
useEffect(() => {
setRefDraft(selectedNode?.ref || '')
setRefError('')
}, [selectedNode?.id, selectedNode?.ref])
/**
* Renames the selected component's handle and rewrites the scripts that use
* it, so `refs.button1` does not silently stop resolving once the ref is given
* a meaningful name.
*/
const commitRefRename = () => {
if (!selectedNode) return
const previous = selectedNode.ref || ''
const next = refDraft.trim()
if (!next || next === previous) {
setRefDraft(previous)
setRefError('')
return
}
if (!isValidDesignerRef(next)) {
setRefError(translate('::App.DeveloperKit.ComponentDesigner.RefInvalid'))
return
}
if (documentRefs.has(next)) {
setRefError(translate('::App.DeveloperKit.ComponentDesigner.RefTaken'))
return
}
setRefError('')
commitDocument((current) => ({
...current,
lifecycle: { onMount: renameDesignerRefInScript(current.lifecycle.onMount, previous, next) },
nodes: renameDesignerRefInNodes(
updateNodeTree(current.nodes, selectedNode.id, (node) => ({ ...node, ref: next })),
previous,
next,
),
}))
}
const copyToClipboard = (text: string, message: string) => {
void navigator.clipboard?.writeText(text)
toast.push(
<Notification type="success" duration={1800}>
{message}
</Notification>,
{ placement: 'bottom-end' },
)
}
const copyRefName = (ref: string) =>
copyToClipboard(
`refs.${ref}`,
translate('::App.DeveloperKit.ComponentDesigner.RefCopied', { ref: `refs.${ref}` }),
)
/**
* Fills the `{{ref}}` / `{{sql}}` / `{{url}}` placeholders of a Script API
* example with names that actually exist on this page, so a copied example
* runs without being edited first.
*/
const resolveRecipeCode = useCallback(
(code: string) => {
const entries = [...documentRefs.entries()]
const sqlRef = entries.find(([, node]) => isSqlDataSourceNode(node.type))?.[0]
const selectedRef =
selectedNode && !isSqlDataSourceNode(selectedNode.type) ? selectedNode.ref : undefined
const componentRef =
selectedRef ||
entries.find(([, node]) => !isSqlDataSourceNode(node.type) && node.kind === 'ui')?.[0] ||
entries.find(([, node]) => !isSqlDataSourceNode(node.type))?.[0]
const url = document.dataSources.find((source) => source.method === 'GET')?.url
return code
.replace(/\{\{ref\}\}/g, componentRef || 'button1')
.replace(/\{\{sql\}\}/g, sqlRef || 'sqlDataSource1')
.replace(/\{\{url\}\}/g, url || '/api/app/orders')
},
[document.dataSources, documentRefs, selectedNode],
)
const filteredScriptRecipes = useMemo(() => {
const query = scriptApiSearch.trim().toLocaleLowerCase('tr')
if (!query) return DESIGNER_SCRIPT_RECIPES
return DESIGNER_SCRIPT_RECIPES.filter((recipe) =>
`${translate('::' + recipe.group)} ${translate('::' + recipe.title)} ${translate(
'::' + recipe.description,
)} ${recipe.code}`
.toLocaleLowerCase('tr')
.includes(query),
)
}, [scriptApiSearch, translate])
/**
* Record of every SqlDataSource on the canvas, derived from the tested Select
* endpoint. Children bind to it through the container's node id, so it is
* merged into the canvas data values and into the binding inspector samples.
*/
const sqlDataSourceRecords = useMemo(() => {
const records: Record<string, unknown> = {}
walkDesignerNodes(document.nodes, (node) => {
if (isSqlDataSourceNode(node.type)) {
records[node.id] = getSqlDataSourceRecord(node, dataSourceSamples)
}
})
return records
}, [dataSourceSamples, document.nodes])
const previewDataValues = useMemo(
() => ({ ...dataSourceSamples, ...sqlDataSourceRecords }),
[dataSourceSamples, sqlDataSourceRecords],
)
const selectedAncestors = useMemo(
() => findDesignerAncestors(document.nodes, selectedId) || [],
[document.nodes, selectedId],
)
const selectedIsSqlDataSource = isSqlDataSourceNode(selectedNode?.type)
/** Nearest SqlDataSource above the selection; its record is bindable. */
const sqlScopeNode = useMemo(
() => [...selectedAncestors].reverse().find((node) => isSqlDataSourceNode(node.type)),
[selectedAncestors],
)
/**
* The container's record presented as a data source, so the existing endpoint
* dropdown, field discovery and binding editors work on it unchanged.
*/
const sqlScopeSource = useMemo<DesignerDataSource | null>(
() =>
sqlScopeNode
? {
id: sqlScopeNode.id,
name: translate('::App.DeveloperKit.ComponentDesigner.SqlDataSourceRecord', {
field: getSqlDataSourceKeyField(sqlScopeNode),
}),
method: 'GET',
url: '',
responsePath: '',
}
: null,
[sqlScopeNode],
)
/**
* Only a GET endpoint returns rows, so only GET can feed a property binding —
* and only one that is directly callable: a `{id}` URL has no key to fill it
* with outside a SqlDataSource, so it cannot back a Grid or a Select list.
*/
const bindableDataSources = useMemo(
() =>
document.dataSources.filter(
(source) => source.method === 'GET' && !hasSqlDataSourceUrlParams(source.url),
),
[document.dataSources],
)
/** The Select slot additionally accepts GetById, whose key comes from the URL. */
const selectSlotDataSources = useMemo(
() => document.dataSources.filter((source) => source.method === 'GET'),
[document.dataSources],
)
/**
* A collection component keeps managing its own list endpoint even inside a
* SqlDataSource — the option list of a Select and the record the form edits are
* two different things. Everything else inside the container is a record field
* and may only bind to the container's Select result.
*/
const selectedIsCollectionComponent =
isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)
const sqlScopeLocked = Boolean(sqlScopeNode) && !selectedIsCollectionComponent
const inspectorDataSources = useMemo(
() => (sqlScopeLocked ? (sqlScopeSource ? [sqlScopeSource] : []) : bindableDataSources),
[bindableDataSources, sqlScopeLocked, sqlScopeSource],
)
const selectedIsDataComponent = Boolean(
selectedNode &&
(isDataCategoryComponent(selectedDefinition) || selectedIsSqlDataSource || sqlScopeNode),
)
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
if (!selectedNode) return []
const definedProperties = selectedDefinition?.properties || []
const definedNames = new Set(definedProperties.map((property) => property.name))
const storedProperties = Object.entries(selectedNode.props)
.filter(([propertyName]) => !definedNames.has(propertyName))
.map(([propertyName, value]) => ({
name: propertyName,
type: (Array.isArray(value)
? 'array'
: value !== null && typeof value === 'object'
? 'object'
: typeof value === 'boolean'
? 'boolean'
: typeof value === 'number'
? 'number'
: 'string') as DesignerPropertyInfo['type'],
value,
category: 'properties' as const,
}))
const properties = [...definedProperties, ...storedProperties]
if (!properties.some((property) => property.name === 'className')) {
properties.push({
name: 'className',
type: 'string',
value: '',
category: 'styling',
})
}
return properties
}, [selectedDefinition, selectedNode])
const selectedBindingSourceId = selectedNode
? Object.values(selectedNode.bindings || {}).find((binding) =>
inspectorDataSources.some((source) => source.id === binding.sourceId),
)?.sourceId
: undefined
useEffect(() => {
setDataPanelSourceId((current) => {
if (selectedBindingSourceId) return selectedBindingSourceId
if (inspectorDataSources.some((source) => source.id === current)) return current
// Must fall back within the selectable set: a default the picker does not
// list leaves `activeDataSource` undefined while the dropdown still paints
// its first option, so the panel looks configured when nothing is selected.
return inspectorDataSources[0]?.id || ''
})
}, [inspectorDataSources, selectedBindingSourceId])
const dataSourceCatalog = useMemo<DataSourceCatalogItem[]>(() => {
const items = new Map<string, DataSourceCatalogItem>()
const catalogComponents = componentDetails
? [componentDetails, ...components.filter((storedComponent) => storedComponent.id !== id)]
: components
catalogComponents.forEach((storedComponent) => {
const storedDocument = parseDesignerDocument(storedComponent.props)
storedDocument?.dataSources.forEach((source) => {
const dataSourceKey = `${source.method}:${source.url.trim()}:${source.responsePath.trim()}`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(currentSource) =>
currentSource.method === source.method &&
currentSource.url.trim() === source.url.trim() &&
currentSource.responsePath.trim() === source.responsePath.trim(),
)
items.set(dataSourceKey, {
attached,
key: `component:${storedComponent.id}:${source.id}`,
origin: `${storedComponent.name} componenti`,
originType: 'component',
ownerComponentId: storedComponent.id,
source,
})
})
})
generatedEndpoints
// Every active endpoint is offered, including GetById: its `{id}` is filled
// by the SqlDataSource from the page URL, exactly like Update and Delete.
.filter((endpoint) => endpoint.isActive)
.forEach((endpoint) => {
const method = toDesignerHttpMethod(endpoint.method)
const dataSourceKey = `${method}:${endpoint.path.trim()}:`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(source) => source.method === method && source.url.trim() === endpoint.path.trim(),
)
items.set(dataSourceKey, {
attached,
key: `generated:${endpoint.id}`,
origin: 'CRUD Endpoint Manager',
originType: 'crud',
source: {
id: `generated_${endpoint.id}`,
name: `${endpoint.entityName} · ${endpoint.operationType}`,
method,
url: endpoint.path,
responsePath: '',
},
})
})
return [...items.values()]
}, [componentDetails, components, document.dataSources, generatedEndpoints, id])
const reusableDataSources = useMemo(() => dataSourceCatalog, [dataSourceCatalog])
const updateSelectedProp = (propertyName: string, value: unknown) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
}
const updateNodeProp = useCallback(
(nodeId: string, propertyName: string, value: unknown) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
},
[commitDocument],
)
const updateNodeEvent = (nodeId: string, eventName: string, value: string) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
events: { ...node.events, [eventName]: value },
})),
}))
}
const updateSelectedBinding = (propertyName: string, sourceId: string, path = '') => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
if (sourceId) bindings[propertyName] = { sourceId, path }
else delete bindings[propertyName]
return { ...node, bindings }
}),
}))
}
const updateSelectedBindingDetails = (
propertyName: string,
details: Partial<DesignerBinding>,
) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const binding = node.bindings?.[propertyName]
if (!binding) return node
return {
...node,
bindings: {
...node.bindings,
[propertyName]: { ...binding, ...details },
},
}
}),
}))
}
const addDataSource = () => {
const dataSource: DesignerDataSource = {
id: `source_${createDesignerId().slice(-8)}`,
name: `Endpoint ${document.dataSources.length + 1}`,
method: 'GET',
url: '',
responsePath: '',
}
setCatalogSourceEditor({ item: null, draft: dataSource })
setCatalogSourceTestResult(null)
}
const addReusableDataSource = (catalogItem: DataSourceCatalogItem) => {
const existingSource = document.dataSources.find(
(source) =>
source.method === catalogItem.source.method &&
source.url.trim() === catalogItem.source.url.trim() &&
source.responsePath.trim() === catalogItem.source.responsePath.trim(),
)
// Only a directly callable GET source produces rows. A write endpoint or a
// GetById is attached to a SqlDataSource command slot from the inspector.
const bindsCollection =
catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url)
if (existingSource) {
if (!bindsCollection) {
setDataPanelSourceId(existingSource.id)
return
}
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
updateSelectedBinding('items', existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
} else if (selectedId && isOptionDataComponent(selectedNode?.type)) {
updateSelectedBinding(getOptionDataProperty(selectedNode?.type), existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(existingSource.id)
return
}
const dataSource: DesignerDataSource = {
...catalogItem.source,
id: `source_${createDesignerId().slice(-8)}`,
}
commitDocument((current) => ({
...current,
dataSources: [...current.dataSources, dataSource],
nodes:
bindsCollection &&
selectedId &&
(isTabularDataComponent(selectedNode?.type) || isOptionDataComponent(selectedNode?.type))
? updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
[isTabularDataComponent(selectedNode?.type)
? 'items'
: getOptionDataProperty(selectedNode?.type)]: {
sourceId: dataSource.id,
path: '',
},
},
}))
: current.nodes,
}))
if (
bindsCollection &&
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(dataSource.id)
}
/**
* `urlOverride` carries a URL whose `{id}` was already filled in — a GetById
* endpoint cannot be sampled otherwise, and without a sample the designer has
* no columns to offer the components inside the SqlDataSource.
*/
const testDataSource = useCallback(
async (source: DesignerDataSource, showResult = false, urlOverride?: string) => {
// Running a POST/PUT/DELETE endpoint would mutate real data, so write sources
// are never executed from the designer — they are only wired to a command slot.
if (source.method !== 'GET') {
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'error',
message: translate('::App.DeveloperKit.ComponentDesigner.MethodNotRunnableBindToSql', {
method: source.method,
}),
},
}))
return
}
if (showResult) setEndpointResultModal(null)
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'loading',
message: translate('::App.DeveloperKit.ComponentDesigner.SendingRequest'),
},
}))
const requestUrl = (urlOverride ?? source.url).trim()
try {
if (!requestUrl) {
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.EndpointUrlRequired'))
}
if (!requestUrl.startsWith('/api/')) {
throw new Error(
translate('::App.DeveloperKit.ComponentDesigner.EndpointMustStartWithApi'),
)
}
if (!isRunnableDataSourceUrl(requestUrl)) {
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.EndpointNotRunnable'))
}
if (hasSqlDataSourceUrlParams(requestUrl)) {
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.EndpointNeedsKey'))
}
const response = await apiService.fetchData({ method: source.method, url: requestUrl })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(
translate('::App.DeveloperKit.ComponentDesigner.ResponsePathNotFound', {
path: 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, translate) },
}))
}
},
[],
)
const persistCatalogOwnerDocument = useCallback(
async (item: DataSourceCatalogItem, nextDocument: DesignerDocument) => {
if (item.originType !== 'component' || !item.ownerComponentId) {
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.CatalogReadOnly'))
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
if (!owner)
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.OwnerComponentNotFound'))
let existingProps: Record<string, unknown> = {}
try {
const parsedProps = owner.props ? JSON.parse(owner.props) : {}
if (parsedProps && typeof parsedProps === 'object' && !Array.isArray(parsedProps)) {
existingProps = parsedProps
}
} catch {
existingProps = {}
}
const update = {
name: owner.name,
routePath: owner.routePath,
description: owner.description,
dependencies: owner.dependencies,
code:
nextDocument.sourceMode === 'visual'
? generateDesignerCode(owner.name, nextDocument)
: owner.code,
props: JSON.stringify({ ...existingProps, visualDesigner: nextDocument }),
isActive: owner.isActive,
}
await updateComponent(owner.id, update)
return update
},
[componentDetails, components, updateComponent],
)
const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
setCatalogSourceEditor({ item, draft: { ...item.source } })
setCatalogSourceTestResult(null)
}
const updateCatalogSourceDraft = (updates: Partial<DesignerDataSource>) => {
const sourceId = catalogSourceEditor?.draft.id
if (sourceId) {
setDataSourceSamples((samples) => {
const next = { ...samples }
delete next[sourceId]
return next
})
}
setCatalogSourceEditor((current) =>
current ? { ...current, draft: { ...current.draft, ...updates } } : current,
)
setCatalogSourceTestResult(null)
}
const testCatalogSource = async () => {
if (!catalogSourceEditor) return
const source = catalogSourceEditor.draft
if (source.method !== 'GET') {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKit.ComponentDesigner.MethodNotRunnableWrites', {
method: source.method,
}),
})
return
}
setCatalogSourceTestResult({
status: 'loading',
message: translate('::App.DeveloperKit.ComponentDesigner.SendingRequest'),
})
try {
if (!source.name.trim())
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.EndpointNameRequired'))
if (!isRunnableDataSourceUrl(source.url)) {
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.EndpointUrlInvalid'))
}
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(
translate('::App.DeveloperKit.ComponentDesigner.ResponsePathNotFound', {
path: 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, translate),
})
}
}
const saveCatalogSource = async () => {
if (!catalogSourceEditor) return
const { item, draft } = catalogSourceEditor
if (!draft.name.trim()) {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKit.ComponentDesigner.EndpointNameRequired'),
})
return
}
if (!isRunnableDataSourceUrl(draft.url)) {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKit.ComponentDesigner.EndpointUrlInvalid'),
})
return
}
// A write endpoint cannot be verified without side effects, so the successful
// run is only demanded from GET sources.
if (draft.method === 'GET' && catalogSourceTestResult?.status !== 'success') {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKit.ComponentDesigner.RunEndpointBeforeSave'),
})
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: translate('::App.DeveloperKit.ComponentDesigner.OriginComponent', {
name: componentDetails.name,
}),
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}>
{translate('::App.DeveloperKit.ComponentDesigner.EndpointAdded')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({
status: 'error',
message: getSaveErrorMessage(error, translate),
})
} 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: translate('::App.DeveloperKit.ComponentDesigner.DesignSchemaUnreadable'),
})
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}>
{translate('::App.DeveloperKit.ComponentDesigner.EndpointUpdated')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({
status: 'error',
message: getSaveErrorMessage(error, translate),
})
} 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(
translate('::App.DeveloperKit.ComponentDesigner.EndpointDeleteConfirm', {
name: item.source.name,
}),
)
)
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}>
{translate('::App.DeveloperKit.ComponentDesigner.EndpointDeleted')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
toast.push(
<Notification type="danger" duration={4000}>
{getSaveErrorMessage(error, translate)}
</Notification>,
{ placement: 'bottom-end' },
)
}
}
const deleteNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: removeNodeTree(current.nodes, nodeId) }))
setSelectedId((current) => (current === nodeId ? null : current))
},
[commitDocument],
)
const duplicateNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: duplicateInTree(current.nodes, nodeId) }))
},
[commitDocument],
)
const moveNode = useCallback(
(nodeId: string, direction: -1 | 1) => {
commitDocument((current) => ({
...current,
nodes: moveInTree(current.nodes, nodeId, direction),
}))
},
[commitDocument],
)
const moveNodeIntoContainerNode = useCallback(
(nodeId: string, parentId: string | null, slot?: string) => {
commitDocument((current) => ({
...current,
nodes: moveNodeIntoContainer(current.nodes, nodeId, parentId, slot),
}))
selectDesignerNode(nodeId)
},
[commitDocument, selectDesignerNode],
)
const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({
...current,
nodes: reorderNodeTree(current.nodes, sourceId, targetId, placement),
}))
selectDesignerNode(sourceId)
},
[commitDocument, selectDesignerNode],
)
const collectDependencies = useCallback(() => {
const names = new Set<string>()
walkDesignerNodes(document.nodes, (node) => {
if (node.kind === 'custom') names.add(node.type)
})
return [...names]
}, [document.nodes])
const handleSave = useCallback(
async (successMessage = translate('::App.DeveloperKit.ComponentDesigner.ComponentSaved')) => {
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(
translate('::App.DeveloperKit.ComponentDesigner.DataSourceMustStartWithApi', {
name: invalidDataSource.name,
}),
)
}
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, translate)
toast.push(
<Notification type="danger" duration={4000}>
{errorMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsSaving(false)
}
},
[collectDependencies, component, document, generatedCode, id, manualCode, updateComponent],
)
useEffect(() => {
const keyHandler = (event: KeyboardEvent) => {
const target = event.target
const isPropertyEditing =
target instanceof HTMLElement &&
Boolean(
target.closest(
'[data-property-panel], input, textarea, select, [contenteditable="true"], [role="textbox"], .monaco-editor',
),
)
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void handleSave()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) redo()
else undo()
}
if (event.key === 'Delete' && !isPropertyEditing && selectedId && workspaceTab === 'design') {
deleteNode(selectedId)
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
setScriptApiOpen((current) => !current)
}
if (event.key === 'Escape') setScriptApiOpen(false)
}
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} ${translate('::' + item.description)} ${item.category}`
.toLocaleLowerCase('tr')
.includes(term),
)
}, [catalog, search, translate])
const groupedCatalog = useMemo(() => {
const groups = new Map<
DesignerComponentDefinition['toolboxGroup'],
DesignerComponentDefinition[]
>(TOOLBOX_GROUP_ORDER.map((group) => [group, []]))
filteredCatalog.forEach((item) => {
groups.get(item.toolboxGroup)?.push(item)
})
return TOOLBOX_GROUP_ORDER.map(
(group) =>
[
group,
[...(groups.get(group) || [])].sort((left, right) =>
left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' }),
),
] as const,
).filter(([, definitions]) => definitions.length > 0)
}, [filteredCatalog])
const activeDataSource = inspectorDataSources.find((source) => source.id === dataPanelSourceId)
const activeDataSample = activeDataSource ? previewDataValues[activeDataSource.id] : undefined
/**
* Reads every GET endpoint that has no sample yet: the ones stored with the
* document, and any attached later from the reusable catalog. Without this a
* freshly attached endpoint shows up as selected in the inspector while its
* column list — and therefore the label/value mapping — stays empty.
* A recorded test result means it already ran, so failures do not loop.
*/
useEffect(() => {
if (!id || loadedId !== id) return
// A GetById Select is sampled through the owning SqlDataSource's preview key,
// since there is no page URL to read the real key from while designing.
const previewUrls = new Map<string, string>()
walkDesignerNodes(document.nodes, (node) => {
if (!isSqlDataSourceNode(node.type)) return
const previewKey = getSqlDataSourcePreviewKey(node)
if (!previewKey) return
const source = document.dataSources.find(
(item) => item.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
)
if (!source) return
const parameterName = getSqlDataSourceKeyParam(node)
const bound = bindSqlDataSourceUrl(source.url, { [parameterName]: previewKey }, parameterName)
previewUrls.set(
source.id,
bound.keyBound
? bound.url
: appendSqlDataSourceQueryParam(bound.url, parameterName, previewKey),
)
})
document.dataSources.forEach((source) => {
if (source.method !== 'GET') return
if (dataSourceSamples[source.id] !== undefined || dataTestResults[source.id]) return
const requestUrl = previewUrls.get(source.id) ?? source.url
// An unresolved `{id}` would just 404; it waits for a preview key instead.
if (hasSqlDataSourceUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
void testDataSource(source, false, requestUrl)
})
}, [
dataSourceSamples,
dataTestResults,
document.dataSources,
document.nodes,
id,
loadedId,
testDataSource,
])
const optionDataProperty = getOptionDataProperty(selectedNode?.type)
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
? selectedNode?.bindings?.[optionDataProperty]
: undefined
const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding)
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
const selectDataMode =
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
? selectDataModes[selectedId] ||
(selectOptionsBinding?.sourceId || tabularItemsBinding?.sourceId ? 'endpoint' : 'static')
: 'static'
const staticSelectOptions: StaticSelectOption[] =
isOptionDataComponent(selectedNode?.type) &&
Array.isArray(selectedNode?.props[optionDataProperty])
? (selectedNode.props[optionDataProperty] as unknown[]).map((option, index) => {
if (option && typeof option === 'object' && !Array.isArray(option)) {
const record = option as Record<string, unknown>
return {
label: String(
record.label ??
record.children ??
record.title ??
translate('::App.DeveloperKit.ComponentDesigner.OptionN', { index: index + 1 }),
),
value: record.value ?? record.eventKey ?? record.key ?? index + 1,
}
}
return { label: String(option ?? ''), value: option ?? index + 1 }
})
: []
const selectCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const tabularCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const selectCollectionSample = useMemo(() => {
if (activeDataSample === undefined) return undefined
const path = selectOptionsBinding?.path || ''
const value = getDesignerValueByPath(activeDataSample, path)
return Array.isArray(value) ? value : undefined
}, [activeDataSample, selectOptionsBinding?.path])
const selectColumnFields = useMemo(
() =>
selectCollectionSample === undefined
? []
: discoverDataFields(selectCollectionSample).filter(
(field) => !['array', 'object'].includes(field.type),
),
[selectCollectionSample],
)
const setSelectDataMode = (mode: 'static' | 'endpoint') => {
if (
!selectedId ||
(!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
)
return
setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
if (isTabularDataComponent(selectedNode?.type)) {
// Only a source the picker lists can be bound; a POST/PUT entry or the
// SqlDataSource record would silently produce an unusable collection.
const source = activeDataSource || inspectorDataSources[0]
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
const props = { ...node.props }
delete props.dataColumns
if (mode === 'endpoint' && source) {
bindings.items = { sourceId: source.id, path: '' }
} else {
delete bindings.items
}
return { ...node, bindings, props }
}),
}))
if (mode === 'static') {
const staticItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
setStaticCollectionDrafts((current) => ({
...current,
[selectedId]: JSON.stringify(staticItems, null, 2),
}))
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
} else if (source && dataSourceSamples[source.id] === undefined) {
void testDataSource(source)
}
return
}
if (mode === 'static') {
updateSelectedBinding(optionDataProperty, '')
return
}
const source = activeDataSource || inspectorDataSources[0]
if (source) {
updateSelectedBinding(optionDataProperty, source.id, '')
if (dataSourceSamples[source.id] === undefined) void testDataSource(source)
}
}
const updateStaticSelectOptions = (options: StaticSelectOption[]) => {
updateSelectedBinding(optionDataProperty, '')
updateSelectedProp(optionDataProperty, options)
if (selectedId) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
}
}
const updateStaticCollection = (rawValue: string) => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type)) return
setStaticCollectionDrafts((current) => ({ ...current, [selectedId]: rawValue }))
try {
const value = JSON.parse(rawValue)
if (!Array.isArray(value))
throw new Error(translate('::App.DeveloperKit.ComponentDesigner.StaticDataMustBeArray'))
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
: translate('::App.DeveloperKit.ComponentDesigner.InvalidJson'),
}))
}
}
const repeatedGridAncestor = [...selectedAncestors]
.reverse()
.find((node) => node.type === 'Grid' && node.bindings?.items?.sourceId)
const isActiveRepeatedSource =
repeatedGridAncestor?.bindings.items.sourceId === activeDataSource?.id
const activeBindingSample =
isTabularDataComponent(selectedNode?.type) && selectDataMode === 'static'
? selectedNode?.props.items
: isTabularDataComponent(selectedNode?.type) &&
tabularItemsBinding?.sourceId === activeDataSource?.id &&
activeDataSample !== undefined
? getDesignerValueByPath(activeDataSample, tabularItemsBinding?.path || '')
: activeDataSample !== undefined && isActiveRepeatedSource
? getDesignerValueByPath(
activeDataSample,
repeatedGridAncestor?.bindings.items.path || '',
)
: activeDataSample
const activeDataFields = useMemo(
() => (activeBindingSample === undefined ? [] : discoverDataFields(activeBindingSample)),
[activeBindingSample],
)
const gridColumnFields = useMemo(
() => activeDataFields.filter((field) => !['array', 'object'].includes(field.type)),
[activeDataFields],
)
const configuredGridColumns =
isTabularDataComponent(selectedNode?.type) && Array.isArray(selectedNode?.props.dataColumns)
? selectedNode.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: null
const selectedGridColumns = configuredGridColumns
? configuredGridColumns.filter((column) =>
gridColumnFields.some((field) => field.path === column),
)
: gridColumnFields.map((field) => field.path)
const allGridColumnsSelected =
gridColumnFields.length > 0 && selectedGridColumns.length === gridColumnFields.length
const toggleGridColumn = (columnPath: string) => {
const nextColumns = selectedGridColumns.includes(columnPath)
? selectedGridColumns.filter((column) => column !== columnPath)
: [...selectedGridColumns, columnPath]
updateSelectedProp('dataColumns', nextColumns)
}
const toggleAllGridColumns = () => {
updateSelectedProp(
'dataColumns',
allGridColumnsSelected ? [] : gridColumnFields.map((field) => field.path),
)
}
useEffect(() => {
if (
!selectedId ||
!isTabularDataComponent(selectedNode?.type) ||
configuredGridColumns !== null ||
!gridColumnFields.length
) {
return
}
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, dataColumns: gridColumnFields.map((field) => field.path) },
})),
}))
}, [commitDocument, configuredGridColumns, gridColumnFields, selectedId, selectedNode?.type])
useEffect(() => {
const itemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
if (
!selectedId ||
!itemsBinding?.sourceId ||
itemsBinding.sourceId !== activeDataSource?.id ||
activeDataSample === undefined
) {
return
}
const currentValue = getDesignerValueByPath(activeDataSample, itemsBinding.path)
if (Array.isArray(currentValue)) return
const collectionPath = Array.isArray(activeDataSample)
? ''
: discoverDataFields(activeDataSample).find((field) => field.type === 'array')?.path
if (collectionPath === undefined || collectionPath === itemsBinding.path) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
items: { sourceId: itemsBinding.sourceId, path: collectionPath },
},
})),
}))
}, [activeDataSample, activeDataSource?.id, commitDocument, selectedId, selectedNode])
const dataBindableProperties =
selectedNode &&
selectedDefinition &&
(isDataCategoryComponent(selectedDefinition) || Boolean(sqlScopeNode))
? selectedDefinition.properties.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
(isTabularDataComponent(selectedNode.type)
? property.name === 'items'
: isOptionDataComponent(selectedNode.type)
? property.name === getOptionDataProperty(selectedNode.type)
: sqlScopeLocked
? // A record field only needs the props that carry data.
getSqlRecordFieldOrder(property.name) >= 0
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) ||
property.type === 'array' ||
['number', 'boolean', 'string', 'select'].includes(property.type)),
)
: []
const orderedBindableProperties = sqlScopeLocked
? [...dataBindableProperties].sort(
(left, right) => getSqlRecordFieldOrder(left.name) - getSqlRecordFieldOrder(right.name),
)
: dataBindableProperties
const isCollectionProperty = (propertyName: string, propertyType: string) =>
propertyType === 'array' || ['items', 'data', 'dataSource', 'options'].includes(propertyName)
const getBindingChoice = (propertyName: string) => {
const binding = selectedNode?.bindings?.[propertyName]
if (!binding || binding.sourceId !== activeDataSource?.id) return ''
const path = binding.path.trim()
if (!path || path === '$item' || path === '0') return '__root__'
if (isActiveRepeatedSource && path.startsWith('$item.')) return path.slice(6)
if (Array.isArray(activeBindingSample) && path.startsWith('0.')) return path.slice(2)
return path
}
const getBindingPath = (fieldPath: string, collection: boolean) => {
if (fieldPath === '__root__') {
if (isActiveRepeatedSource) return '$item'
if (Array.isArray(activeBindingSample) && !collection) return '0'
return ''
}
if (isActiveRepeatedSource) return `$item.${fieldPath}`
if (Array.isArray(activeBindingSample)) return `0.${fieldPath}`
return fieldPath
}
const renderReusableDataSources = (compact = false) => {
const visibleSources = reusableDataSources
return (
<div className="space-y-2">
{endpointCatalogLoading && !visibleSources.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs text-slate-500 dark:border-slate-700">
{translate('::App.DeveloperKit.ComponentDesigner.LoadingSavedEndpoints')}
</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)}
>
{translate('::App.DeveloperKit.ComponentDesigner.Use')}
</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={translate('::App.DeveloperKit.ComponentDesigner.EditEndpoint')}
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={translate('::App.DeveloperKit.ComponentDesigner.DeleteEndpoint')}
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">
{translate('::App.DeveloperKit.ComponentDesigner.NoMoreEndpoints')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.CatalogReadFailed')}:{' '}
{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">
{translate('::App.DeveloperKit.ComponentDesigner.DataSourceType')}
</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)
? translate('::App.DeveloperKit.ComponentDesigner.StaticOptions')
: translate('::App.DeveloperKit.ComponentDesigner.StaticData')
: '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">
{translate('::App.DeveloperKit.ComponentDesigner.StaticOptions')}
</div>
{staticSelectOptions.map((option, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-1">
<input
aria-label={translate('::App.DeveloperKit.ComponentDesigner.OptionLabelAria', {
index: index + 1,
})}
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={translate('::App.DeveloperKit.ComponentDesigner.LabelHeader')}
value={option.label}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, label: event.target.value } : current,
),
)
}
/>
<input
aria-label={translate('::App.DeveloperKit.ComponentDesigner.OptionValueAria', {
index: index + 1,
})}
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={translate('::App.DeveloperKit.ComponentDesigner.ValueHeader')}
value={String(option.value ?? '')}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, value: event.target.value } : current,
),
)
}
/>
<button
aria-label={translate('::App.DeveloperKit.ComponentDesigner.OptionDeleteAria', {
index: index + 1,
})}
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: translate('::App.DeveloperKit.ComponentDesigner.OptionN', {
index: staticSelectOptions.length + 1,
}),
value: String(staticSelectOptions.length + 1),
},
])
}
>
<FaPlus /> {translate('::App.DeveloperKit.ComponentDesigner.AddStaticOption')}
</button>
</div>
) : (
<div className="space-y-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.LabelValueMapping')}
</div>
<p className="text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.LabelValueMappingHint')}
</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="">
{translate('::App.DeveloperKit.ComponentDesigner.NoCollectionFound')}
</option>
)}
{selectCollectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path ||
translate('::App.DeveloperKit.ComponentDesigner.WholeResponseArray')}
</option>
))}
</select>
</label>
{selectCollectionSample && (
<div className="grid grid-cols-2 gap-2">
{(
[
['labelPath', 'App.DeveloperKit.ComponentDesigner.LabelColumn'],
['valuePath', 'App.DeveloperKit.ComponentDesigner.ValueColumn'],
] as const
).map(([key, label]) => (
<label key={key} className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
{translate('::' + 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="">
{translate(
selectColumnFields.length
? '::App.DeveloperKit.ComponentDesigner.SelectColumn'
: '::App.DeveloperKit.ComponentDesigner.ItemItself',
)}
</option>
{selectColumnFields.map((field) => (
<option key={field.path} value={field.path}>
{field.path}
</option>
))}
</select>
</label>
))}
</div>
)}
{/*
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
başka bir komponente ya da SqlDataSource kaydına yazabilir.
*/}
{selectCollectionSample && selectColumnFields.length > 0 && (
<div>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.ExtraColumns')}
<code className="font-normal normal-case text-sky-600">
refs.{selectedNode?.ref}.getColumn(&apos;&apos;)
</code>
</span>
<div className="flex flex-wrap items-center gap-1">
{selectOptionColumns.map((column) => (
<span
key={column}
className="flex items-center gap-1 rounded-full bg-sky-100 px-2 py-[2px] text-[10px] text-sky-700 dark:bg-sky-950 dark:text-sky-200"
>
{column}
<button
className="text-sky-400 hover:text-red-500"
title={translate('::App.DeveloperKit.ComponentDesigner.RemoveColumn')}
type="button"
onClick={() =>
updateSelectedBindingDetails(optionDataProperty, {
columns: selectOptionColumns.filter((item) => item !== column),
})
}
>
<FaTimes size={8} />
</button>
</span>
))}
<select
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value=""
onChange={(event) => {
const next = event.target.value
if (!next || selectOptionColumns.includes(next)) return
updateSelectedBindingDetails(optionDataProperty, {
columns: [...selectOptionColumns, next],
})
}}
>
<option value="">
{translate('::App.DeveloperKit.ComponentDesigner.AddColumn')}
</option>
{selectColumnFields
.filter((field) => !selectOptionColumns.includes(field.path))
.map((field) => (
<option key={field.path} value={field.path}>
{field.path}
</option>
))}
</select>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.ExtraColumnsHint')}{' '}
<code>
refs.{selectedNode?.ref || 'select1'}.getColumn(&apos;sutun&apos;)
</code>{' '}
{translate('::App.DeveloperKit.ComponentDesigner.ExtraColumnsHintSuffix')}
</p>
</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">
{translate('::App.DeveloperKit.ComponentDesigner.SelectLabelValueWarning', {
type: selectedNode?.type ?? '',
})}
</p>
)}
</>
)}
{activeDataSample === undefined && (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-slate-950">
{translate('::App.DeveloperKit.ComponentDesigner.SelectEndpointFirst')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.StaticRowsJson')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.StaticRowsHint')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.RowsReady', {
count: currentItems.length,
})}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.VisibleColumns')}
</span>
<button
className="text-[9px] font-semibold text-sky-600"
type="button"
onClick={toggleAllGridColumns}
>
{translate(
allGridColumnsSelected ? '::App.Platform.RemoveAll' : '::App.Platform.SelectAll',
)}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.AddObjectForColumns')}
</p>
)}
</div>
)
}
/**
* SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete
* slots. Each slot only accepts a data source declared with the matching HTTP
* method, which is what enables the Save/Delete buttons at runtime.
*/
const renderSqlDataSourceConfiguration = () => {
if (!selectedNode || !selectedIsSqlDataSource) return null
const selectSourceId = String(selectedNode.props.selectEndpoint || '')
const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined
const collectionPaths = selectSample
? [
'',
...discoverDataFields(selectSample)
.filter((field) => field.type === 'array')
.map((field) => field.path),
]
: ['']
const record = sqlDataSourceRecords[selectedNode.id]
const recordFields = record === undefined ? [] : discoverDataFields(record)
const selectSource = document.dataSources.find((source) => source.id === selectSourceId)
// A GetById URL cannot be sampled at design time without a stand-in key.
const selectNeedsPreviewKey = Boolean(
selectSource && hasSqlDataSourceUrlParams(selectSource.url),
)
return (
<div className="space-y-3">
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
{translate('::App.DeveloperKit.ComponentDesigner.CommandSlotsHint')}
</div>
{SQL_DATA_SOURCE_SLOTS.map((slot) => {
const options =
slot.method === 'GET'
? selectSlotDataSources
: document.dataSources.filter((source) => source.method === slot.method)
const currentValue = String(selectedNode.props[slot.property] || '')
return (
<label
key={slot.property}
className="block rounded-lg border border-slate-200 p-3 dark:border-slate-800"
>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-400">
<span>{slot.label}</span>
{currentValue && (
<span className="text-emerald-600">
{translate('::App.DeveloperKit.ComponentDesigner.Bound')}
</span>
)}
</span>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::' + slot.description)}
</p>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={currentValue}
onChange={(event) => {
const sourceId = event.target.value
updateSelectedProp(slot.property, sourceId)
const source = document.dataSources.find((item) => item.id === sourceId)
if (
slot.method === 'GET' &&
source &&
dataSourceSamples[sourceId] === undefined
) {
void testDataSource(source)
}
}}
>
<option value="">
{translate('::App.DeveloperKit.ComponentDesigner.NotDefined')}
</option>
{options.map((source) => (
<option key={source.id} value={source.id}>
{source.name} · {source.url}
</option>
))}
</select>
{!options.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKit.ComponentDesigner.NoEndpointForMethod', {
method: slot.method,
})}
</p>
)}
</label>
)
})}
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Key field
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="id"
value={String(selectedNode.props.keyFieldName ?? '')}
onChange={(event) => updateSelectedProp('keyFieldName', event.target.value)}
/>
</label>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={String(selectedNode.props.collectionPath ?? '')}
onChange={(event) => updateSelectedProp('collectionPath', event.target.value)}
>
{collectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || translate('::App.DeveloperKit.ComponentDesigner.ResponseItself')}
</option>
))}
</select>
</label>
</div>
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Select key parametresi
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.KeyParamHint')}
</p>
<select
className="mb-2 w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={getSqlDataSourceKeySource(selectedNode)}
onChange={(event) => updateSelectedProp('keySource', event.target.value)}
>
{SQL_DATA_SOURCE_KEY_SOURCES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<input
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={translate('::App.DeveloperKit.ComponentDesigner.KeyParamPlaceholder', {
field: getSqlDataSourceKeyField(selectedNode),
})}
value={String(selectedNode.props.keyParamName ?? '')}
onChange={(event) => updateSelectedProp('keyParamName', event.target.value)}
/>
</div>
{selectNeedsPreviewKey && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.PreviewKeyValue')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.PreviewKeyValueHint')}
</p>
<input
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={translate(
'::App.DeveloperKit.ComponentDesigner.PreviewKeyValuePlaceholder',
)}
value={String(selectedNode.props.previewKeyValue ?? '')}
onChange={(event) => updateSelectedProp('previewKeyValue', event.target.value)}
/>
</div>
)}
<div className="space-y-1.5 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
{(
[
['autoLoad', 'App.DeveloperKit.ComponentDesigner.AutoLoad', true],
['showToolbar', 'App.DeveloperKit.ComponentDesigner.ShowToolbar', true],
] as const
).map(([property, label, defaultChecked]) => (
<label key={property} className="flex cursor-pointer items-center gap-2 text-[10px]">
<input
checked={
defaultChecked
? selectedNode.props[property] !== false
: selectedNode.props[property] === true
}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) => updateSelectedProp(property, event.target.checked)}
/>
{translate('::' + label)}
</label>
))}
</div>
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.RecordColumns')}
</div>
{recordFields.length ? (
<div className="max-h-40 space-y-1 overflow-y-auto">
{recordFields.map((field) => (
<div
key={field.path}
className="flex items-center justify-between gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-slate-950"
title={field.preview}
>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
</div>
))}
</div>
) : (
<p className="text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.RunSelectToListColumns')}
</p>
)}
</div>
</div>
)
}
/**
* Banner shown for anything inside a SqlDataSource: it states which record the
* component is bound to and, when the container has no Select endpoint yet,
* why no column can be picked.
*/
const renderSqlScopeNotice = () => {
if (!sqlScopeNode) return null
const hasSelect = Boolean(getSqlDataSourceEndpointId(sqlScopeNode, 'selectEndpoint'))
const record = sqlDataSourceRecords[sqlScopeNode.id]
const hasColumns = record !== undefined && discoverDataFields(record).length > 0
if (!hasSelect) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-[10px] leading-4 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
{translate('::App.DeveloperKit.ComponentDesigner.DefineSelectEndpointFirst')}
</div>
)
}
return (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
{translate('::App.DeveloperKit.ComponentDesigner.BoundToSqlDataSource')}
{!hasColumns && ` ${translate('::App.DeveloperKit.ComponentDesigner.RunSelectOnce')}`}
</div>
)
}
/**
* Value binding for an option component inside a SqlDataSource. Its option list
* still comes from its own GET endpoint above; this only says which record
* column the selection reads from and writes back to.
*/
const renderSqlRecordValueBinding = () => {
if (!sqlScopeNode || !selectedNode) return null
if (!['AutoComplete', 'Radio.Group', 'Select'].includes(selectedNode.type)) return null
const record = sqlDataSourceRecords[sqlScopeNode.id]
const columns =
record === undefined
? []
: discoverDataFields(record).filter((field) => !['array', 'object'].includes(field.type))
const binding = selectedNode.bindings?.value
const current = binding?.sourceId === sqlScopeNode.id ? binding.path : ''
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.RecordField')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.RecordFieldHint')}
</p>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={current}
onChange={(event) =>
updateSelectedBinding(
'value',
event.target.value ? sqlScopeNode.id : '',
event.target.value,
)
}
>
<option value="">
{translate('::App.DeveloperKit.ComponentDesigner.DoNotBindToRecord')}
</option>
{columns.map((field) => (
<option key={field.path} value={field.path}>
{field.path} · {field.type}
</option>
))}
</select>
{!columns.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKit.ComponentDesigner.DefineAndRunSelect')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.PlatformDataModel')}
</strong>
{translate('::App.DeveloperKit.ComponentDesigner.PlatformDataModelHint', {
type: selectedNode.type,
})}
</div>
)
}
if (selectedIsSqlDataSource) return renderSqlDataSourceConfiguration()
const scopeNotice = renderSqlScopeNotice()
const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration()
if (selectedIsCollectionComponent && selectDataMode === 'static') {
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
{renderSqlRecordValueBinding()}
</div>
)
}
if (!inspectorDataSources.length) {
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
<div className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
{translate('::App.DeveloperKit.ComponentDesigner.NoSelectableGetEndpoint')}
</div>
</div>
)
}
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
{/* Inside a SqlDataSource a record field has exactly one legal source, so
the picker is replaced by a read-only statement of that source. */}
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{sqlScopeLocked
? translate('::App.DeveloperKit.ComponentDesigner.DataSource')
: isOptionDataComponent(selectedNode?.type)
? '1. Endpoint'
: 'Endpoint'}
</span>
{sqlScopeLocked ? (
<div className="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
{sqlScopeSource?.name}
</div>
) : (
<select
className="w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs dark:border-slate-700 dark:bg-slate-900"
value={activeDataSource?.id || ''}
onChange={(event) => {
const sourceId = event.target.value
setDataPanelSourceId(sourceId)
if (
(isOptionDataComponent(selectedNode?.type) ||
isTabularDataComponent(selectedNode?.type)) &&
selectDataMode === 'endpoint'
) {
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const props = { ...node.props }
delete props.dataColumns
return {
...node,
props,
bindings: {
...node.bindings,
items: { sourceId, path: '' },
},
}
}),
}))
} else {
updateSelectedBinding(optionDataProperty, sourceId, '')
}
}
const source = document.dataSources.find((item) => item.id === sourceId)
if (source && dataSourceSamples[sourceId] === undefined) {
void testDataSource(source)
}
}}
>
{/* Without this a browser paints the first option while the
value is still empty, which reads as a made choice. */}
{!activeDataSource && (
<option value="">
{translate('::App.DeveloperKit.ComponentDesigner.SelectEndpoint')}
</option>
)}
{inspectorDataSources.map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</select>
)}
</label>
</div>
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) &&
activeDataSample !== undefined &&
tabularItemsBinding?.sourceId && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={tabularItemsBinding.path}
onChange={(event) =>
updateSelectedBindingDetails('items', { path: event.target.value })
}
>
{!tabularCollectionPaths.length && (
<option value="">
{translate('::App.DeveloperKit.ComponentDesigner.NoCollectionFound')}
</option>
)}
{tabularCollectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || translate('::App.DeveloperKit.ComponentDesigner.WholeResponseArray')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.AvailableFields')}
</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}
/>
{translate(
allGridColumnsSelected
? '::App.Platform.RemoveAll'
: '::App.Platform.SelectAll',
)}
</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">
{translate(
isActiveRepeatedSource
? '::App.DeveloperKit.ComponentDesigner.GridRows'
: '::App.DeveloperKit.ComponentDesigner.Collection',
)}{' '}
·{' '}
{translate('::App.DeveloperKit.ComponentDesigner.RecordCount', {
count: activeBindingSample.length,
})}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.NoColumnInResponse')}
</p>
)}
</div>
</div>
)}
{!isOptionDataComponent(selectedNode?.type) &&
!isTabularDataComponent(selectedNode?.type) && (
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.PropertyBindings')}
</div>
{!orderedBindableProperties.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
{translate('::App.DeveloperKit.ComponentDesigner.NoBindableProperty')}
</p>
) : (
orderedBindableProperties.map((property) => {
const choice = getBindingChoice(property.name)
const collection = isCollectionProperty(property.name, property.type)
const selectableFields = collection
? activeDataFields.filter((field) => field.type === 'array')
: activeDataFields
const currentBinding = selectedNode?.bindings?.[property.name]
const currentBindingSource = document.dataSources.find(
(source) => source.id === currentBinding?.sourceId,
)
// A binding saved before the SqlDataSource lock existed can still
// point at an unrelated endpoint; the dropdown cannot show it, so
// it is called out explicitly instead of failing silently.
const foreignBinding =
sqlScopeLocked &&
Boolean(currentBinding?.sourceId) &&
currentBinding?.sourceId !== sqlScopeSource?.id
const knownChoice =
choice === '' ||
choice === '__root__' ||
selectableFields.some((field) => field.path === choice)
return (
<label
key={property.name}
className="mb-2 block rounded-lg border border-slate-200 p-2 dark:border-slate-800"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>{property.name}</span>
<span className="text-[9px] font-normal text-slate-400">
{property.tsType || property.type}
</span>
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={choice}
onChange={(event) => {
const nextChoice = event.target.value
if (!nextChoice) {
updateSelectedBinding(property.name, '')
return
}
if (!activeDataSource) return
updateSelectedBinding(
property.name,
activeDataSource.id,
getBindingPath(nextChoice, collection),
)
}}
>
<option value="">
{translate('::App.DeveloperKit.ComponentDesigner.StaticValueNoBinding')}
</option>
{activeBindingSample !== undefined &&
(collection ||
!Array.isArray(activeBindingSample) ||
isActiveRepeatedSource) && (
<option value="__root__">
{translate(
isActiveRepeatedSource
? '::App.DeveloperKit.ComponentDesigner.CurrentGridRow'
: collection
? '::App.DeveloperKit.ComponentDesigner.WholeCollection'
: '::App.DeveloperKit.ComponentDesigner.WholeResponse',
)}
</option>
)}
{!knownChoice && <option value={choice}>{choice} (mevcut path)</option>}
{selectableFields.map((field) => (
<option key={field.path} value={field.path}>
{field.path} · {field.type}
</option>
))}
</select>
{currentBinding?.sourceId && !foreignBinding && (
<div className="mt-1.5 truncate font-mono text-[9px] text-emerald-600">
{currentBindingSource?.name || currentBinding.sourceId}:{' '}
{currentBinding.path || '(root)'}
</div>
)}
{foreignBinding && (
<div className="mt-1.5 rounded bg-amber-50 p-1.5 text-[9px] leading-3 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
<span className="block truncate font-mono">
{currentBindingSource?.name || currentBinding?.sourceId}:{' '}
{currentBinding?.path || '(root)'}
</span>
<button
className="mt-1 font-semibold underline"
type="button"
onClick={() => updateSelectedBinding(property.name, '')}
>
{translate('::App.DeveloperKit.ComponentDesigner.ClearNonSqlBinding')}
</button>
</div>
)}
</label>
)
})
)}
</div>
)}
{renderSqlRecordValueBinding()}
</div>
)
}
const renderTree = (nodes: DesignerNode[], depth = 0): React.ReactNode =>
nodes.map((node) => (
<React.Fragment key={node.id}>
<button
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${selectedId === node.id ? 'bg-sky-100 font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'}`}
style={{ paddingLeft: 8 + depth * 14 }}
type="button"
onClick={() => selectDesignerNode(node.id)}
>
<FaLayerGroup className="shrink-0" />
<span className="truncate">{node.type}</span>
<span className="ml-auto text-[9px] text-slate-400">{node.id.slice(-4)}</span>
</button>
{renderTree(node.children, depth + 1)}
</React.Fragment>
))
const pageHelmet = <PageTitle title={pageTitle} />
if (!component) {
return (
<div className="flex min-h-[70vh] items-center justify-center text-sm text-slate-500">
{pageHelmet}
{componentLoadError || translate('::App.DeveloperKit.ComponentDesigner.LoadingComponent')}
</div>
)
}
const canvas = (
<VisualCanvas
interactive
nodes={document.nodes}
selectedId={selectedId}
dataValues={previewDataValues}
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
onDropComponent={addComponent}
onDropComponentBeside={addComponentBeside}
onMoveIntoContainer={moveNodeIntoContainerNode}
onMove={moveNode}
onReorder={reorderNode}
onDuplicate={duplicateNode}
onDelete={deleteNode}
/>
)
const javascriptEventTargets: Array<{ node: DesignerNode; eventNames: string[] }> = []
walkDesignerNodes(document.nodes, (node) => {
const definition = catalogByName.get(node.type)
const primaryEvents = DESIGNER_PRIMARY_EVENTS[node.type]
const savedEventNames = Object.entries(node.events || {})
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName)
const definitionEventNames = (definition?.properties || [])
.filter((property) => property.category === 'events' || property.type === 'function')
.map((property) => property.name)
const eventNames = Array.from(
new Set([...(primaryEvents || definitionEventNames), ...savedEventNames]),
)
if (eventNames.length) javascriptEventTargets.push({ node, eventNames })
})
const activeJavaScriptTarget =
javascriptEventTargets.find((target) => target.node.id === selectedId) ||
javascriptEventTargets[0]
// A Script API example is appended to the editor the user last worked in,
// falling back to the first event of the selected component.
const activeEventName = activeJavaScriptTarget?.eventNames.includes(focusedEventName)
? focusedEventName
: activeJavaScriptTarget?.eventNames[0] || ''
const appendScriptToEvent = (snippet: string) => {
if (!activeJavaScriptTarget || !activeEventName) return
const current = activeJavaScriptTarget.node.events[activeEventName] || ''
updateNodeEvent(
activeJavaScriptTarget.node.id,
activeEventName,
current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet,
)
}
/**
* Builder context of the event being edited: the SqlDataSource that owns the
* record (the node itself, or the nearest one above it) plus the columns and
* ref names its rules can address.
*/
const scriptBuilderNode = scriptBuilderTarget
? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId)
: null
const scriptBuilderSqlNode = scriptBuilderNode
? isSqlDataSourceNode(scriptBuilderNode.type)
? scriptBuilderNode
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
.reverse()
.find((node) => isSqlDataSourceNode(node.type))
: undefined
const scriptBuilderRecordFields = scriptBuilderSqlNode
? Object.keys((sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {})
: []
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)
? translate('::App.DeveloperKit.ComponentDesigner.RecordCount', {
count: endpointResultModal.result.length,
})
: endpointResultModal.result && typeof endpointResultModal.result === 'object'
? `${Object.keys(endpointResultModal.result).length} alan`
: typeof endpointResultModal.result
: ''
return (
<div className="flex h-[calc(100vh-72px)] min-h-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-100 shadow-sm dark:border-slate-800 dark:bg-slate-950">
{pageHelmet}
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 items-center gap-3">
<Link
className="rounded-md p-2 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
title={translate('::App.DeveloperKit.ComponentEditor.Back')}
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={translate('::App.Platform.UndoWithShortcut')}
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={translate('::App.DeveloperKit.ComponentDesigner.ResetCanvas')}
variant="plain"
onClick={() => {
commitDocument(() => createEmptyDesignerDocument('visual'))
setSelectedId(null)
}}
>
{translate('::Reset')}
</Button>
<Button
icon={<FaEye />}
size="sm"
title={translate('::App.DeveloperKit.ComponentDesigner.OpenInNewTab')}
variant="plain"
onClick={() => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank')
}}
>
{translate('::OpenUrl')}
</Button>
<Button
loading={isSaving}
icon={<FaRegSave />}
size="sm"
variant="solid"
onClick={() => void handleSave()}
>
{translate('::Save')}
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{workspaceTab === 'design' && (
<aside className="flex w-80 shrink-0 flex-col border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
<FaPlus /> Toolbox
</div>
<label className="relative block">
<FaSearch className="absolute left-2.5 top-2.5 text-xs text-slate-400" />
<input
className="w-full rounded-md border border-slate-200 bg-slate-50 py-2 pl-8 pr-2 text-xs outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-950"
placeholder={translate('::App.DeveloperKit.ComponentDesigner.SearchComponent')}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
</div>
<div className="flex-1 overflow-y-auto p-2">
{groupedCatalog.map(([group, definitions]) => (
<section key={group} className="mb-5">
<button
aria-expanded={expandedToolboxGroups[group] || Boolean(search.trim())}
className="mb-2 flex w-full items-center gap-2 rounded bg-slate-100 px-2 py-1.5 text-left text-[10px] font-bold uppercase tracking-widest text-slate-600 dark:bg-slate-800 dark:text-slate-300"
type="button"
onClick={() =>
setExpandedToolboxGroups((current) => ({
...current,
[group]: !current[group],
}))
}
>
{expandedToolboxGroups[group] || search.trim() ? (
<FaChevronDown className="shrink-0" />
) : (
<FaChevronRight className="shrink-0" />
)}
<span>{TOOLBOX_GROUP_LABELS[group]}</span>
<span className="ml-auto rounded bg-white/70 px-1.5 py-0.5 text-[9px] font-medium tracking-normal dark:bg-slate-900/60">
{definitions.length}
</span>
</button>
{(expandedToolboxGroups[group] || Boolean(search.trim())) && (
<div className="grid grid-cols-3 gap-1.5">
{definitions.map((definition) => (
<button
key={`${definition.toolboxGroup}-${definition.name}`}
draggable
className={`group min-w-0 rounded-lg border bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950 ${
activeDrag?.source === 'library' && activeDrag.name === definition.name
? 'border-sky-500 ring-2 ring-sky-300 dark:border-sky-500'
: 'border-slate-200 dark:border-slate-700'
}`}
title={
definition.acceptsChildren
? `${translate('::' + definition.description)}\n\n${translate('::App.DeveloperKit.ComponentDesigner.AcceptsChildren')}`
: translate('::' + 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)
// Canvas bırakma hedeflerini vurgulayabilmek için
// sürüklemenin başladığını duyuruyoruz — senkron bir
// state güncellemesi sürüklemeyi iptal ettirebildiği
// için bir tık sonraya bırakarak.
beginDesignerDragSoon({ source: 'library', name: definition.name })
}}
onDragEnd={() => endDesignerDrag()}
>
<div className="mb-1 flex h-6 w-6 items-center justify-center rounded bg-slate-100 text-[10px] font-bold text-slate-500 group-hover:bg-sky-100 group-hover:text-sky-600 dark:bg-slate-800">
{definition.name.slice(0, 2).toUpperCase()}
</div>
<div className="truncate text-[11px] font-medium text-slate-700 dark:text-slate-200">
{definition.name}
</div>
</button>
))}
</div>
)}
</section>
))}
</div>
</aside>
)}
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 dark:border-slate-800 dark:bg-slate-900">
<div className="flex h-full items-center gap-1">
{(
[
['design', 'Design', <FaMousePointer key="design" />],
['data', 'Data', <FaDatabase key="data" />],
['code', 'React', <FaCode key="code" />],
['event', 'Event', <FaBolt key="js" />],
] as [WorkspaceTab, string, React.ReactNode][]
).map(([tab, label, icon]) => (
<button
key={tab}
className={`flex h-full items-center gap-1.5 border-b-2 px-3 text-xs font-medium ${workspaceTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-white'}`}
type="button"
onClick={() => setWorkspaceTab(tab)}
>
{icon}
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
{workspaceTab === 'design' && (
<div className="flex items-center rounded-md border border-slate-200 p-0.5 dark:border-slate-700">
{(
[
['responsive', <FaDesktop key="r" />, 'Responsive'],
['desktop', <FaDesktop key="d" />, 'Desktop'],
['tablet', <FaTabletAlt key="t" />, 'Tablet'],
['mobile', <FaMobileAlt key="m" />, 'Mobile'],
] as [DesignerDocument['canvas']['width'], React.ReactNode, string][]
).map(([width, icon, label]) => (
<button
key={width}
className={`rounded p-1.5 ${document.canvas.width === width ? 'bg-sky-100 text-sky-600 dark:bg-sky-950' : 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'}`}
title={label}
type="button"
onClick={() =>
commitDocument((current) => ({
...current,
canvas: { ...current.canvas, width },
}))
}
>
{icon}
</button>
))}
</div>
)}
{/* Reachable from every tab: the examples are just as useful while
laying out the page as they are inside the event editor. */}
<button
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium ${
scriptApiOpen
? 'border-sky-500 bg-sky-50 text-sky-600 dark:bg-sky-950'
: 'border-slate-200 text-slate-500 hover:text-slate-800 dark:border-slate-700 dark:hover:text-white'
}`}
title={translate('::App.DeveloperKit.ComponentDesigner.ScriptApiExamples')}
type="button"
onClick={() => setScriptApiOpen((current) => !current)}
>
<FaBolt />
Script API
<span className="rounded bg-sky-100 px-1 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-900 dark:text-sky-300">
{DESIGNER_SCRIPT_RECIPES.length}
</span>
</button>
</div>
</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">
{translate('::App.DeveloperKit.ComponentDesigner.ApiAuthHint')}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button icon={<FaPlus />} size="sm" variant="solid" onClick={addDataSource}>
{translate('::App.DeveloperKit.ComponentDesigner.NewEndpoint')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.GetPostHint')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.EndpointSettingsHint')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.AvailableSources')}
</h3>
<p className="mt-1 text-[10px] text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.AvailableSourcesHint')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.EventsHint')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.NoEventComponent')}
</p>
)}
</div>
{/* Every component on the page is addressable from any script,
which is what makes cross component behaviour possible. */}
<div className="mt-4 border-t border-slate-200 pt-3 dark:border-slate-800">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Referanslar
</h3>
<p className="mb-2 mt-1 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.RefsAccessHint')}{' '}
<code>setValue</code>, <code>setVisible</code>, <code>setEnabled</code>,{' '}
<code>setReadOnly</code>, <code>setText</code>, <code>setProps</code>,{' '}
<code>getValue</code>.
</p>
<div className="max-h-64 space-y-1 overflow-y-auto">
{[...documentRefs.entries()].map(([ref, node]) => (
<button
key={ref}
className="flex w-full items-center gap-2 rounded border border-transparent px-2 py-1 text-left hover:border-slate-200 hover:bg-white dark:hover:border-slate-700 dark:hover:bg-slate-900"
title={translate('::App.DeveloperKit.ComponentDesigner.CopyRefName')}
type="button"
onClick={() => copyRefName(ref)}
>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-600">
{ref}
</code>
<span className="shrink-0 text-[9px] text-slate-400">{node.type}</span>
</button>
))}
{!documentRefs.size && (
<p className="text-[10px] text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.NoComponentAdded')}
</p>
)}
</div>
</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">
{translate('::App.DeveloperKit.ComponentDesigner.OnMountHint')}
</p>
</div>
<code className="text-[10px] text-sky-600">onMount()</code>
</div>
<Editor
height="150px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.lifecycle.onMount}
onChange={(value) =>
commitDocument((current) => ({
...current,
lifecycle: { onMount: value || '' },
}))
}
/>
</div>
{activeJavaScriptTarget ? (
<div>
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">
{activeJavaScriptTarget.node.type}
</h3>
<code className="text-[10px] text-slate-400">
{activeJavaScriptTarget.node.id}
</code>
</div>
<span className="rounded bg-amber-100 px-2 py-1 text-[10px] font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{activeJavaScriptTarget.eventNames.length} event
</span>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{activeJavaScriptTarget.eventNames.map((eventName) => (
<label
key={eventName}
className={`overflow-hidden rounded-lg border dark:border-slate-800 ${eventName === activeEventName ? 'border-amber-400' : 'border-slate-200'}`}
onFocus={() => setFocusedEventName(eventName)}
>
<span className="flex items-center justify-between bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<span
className="min-w-0 truncate"
title={
getSqlDataSourceEvent(eventName)?.description
? translate(
'::' + getSqlDataSourceEvent(eventName)!.description,
)
: undefined
}
>
<code>{eventName}(event)</code>
{getSqlDataSourceEvent(eventName) &&
isSqlDataSourceNode(activeJavaScriptTarget.node.type) && (
<span className="ml-2 font-normal text-slate-400">
{getSqlDataSourceEvent(eventName)?.label}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[9px] font-normal text-slate-400">
{activeJavaScriptTarget.node.type}
</span>
<button
className="rounded bg-indigo-100 px-2 py-1 text-[9px] font-semibold text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950 dark:text-indigo-300"
title={translate(
'::App.DeveloperKit.ComponentDesigner.RuleBasedScriptBuilder',
)}
type="button"
onClick={() =>
setScriptBuilderTarget({
nodeId: activeJavaScriptTarget.node.id,
eventName,
})
}
>
Builder
</button>
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-sky-950 dark:text-sky-300"
disabled={Boolean(
activeJavaScriptTarget.node.events[eventName]?.trim(),
)}
type="button"
onClick={() =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
resolveRecipeCode(
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
),
),
)
}
>
{translate('::App.DeveloperKit.ComponentDesigner.AddSnippet')}
</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">
{translate('::App.DeveloperKit.ComponentDesigner.NoEditableEvent')}
</div>
)}
</div>
</div>
)}
</div>
</main>
{workspaceTab === 'design' && (
<aside
data-property-panel
className="flex w-72 shrink-0 flex-col border-l border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex h-12 shrink-0 border-b border-slate-200 dark:border-slate-800">
{(
[
['properties', <FaSlidersH key="p" />, 'Properties'],
['data', <FaDatabase key="d" />, 'Data'],
['tree', <FaLayerGroup key="t" />, 'Tree'],
] as [InspectorTab, React.ReactNode, string][]
).map(([tab, icon, label]) => (
<button
key={tab}
className={`flex flex-1 items-center justify-center gap-1 border-b-2 text-[11px] ${tab === 'data' && !selectedIsDataComponent ? 'cursor-not-allowed border-transparent text-slate-300 dark:text-slate-700' : inspectorTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500'}`}
disabled={tab === 'data' && !selectedIsDataComponent}
title={
tab === 'data' && !selectedIsDataComponent
? translate('::App.DeveloperKit.ComponentDesigner.DataTabOnlyForDataCategory')
: undefined
}
type="button"
onClick={() => {
if (tab !== 'data' || selectedIsDataComponent) setInspectorTab(tab)
}}
>
{icon}
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3">
{inspectorTab === 'tree' ? (
<div>
{document.nodes.length ? (
renderTree(document.nodes)
) : (
<p className="p-3 text-xs text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.NoComponentYet')}
</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={translate('::App.DeveloperKit.Component.Action.Delete')}
type="button"
onClick={() => deleteNode(selectedNode.id)}
>
<FaTrash />
</button>
</div>
<code className="text-[10px] text-slate-400">{selectedNode.id}</code>
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-slate-800">
<span className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Ref
<button
className="font-mono text-[10px] font-normal normal-case tracking-normal text-sky-600 hover:underline"
title={translate('::App.Platform.CopyToClipboard')}
type="button"
onClick={() => copyRefName(selectedNode.ref || '')}
>
refs.{selectedNode.ref}
</button>
</span>
<input
className="mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
spellCheck={false}
value={refDraft}
onBlur={commitRefRename}
onChange={(event) => setRefDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
{refError ? (
<p className="mt-1 text-[10px] leading-4 text-red-500">{refError}</p>
) : (
<p className="mt-1 text-[10px] leading-4 text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.RefRenameHint')}
</p>
)}
</div>
{selectedDefinition?.interfaceName && (
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-slate-800">
<div className="text-[10px] font-semibold text-sky-600">
{selectedDefinition.interfaceName}
</div>
<div
className="truncate text-[9px] text-slate-400"
title={selectedDefinition.sourceFile}
>
{selectedDefinition.sourceFile}
</div>
</div>
)}
</div>
{inspectorTab === 'properties' ? (
<>
{selectedProperties
.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
!(
isOptionDataComponent(selectedNode.type) &&
property.name === getOptionDataProperty(selectedNode.type)
) &&
!(
isTabularDataComponent(selectedNode.type) &&
['items', 'data', 'columns', 'dataColumns'].includes(property.name)
) &&
// Command slots and the key/collection settings are
// owned by the SqlDataSource panel in the Data tab.
!(
selectedIsSqlDataSource &&
[
...SQL_DATA_SOURCE_SLOTS.map((slot) => slot.property),
'keyFieldName',
'collectionPath',
'keySource',
'keyParamName',
'previewKeyValue',
'autoLoad',
'showToolbar',
'showNavigation',
'filterByUrlKey',
].includes(property.name)
),
)
.map((property) => {
const propertyValue = selectedNode.props[property.name] ?? property.value
const helperValue = getPropertyExampleValue(
property,
property.value,
translate,
)
const snippet = getPropertySnippet(property, helperValue, translate)
return (
<div
key={property.name}
className="group/property mb-4 rounded-md border border-slate-200 p-2 dark:border-slate-800"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>
{property.name}
{property.required ? (
<b className="ml-0.5 text-red-500">*</b>
) : null}
</span>
<span className="flex items-center gap-1 text-[9px] font-normal text-slate-400">
<span>{property.tsType || property.type}</span>
<span className="group/snippet relative">
<button
className="rounded p-1 text-sky-500 opacity-0 transition hover:bg-sky-50 hover:text-sky-700 focus:opacity-100 group-hover/property:opacity-100 dark:hover:bg-sky-950"
title={translate(
'::App.DeveloperKit.ComponentDesigner.ApplyHelperValue',
)}
type="button"
onClick={() =>
updateSelectedProp(property.name, clone(helperValue))
}
>
<FaBolt />
</button>
<span className="pointer-events-none absolute right-0 top-full z-20 mt-1 hidden w-56 rounded-md border border-slate-200 bg-white p-2 text-left font-mono text-[9px] leading-4 text-sky-700 shadow-lg group-hover/snippet:block dark:border-slate-700 dark:bg-slate-950 dark:text-sky-300">
{snippet}
<span className="mt-1 block font-sans text-[9px] text-slate-400">
{translate(
'::App.DeveloperKit.ComponentDesigner.ApplyHelperValueHint',
)}
</span>
</span>
</span>
</span>
</span>
{property.description && (
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{translate('::' + property.description)}
</p>
)}
<PropertyEditor
componentType={selectedNode?.type}
name={property.name}
type={property.type}
value={propertyValue}
options={property.options}
onChange={(value) => updateSelectedProp(property.name, value)}
/>
</div>
)
})}
</>
) : (
renderDataInspector()
)}
</>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-slate-300 p-5 text-center dark:border-slate-700">
<FaMousePointer className="mx-auto mb-2 text-slate-400" />
<p className="text-xs text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.SelectComponentForProps')}
</p>
</div>
</div>
)}
</div>
<div className="border-t border-slate-200 bg-slate-50 px-3 py-2 text-[10px] text-slate-500 dark:border-slate-800 dark:bg-slate-950">
<span className="font-semibold">{document.nodes.length}</span> root component ·{' '}
<span className="font-semibold">Ctrl+S</span> save ·{' '}
<span className="font-semibold">Del</span> delete
</div>
</aside>
)}
</div>
{/* Everything an event script can do, as copy/paste examples filled in
with this page's own ref names and endpoints. It is a drawer rather
than a dialog on purpose: no backdrop, so the editor underneath stays
usable while an example is being read or pasted. */}
{scriptApiOpen && (
<aside className="fixed inset-y-0 right-0 z-[90] flex w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-white">
<FaBolt className="text-amber-500" /> Script API yapabilecekleriniz
<span className="shrink-0 rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300">
{translate('::App.DeveloperKit.ComponentDesigner.ExampleCount', {
count: DESIGNER_SCRIPT_RECIPES.length,
})}
</span>
</h2>
<code className="mt-1 block text-[10px] text-slate-400">
event · component · props · refs · api · notify
</code>
</div>
<button
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
title={translate('::App.Platform.CloseWithEsc')}
type="button"
onClick={() => setScriptApiOpen(false)}
>
×
</button>
</div>
<div className="shrink-0 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
placeholder={translate('::App.DeveloperKit.ComponentDesigner.SearchExample')}
value={scriptApiSearch}
onChange={(event) => setScriptApiSearch(event.target.value)}
/>
{activeEventName ? (
<p className="mt-2 text-[10px] text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.AppendExampleHintPrefix')}{' '}
<code className="text-amber-600 dark:text-amber-400">{activeEventName}</code>{' '}
{translate('::App.DeveloperKit.ComponentDesigner.AppendExampleHintSuffix')}
</p>
) : (
<p className="mt-2 text-[10px] text-slate-400">
{translate('::App.DeveloperKit.ComponentDesigner.ExampleInsertHint')}
</p>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
{DESIGNER_SCRIPT_RECIPE_GROUPS.map((group) => {
const recipes = filteredScriptRecipes.filter((recipe) => recipe.group === group)
if (!recipes.length) return null
return (
<div key={group} className="mb-4 last:mb-0">
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{group}
</h4>
<div className="grid gap-2">
{recipes.map((recipe) => {
const recipeCode = resolveRecipeCode(recipe.code)
return (
<div
key={recipe.title}
className="overflow-hidden rounded-md border border-slate-200 dark:border-slate-800"
>
<div className="flex items-start justify-between gap-2 px-3 pt-2">
<div className="min-w-0">
<div className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{translate('::' + recipe.title)}
</div>
<p className="text-[10px] leading-4 text-slate-400">
{translate('::' + recipe.description)}
</p>
</div>
<span className="flex shrink-0 items-center gap-1">
{activeEventName && (
<button
className="rounded bg-amber-100 px-2 py-1 text-[9px] font-semibold text-amber-700 hover:bg-amber-200 dark:bg-amber-950 dark:text-amber-300"
title={translate(
'::App.DeveloperKit.ComponentDesigner.AppendToEventScript',
{ event: activeEventName },
)}
type="button"
onClick={() => appendScriptToEvent(recipeCode)}
>
{translate('::ListForms.Wizard.Add')}
</button>
)}
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 dark:bg-sky-950 dark:text-sky-300"
type="button"
onClick={() =>
copyToClipboard(
recipeCode,
translate('::App.DeveloperKit.ComponentDesigner.ExampleCopied'),
)
}
>
{translate('::App.SqlQueryManager.Copy')}
</button>
</span>
</div>
<pre className="mt-2 overflow-x-auto bg-slate-950 px-3 py-2 font-mono text-[10px] leading-4 text-sky-200">
{recipeCode}
</pre>
</div>
)
})}
</div>
</div>
)
})}
{!filteredScriptRecipes.length && (
<p className="rounded-md border border-dashed border-slate-300 p-4 text-center text-xs text-slate-500 dark:border-slate-700">
{translate('::App.DeveloperKit.ComponentDesigner.NoMatchingExample')}
</p>
)}
</div>
</aside>
)}
{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">
{translate(
catalogSourceEditor.item
? '::App.DeveloperKit.ComponentDesigner.EditEndpointTitle'
: '::App.DeveloperKit.ComponentDesigner.NewEndpoint',
)}
</h2>
<p className="mt-1 text-[10px] text-slate-500">
{catalogSourceEditor.item?.origin ||
translate('::App.DeveloperKit.ComponentDesigner.NewEndpointHint')}
</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={translate('::App.Platform.Close')}
type="button"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
×
</button>
</div>
<div className="space-y-4 p-5">
<div className="grid gap-4 md:grid-cols-[1fr_110px]">
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Ad
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
value={catalogSourceEditor.draft.name}
onChange={(event) => updateCatalogSourceDraft({ name: event.target.value })}
/>
</label>
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Metot
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
value={catalogSourceEditor.draft.method}
onChange={(event) =>
updateCatalogSourceDraft({
method: event.target.value as DesignerHttpMethod,
})
}
>
{DESIGNER_HTTP_METHODS.map((method) => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Endpoint URL
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
placeholder="/api/app/customers"
value={catalogSourceEditor.draft.url}
onChange={(event) => updateCatalogSourceDraft({ url: event.target.value })}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
{translate('::App.DeveloperKit.ComponentDesigner.ResponseRootPath')}{' '}
<span className="font-normal normal-case">
({translate('::Public.common.optional')})
</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)
}}
>
{translate('::Cancel')}
</Button>
<Button
disabled={
catalogSourceEditor.draft.method !== 'GET' ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url)
}
icon={<FaPlay />}
loading={catalogSourceTestResult?.status === 'loading'}
size="sm"
title={
catalogSourceEditor.draft.method === 'GET'
? undefined
: translate('::App.DeveloperKit.ComponentDesigner.OnlyGetRunnable')
}
variant="default"
onClick={() => void testCatalogSource()}
>
{translate('::App.Platform.Execute')}
</Button>
<Button
disabled={
!catalogSourceEditor.draft.name.trim() ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url) ||
(catalogSourceEditor.draft.method === 'GET' &&
catalogSourceTestResult?.status !== 'success')
}
icon={<FaRegSave />}
loading={isCatalogSourceSaving}
size="sm"
variant="solid"
onClick={() => void saveCatalogSource()}
>
{translate(
catalogSourceEditor.item
? '::App.SaveChanges'
: '::App.DeveloperKit.ComponentDesigner.AddEndpoint',
)}
</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">
{translate('::App.Platform.Success')}
</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={translate('::App.DeveloperKit.ComponentDesigner.CloseResultModal')}
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)}>
{translate('::App.Platform.Close')}
</Button>
</div>
</div>
</div>
)}
{scriptBuilderTarget && scriptBuilderNode && (
<DesignerScriptBuilderDialog
isOpen
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
eventInfo={getSqlDataSourceEvent(scriptBuilderTarget.eventName)}
eventName={scriptBuilderTarget.eventName}
recordFields={scriptBuilderRecordFields}
refNames={[...documentRefs.keys()]}
sqlRef={scriptBuilderSqlNode?.ref || ''}
value={scriptBuilderNode.events[scriptBuilderTarget.eventName] || ''}
onApply={(next) =>
updateNodeEvent(scriptBuilderTarget.nodeId, scriptBuilderTarget.eventName, next)
}
onClose={() => setScriptBuilderTarget(null)}
/>
)}
</div>
)
}
export default VisualComponentDesigner