5062 lines
211 KiB
TypeScript
5062 lines
211 KiB
TypeScript
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('…')
|
||
</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('sutun')
|
||
</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
|