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