sozsoft-platform/ui/src/views/developerKit/VisualComponentDesigner.tsx
2026-09-05 21:20:18 +03:00

6491 lines
272 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import Editor from '@monaco-editor/react'
import axios from 'axios'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import PageTitle from '@/components/shared/PageTitle'
import { Link, useParams } from 'react-router-dom'
import {
FaArrowLeft,
FaBolt,
FaCheckCircle,
FaArrowDown,
FaArrowUp,
FaChevronDown,
FaChevronRight,
FaCode,
FaDesktop,
FaDatabase,
FaEye,
FaEdit,
FaLayerGroup,
FaMobileAlt,
FaMousePointer,
FaPlus,
FaPlay,
FaRedo,
FaRegSave,
FaSearch,
FaSlidersH,
FaSync,
FaTabletAlt,
FaTimes,
FaTrash,
FaUndo,
} from 'react-icons/fa'
import { useComponents } from '@/contexts/ComponentContext'
import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models'
import { ROUTES_ENUM } from '@/routes/route.constant'
import apiService from '@/services/api.service'
import { getList } from '@/services/form.service'
import type { GridDto } from '@/proxy/form/models'
import { getListForms } from '@/services/admin/list-form.service'
import { developerKitService } from '@/services/developerKit.service'
import { Button, Notification, Select, toast } from '@/components/ui'
import StyleModal from '@/components/codeLayout/StyleModal'
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
beginDesignerDragSoon,
endDesignerDrag,
useDesignerDrag,
} from '@/components/visualDesigner/designerDrag'
import {
DESIGNER_PRIMARY_EVENTS,
DESIGNER_SCRIPT_RECIPES,
DESIGNER_SCRIPT_RECIPE_GROUPS,
getDesignerCatalog,
getDesignerEventSnippet,
} from '@/components/visualDesigner/catalog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
import {
collectDesignerRefs,
getFormEvent,
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
isValidDesignerRef,
renameDesignerRefInNodes,
renameDesignerRefInScript,
withDesignerRefs,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerValueByPath,
getFormEndpointId,
getFormKeyField,
getFormKeyParam,
getFormKeySource,
getFormPreviewKey,
getFormRecord,
getSqlRecordProperty,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
DESIGNER_ICON_PROPERTIES,
isDesignerOptionComponent,
isDesignerTabularComponent,
isFormNode,
resolveDesignerResponse,
toDesignerHttpMethod,
walkDesignerNodes,
appendFormQueryParam,
bindFormUrl,
hasFormUrlParams,
DESIGNER_HTTP_METHODS,
DESIGNER_PERMISSION_PROP,
FORM_AUTO_SUFFIXES,
getDesignerNodePermission,
getFormPermission,
FORM_KEY_SOURCES,
FORM_SLOTS,
SQL_DEFAULT_VALUE_PROP,
SQL_DEFAULT_VALUE_TOKENS,
type DesignerComponentDefinition,
type DesignerBinding,
buildDesignerPreviewUrl,
getDesignerColumnCaptions,
getDesignerColumnLookups,
type DesignerColumnLookup,
createDesignerFilter,
DESIGNER_FILTER_OPERATORS,
DESIGNER_FILTER_SOURCES,
isValuelessDesignerFilter,
type DesignerDataSource,
type DesignerDataSourceFilter,
type DesignerFilterOperator,
type DesignerFilterSource,
type DesignerDocument,
type DesignerHttpMethod,
type DesignerNode,
type DesignerPropertyInfo,
type SqlFormValueProperty,
} from '@/components/visualDesigner/types'
import { IconPickerField } from '@/views/shared/MenuAddDialog'
import { useLocalization } from '@/utils/hooks/useLocalization'
import Input from '@/components/ui/Input'
import CreatableSelect from 'react-select/creatable'
import { getPermissionsList } from '@/services/identity.service'
import { getMenus } from '@/services/menu.service'
import type { PermissionDefinitionRecord } from '@/proxy/admin/models'
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; content?: string; icon?: string }
type DataSourceCatalogItem = {
attached: boolean
key: string
origin: string
originType: 'current' | 'component' | 'crud'
ownerComponentId?: string
source: DesignerDataSource
}
const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'items',
'data',
'dataSource',
'options',
'value',
'checked',
'children',
'content',
'text',
'label',
'title',
'header',
'headerExtra',
'footer',
'description',
'placeholder',
'src',
'alt',
'disabled',
'loading',
])
/**
* Properties worth binding to a Form record column, most used first.
* A component exposes dozens of props; offering every one of them turns the
* panel into noise, so only the ones that actually carry record data are listed.
*/
const SQL_RECORD_FIELD_PROPERTIES = [
'value',
'checked',
'children',
'text',
'label',
'title',
'placeholder',
'src',
'url',
'href',
'alt',
'content',
'description',
'header',
'footer',
'defaultValue',
'disabled',
]
const getSqlRecordFieldOrder = (propertyName: string) =>
SQL_RECORD_FIELD_PROPERTIES.indexOf(propertyName)
// Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is
// covered by the static/endpoint panel by construction.
const isOptionDataComponent = isDesignerOptionComponent
const getOptionDataProperty = getDesignerCollectionProperty
const isTabularDataComponent = isDesignerTabularComponent
/**
* The inspector's Data tab follows the toolbox category, not the collection
* registry: everything filed under `data` is expected to be data driven, even
* when it carries no collection property of its own.
*/
const isDataCategoryComponent = (definition?: DesignerComponentDefinition) =>
definition?.toolboxGroup === 'data'
type TranslateFn = (key: string, params?: Record<string, string | number>) => string
const getPropertyExampleValues = (translate: TranslateFn): Record<string, unknown> => ({
children: translate('::App.ComponentDesignerExample.ExampleContent'),
className: 'p-4 rounded-lg shadow-sm',
id: 'component-id',
label: translate('::App.ComponentDesignerExample.ExampleTitle'),
name: 'exampleName',
placeholder: translate('::App.ComponentDesignerExample.ExamplePlaceholder'),
src: '/img/example.png',
title: translate('::App.ComponentDesignerExample.ExampleTitle'),
url: '/api/app/example',
})
const getPropertyExampleValue = (
property: DesignerPropertyInfo,
currentValue: unknown,
translate: TranslateFn,
): unknown => {
const hasCurrentValue =
currentValue !== undefined &&
currentValue !== null &&
currentValue !== '' &&
(!Array.isArray(currentValue) || currentValue.length > 0) &&
(typeof currentValue !== 'object' ||
Array.isArray(currentValue) ||
Object.keys(currentValue as Record<string, unknown>).length > 0)
if (hasCurrentValue || typeof currentValue === 'boolean' || typeof currentValue === 'number') {
return currentValue
}
if (property.options?.length) return property.options[0]
const exampleValues = getPropertyExampleValues(translate)
if (exampleValues[property.name] !== undefined) {
return exampleValues[property.name]
}
if (property.name.toLowerCase().includes('color')) return '#0ea5e9'
if (property.type === 'boolean') return true
if (property.type === 'number') return 16
if (property.type === 'array') {
return [{ label: translate('::App.Platform.Example'), value: 'example' }]
}
if (property.type === 'object') {
return property.name === 'style' ? { padding: 16, borderRadius: 8 } : { key: 'value' }
}
return translate('::App.ComponentDesignerExample.ExampleValue')
}
const getPropertySnippet = (
property: DesignerPropertyInfo,
currentValue: unknown,
translate: TranslateFn,
) => {
const exampleValue = getPropertyExampleValue(property, currentValue, translate)
return typeof exampleValue === 'string'
? `${property.name}=${JSON.stringify(exampleValue)}`
: `${property.name}={${JSON.stringify(exampleValue)}}`
}
const getDataValueType = (value: unknown) => {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value
}
const getDataPreview = (value: unknown) => {
const text =
typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value ?? 'undefined'))
return text.length > 48 ? `${text.slice(0, 45)}` : text
}
const discoverDataFields = (sample: unknown): DataField[] => {
const fields: DataField[] = []
const root = Array.isArray(sample) ? sample[0] : sample
const visit = (value: unknown, path: string, depth: number) => {
if (!path || fields.length >= 100) {
if (!path && value && typeof value === 'object' && !Array.isArray(value)) {
Object.entries(value as Record<string, unknown>).forEach(([key, child]) =>
visit(child, key, depth),
)
}
return
}
const valueType = getDataValueType(value)
if (valueType === 'object' && depth < 4) {
Object.entries(value as Record<string, unknown>).forEach(([key, child]) =>
visit(child, `${path}.${key}`, depth + 1),
)
return
}
fields.push({ path, type: valueType, preview: getDataPreview(value) })
if (Array.isArray(value) && value[0] && typeof value[0] === 'object' && depth < 4) {
Object.entries(value[0] as Record<string, unknown>).forEach(([key, child]) =>
visit(child, `${path}.${key}`, depth + 1),
)
}
}
visit(root, '', 0)
return fields
}
const findDesignerAncestors = (
nodes: DesignerNode[],
id: string | null,
ancestors: DesignerNode[] = [],
): DesignerNode[] | null => {
if (!id) return null
for (const node of nodes) {
if (node.id === id) return ancestors
const found = findDesignerAncestors(node.children, id, [...ancestors, node])
if (found) return found
}
return null
}
const getSaveErrorMessage = (error: unknown, translate: TranslateFn) => {
if (!axios.isAxiosError(error)) {
return error instanceof Error
? error.message
: translate('::App.DeveloperKitComponentDesigner.SaveFailed')
}
const responseData = error.response?.data as
{ error?: { message?: string; details?: string }; message?: string } | undefined
return (
responseData?.error?.details ||
responseData?.error?.message ||
responseData?.message ||
error.message ||
translate('::App.DeveloperKitComponentDesigner.SaveFailed')
)
}
/** Hint under a permission field, worded for what that command actually does. */
const PERMISSION_HINT_KEYS: Record<string, string> = {
selectPermission: '::App.DeveloperKitComponentDesigner.PermissionHintRead',
insertPermission: '::App.DeveloperKitComponentDesigner.PermissionHintInsert',
updatePermission: '::App.DeveloperKitComponentDesigner.PermissionHintUpdate',
deletePermission: '::App.DeveloperKitComponentDesigner.PermissionHintDelete',
}
/** Route comparison that ignores a leading slash and letter case. */
const normalizeRoutePath = (value?: string | null) =>
String(value ?? '')
.trim()
.replace(/^\/+|\/+$/g, '')
.toLocaleLowerCase('tr')
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',
]
/**
* The layout group reads outside in - page, row, table - instead of alphabetically,
* because that is the order a page is actually built in.
*/
const LAYOUT_TOOLBOX_ORDER = ['PageContainer', 'FlexRow', 'Table']
const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = {
responsive: '100%',
desktop: '1180px',
tablet: '768px',
mobile: '390px',
}
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
const normalizeTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const updateNodeTree = (
nodes: DesignerNode[],
id: string,
updater: (node: DesignerNode) => DesignerNode,
): DesignerNode[] =>
nodes.map((node) => {
if (node.id === id) return updater(node)
return { ...node, children: updateNodeTree(node.children, id, updater) }
})
const removeNodeTree = (nodes: DesignerNode[], id: string): DesignerNode[] =>
nodes
.filter((node) => node.id !== id)
.map((node) => ({
...node,
children: removeNodeTree(node.children, id),
}))
const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] =>
nodes.map((node) => {
const props = { ...node.props }
// A column lookup names its endpoint the same way a binding does, and the
// generated grid reads that endpoint's state directly — a mapping left
// behind by a detached source would compile into an undefined variable.
const lookups = Object.fromEntries(
Object.entries(getDesignerColumnLookups(node)).filter(
([, lookup]) => lookup.sourceId !== sourceId,
),
)
if (props.columnLookups) props.columnLookups = lookups
return {
...node,
props,
bindings: Object.fromEntries(
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
),
children: removeDataSourceBindings(node.children, sourceId),
}
})
const insertNodeTree = (
nodes: DesignerNode[],
nodeToInsert: DesignerNode,
parentId: string | null,
): DesignerNode[] => {
if (!parentId) return [...nodes, nodeToInsert]
return updateNodeTree(nodes, parentId, (node) => ({
...node,
children: [...node.children, nodeToInsert],
}))
}
const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
const result: DesignerNode[] = []
nodes.forEach((node) => {
result.push({ ...node, children: duplicateInTree(node.children, id) })
if (node.id === id) {
const copy = clone(node)
const renewIds = (item: DesignerNode) => {
item.id = createDesignerId()
item.children.forEach(renewIds)
}
renewIds(copy)
result.push(copy)
}
})
return result
}
const insertRelativeToNode = (
nodes: DesignerNode[],
targetId: string,
nodeToInsert: DesignerNode,
placement: 'before' | 'after',
): DesignerNode[] => {
const targetIndex = nodes.findIndex((node) => node.id === targetId)
if (targetIndex >= 0) {
const next = [...nodes]
next.splice(targetIndex + (placement === 'after' ? 1 : 0), 0, nodeToInsert)
return next
}
return nodes.map((node) => ({
...node,
children: insertRelativeToNode(node.children, targetId, nodeToInsert, placement),
}))
}
/**
* Moves a node one step up/down. Swapping with a sibling only works while the
* node has one, so a node that sits alone in a container (or at the edge of one)
* is lifted out and placed next to its parent instead — otherwise the arrows
* would silently do nothing, which is how they used to behave.
*/
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
const ancestors = findDesignerAncestors(nodes, id)
if (!ancestors) return nodes
const parent = ancestors[ancestors.length - 1]
const siblings = parent ? parent.children : nodes
const index = siblings.findIndex((node) => node.id === id)
if (index < 0) return nodes
// Tabs children live in separate tabs, so only components sharing a tab are
// neighbours — otherwise the arrows would swap with something not on screen.
const group =
parent?.type === 'Tabs'
? siblings.filter((node) => (node.slot || '') === (siblings[index].slot || ''))
: siblings
const groupIndex = group.findIndex((node) => node.id === id)
const targetNode = group[groupIndex + direction]
if (targetNode) {
const target = siblings.findIndex((node) => node.id === targetNode.id)
const next = [...siblings]
;[next[index], next[target]] = [next[target], next[index]]
// In a Table the visible cell comes from `slot`, not from array order, so the
// slots have to stay with the position for the swap to be visible.
if (parent?.type === 'Table') {
next[index] = { ...next[index], slot: siblings[index].slot }
next[target] = { ...next[target], slot: siblings[target].slot }
}
return parent ? updateNodeTree(nodes, parent.id, (node) => ({ ...node, children: next })) : next
}
// Already first/last: at the root there is nowhere left to go.
if (!parent) return nodes
const movingNode = { ...siblings[index], slot: undefined }
const withoutNode = updateNodeTree(nodes, parent.id, (node) => ({
...node,
children: node.children.filter((child) => child.id !== id),
}))
return insertRelativeToNode(
withoutNode,
parent.id,
movingNode,
direction === -1 ? 'before' : 'after',
)
}
/**
* Table cells are addressed by `slot`, and children without one fall back to
* their positional index. Materialising the effective slots keeps every existing
* cell in place while a node is added to or removed from the table.
*/
const withResolvedTableSlots = (table: DesignerNode): DesignerNode => {
const columnCount = normalizeTableDimension(table.props.columnCount, 3)
return {
...table,
children: table.children.map((child, childIndex) => ({
...child,
slot:
child.slot || `table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`,
})),
}
}
const resolveAllTableSlots = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const resolved = { ...node, children: resolveAllTableSlots(node.children) }
return resolved.type === 'Table' ? withResolvedTableSlots(resolved) : resolved
})
/**
* Moves an existing node into a container (or to the root when `parentId` is
* null). Used by drag & drop so a component can leave a Table cell and be put
* back into one.
*/
const moveNodeIntoContainer = (
nodes: DesignerNode[],
nodeId: string,
parentId: string | null,
slot?: string,
): DesignerNode[] => {
const source = findDesignerNode(nodes, nodeId)
if (!source || parentId === nodeId) return nodes
// Dropping a node inside its own subtree would detach that subtree.
if (parentId && findDesignerNode(source.children, parentId)) return nodes
// Pin every table cell before the tree changes, so removing the node does not
// shift the siblings that still rely on their positional fallback.
const pinned = resolveAllTableSlots(nodes)
const movingNode = { ...(findDesignerNode(pinned, nodeId) || source), slot }
const remaining = removeNodeTree(pinned, nodeId)
if (!parentId) return [...remaining, movingNode]
return updateNodeTree(remaining, parentId, (parent) => ({
...parent,
children: [...parent.children, movingNode],
}))
}
const reorderNodeTree = (
nodes: DesignerNode[],
sourceId: string,
targetId: string,
placement: 'before' | 'after',
): DesignerNode[] => {
const source = findDesignerNode(nodes, sourceId)
const target = findDesignerNode(nodes, targetId)
if (!source || !target || sourceId === targetId || findDesignerNode(source.children, targetId)) {
return nodes
}
const movingNode = { ...source, slot: target.slot }
return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement)
}
/**
* Default values that ship as localization keys - option labels, menu captions -
* so a new component starts with readable text instead of `App.Catalog...`. The
* resolved text is what gets stored, because from that point on it is ordinary
* user content the designer edits like any other value.
*/
const localizeDesignerDefaults = (value: unknown, translate: TranslateFn): unknown => {
if (typeof value === 'string') {
return value.startsWith('App.') ? translate('::' + value) : value
}
if (Array.isArray(value)) return value.map((item) => localizeDesignerDefaults(item, translate))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
localizeDesignerDefaults(item, translate),
]),
)
}
return value
}
const definitionToNode = (
definition: DesignerComponentDefinition,
translate: TranslateFn,
): 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] = localizeDesignerDefaults(clone(property.value), translate)
}
})
return {
id: createDesignerId(),
type: definition.name,
kind: definition.kind,
props,
events,
bindings: {},
children: [],
}
}
/**
* Node types that were renamed or merged away. Rewriting them on load is what
* makes an existing page show its current type everywhere it names itself - the
* toolbox, the layer tree and the inspector all read it.
*/
const LEGACY_NODE_TYPES: Record<string, string> = {
TwoColumns: 'FlexRow',
SidebarContent: 'FlexRow',
HeaderContent: 'PageContainer',
}
const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => {
const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const props = { ...(node.props || {}) }
const events = { ...(node.events || {}) }
const bindings = { ...(node.bindings || {}) }
if (node.type === 'Select') {
const legacyPropAliases: Record<string, string> = {
clearable: 'isClearable',
disabled: 'isDisabled',
multiple: 'isMulti',
searchable: 'isSearchable',
}
Object.entries(legacyPropAliases).forEach(([legacyName, runtimeName]) => {
if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
props[runtimeName] = props[legacyName]
}
delete props[legacyName]
})
if (events.onInputChange === undefined && events.onSearch !== undefined) {
events.onInputChange = events.onSearch
}
delete events.onSearch
}
if (node.type === 'Menu') {
if (!Array.isArray(props.items) && Array.isArray(props.options)) {
props.items = props.options
}
if (!bindings.items && bindings.options) bindings.items = bindings.options
delete props.options
delete props.variant
delete bindings.options
}
// The two column, sidebar and header layouts were folded away: a column
// count and an optional pinned first column turn FlexRow into the first
// two, and the header stack is a PageContainer without the page framing.
if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
props.columns = 2
if (node.type === 'SidebarContent') {
props.firstColumnWidth = String(props.firstColumnWidth || props.sidebarWidth || '280px')
}
delete props.sidebarWidth
}
if (node.type === 'HeaderContent') {
if (props.maxWidth === undefined) props.maxWidth = '100%'
if (props.padding === undefined) props.padding = 0
}
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,
type: LEGACY_NODE_TYPES[node.type] || node.type,
props,
events,
bindings,
children,
}
})
return {
...document,
// Documents saved before refs existed get theirs here, so every component is
// addressable from a script the moment it is opened.
nodes: withDesignerRefs(normalizeNodes(document.nodes)),
canvas: { width: document.canvas?.width || 'responsive' },
// Documents saved before non-GET sources existed have no usable method value.
dataSources: (Array.isArray(document.dataSources) ? document.dataSources : []).map(
(source) => ({ ...source, method: toDesignerHttpMethod(source.method) }),
),
}
}
const parseDesignerDocument = (rawProps: string | undefined): DesignerDocument | null => {
if (!rawProps) return null
try {
const parsed = JSON.parse(rawProps)
const document = parsed?.visualDesigner
if (document?.version === 1 && Array.isArray(document.nodes)) {
return normalizeDesignerDocument(document)
}
} catch {
return null
}
return null
}
const parseDesignerDocumentFromCode = (code: string | undefined): DesignerDocument | null => {
const encodedDocument = code?.match(/\/\*__SOZSOFT_VISUAL_DESIGNER__(.*?)__\*\//)?.[1]
if (!encodedDocument) return null
try {
const document = JSON.parse(decodeURIComponent(encodedDocument)) as DesignerDocument
return document?.version === 1 && Array.isArray(document.nodes)
? normalizeDesignerDocument(document)
: null
} catch {
return null
}
}
const parseComponentDefaultProps = (code: string): Record<string, unknown> => {
const defaults: Record<string, unknown> = {}
const parameters = code.match(
/(?:const\s+[A-Za-z_$][\w$]*\s*(?::[^=]+)?=\s*|function\s+[A-Za-z_$][\w$]*\s*)\(\s*\{([\s\S]*?)\}\s*\)/,
)?.[1]
if (!parameters) return defaults
for (const match of parameters.matchAll(
/([A-Za-z_$][\w$]*)\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|true|false|null|-?\d+(?:\.\d+)?)/g,
)) {
const rawValue = match[2]
try {
defaults[match[1]] = rawValue.startsWith("'")
? rawValue.slice(1, -1).replace(/\\'/g, "'")
: JSON.parse(rawValue)
} catch {
defaults[match[1]] = rawValue
}
}
return defaults
}
const parseGeneratedJsxProps = (
source: string,
expressionValues: Record<string, unknown> = {},
): Record<string, unknown> => {
const props: Record<string, unknown> = {}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)="((?:\\.|[^"])*)"/g)) {
try {
props[match[1]] = JSON.parse(`"${match[2]}"`)
} catch {
props[match[1]] = match[2]
}
}
for (const match of source.matchAll(
/([A-Za-z_$][\w$]*)=\{(true|false|null|-?\d+(?:\.\d+)?)\}/g,
)) {
props[match[1]] =
match[2] === 'true'
? true
: match[2] === 'false'
? false
: match[2] === 'null'
? null
: Number(match[2])
}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)=\{([A-Za-z_$][\w$]*)\}/g)) {
if (Object.prototype.hasOwnProperty.call(expressionValues, match[2])) {
props[match[1]] = expressionValues[match[2]]
}
}
return props
}
const migrateGeneratedCodeToDesignerDocument = (
code: string | undefined,
definitions: Map<string, DesignerComponentDefinition>,
): DesignerDocument | null => {
if (!code || !code.includes('export default')) return null
const document = createEmptyDesignerDocument('visual')
const componentDefaultProps = parseComponentDefaultProps(code)
const positionedNodes: Array<{ index: number; node: DesignerNode }> = []
const addNode = (
index: number,
type: string,
kind: DesignerNode['kind'],
props: Record<string, unknown> = {},
bindings: DesignerNode['bindings'] = {},
id = createDesignerId(),
) =>
positionedNodes.push({
index,
node: { id, type, kind, props, bindings, events: {}, children: [] },
})
for (const stateMatch of code.matchAll(
/const \[data_([A-Za-z0-9_$]+), setData_\1\] = React\.useState\(null\)([\s\S]*?)(?=\n\s*const \[data_|\n\s*const [A-Za-z_$][\w$]* = \(\{|$)/g,
)) {
const sourceId = stateMatch[1]
const block = stateMatch[2]
const urlMatch = block.match(/fetchData\(\{\s*url:\s*("(?:\\.|[^"])*")/)
if (!urlMatch) continue
const responsePathMatch = block.match(
new RegExp(`setData_${sourceId}\\(getByPath\\(response\\.data,\\s*("(?:\\\\.|[^"])*")`),
)
document.dataSources.push({
id: sourceId,
name: sourceId.replace(/^source_/, '').replace(/_/g, ' '),
method: 'GET',
url: JSON.parse(urlMatch[1]),
responsePath: responsePathMatch ? JSON.parse(responsePathMatch[1]) : '',
})
}
for (const match of code.matchAll(
/<UiKit\.(Select|AutoComplete|Menu|Pagination)\b([^>]*?)(?:\/>|>)/g,
)) {
const type = match[1]
const attributes = match[2]
const props = parseGeneratedJsxProps(attributes, componentDefaultProps)
const bindings: DesignerNode['bindings'] = {}
if (type === 'Select' || type === 'AutoComplete' || type === 'Menu') {
const optionsMatch = attributes.match(
/options=\{toSelectOptions\(getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\),\s*("(?:\\.|[^"])*"),\s*("(?:\\.|[^"])*")\)\}/,
)
if (optionsMatch) {
bindings.options = {
sourceId: optionsMatch[1],
path: JSON.parse(optionsMatch[2]),
labelPath: JSON.parse(optionsMatch[3]),
valuePath: JSON.parse(optionsMatch[4]),
}
}
}
addNode(match.index, type, 'ui', props, bindings)
}
for (const match of code.matchAll(
/const tableItems_([A-Za-z0-9_$]+)\s*=\s*(\[[^\n]*\])[\s\S]*?const tableColumns_\1\s*=\s*(\[[^\n]*\])[\s\S]*?<UiKit\.Table\b([^>]*)>/g,
)) {
try {
const legacyItems = JSON.parse(match[2]) as unknown[]
const legacyColumns = JSON.parse(match[3]) as unknown[]
addNode(
match.index,
'Table',
'ui',
{
...parseGeneratedJsxProps(match[4], componentDefaultProps),
rowCount: normalizeTableDimension(legacyItems.length, 2),
columnCount: normalizeTableDimension(legacyColumns.length, 3),
},
{},
match[1],
)
} catch {
// Ignore a table block that is not in the generated designer format.
}
}
for (const match of code.matchAll(
/const gridItems_([A-Za-z0-9_$]+)\s*=\s*([^\n]+)[\s\S]*?const gridColumns_\1\s*=\s*(\[[^\n]*\])/g,
)) {
const bindingMatch = match[2].match(/getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\)/)
const bindings: DesignerNode['bindings'] = {}
if (bindingMatch) {
bindings.items = { sourceId: bindingMatch[1], path: JSON.parse(bindingMatch[2]) }
}
try {
addNode(
match.index,
'Grid',
'ui',
{
className:
code
.slice(Math.max(0, match.index - 250), match.index)
.match(/<div className="([^"]*)"[^>]*>\s*\{\(\(\) => \{$/)?.[1] || '',
dataColumns: JSON.parse(match[3]),
compact: !code.slice(match.index, match.index + 5000).includes('px-3 py-2'),
hoverable: code.slice(match.index, match.index + 5000).includes('hover:bg-'),
overflow: code.slice(match.index, match.index + 5000).includes('overflow-auto'),
borderlessRow: !code.slice(match.index, match.index + 5000).includes('border-b'),
},
bindings,
match[1],
)
} catch {
// Ignore a grid block that is not in the generated designer format.
}
}
for (const match of code.matchAll(/<PlatformViewHost\b([^>]*?)\/>/g)) {
const props = parseGeneratedJsxProps(match[1], componentDefaultProps)
const view = String(props.view || '')
const platformTypes: Record<string, string> = {
List: 'ListView',
Edit: 'EditView',
Gantt: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
Pivot: 'PivotView',
Chart: 'ChartView',
}
addNode(match.index, platformTypes[view] || view || 'ListView', 'platform', props)
}
for (const match of code.matchAll(/<img\b([^>]*?)\/>/g)) {
addNode(match.index, 'img', 'html', parseGeneratedJsxProps(match[1], componentDefaultProps))
}
const ignoredCustomTags = new Set(['UiKit', 'PlatformViewHost', 'React', 'Fragment'])
for (const match of code.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b([^>]*?)\/>/g)) {
if (ignoredCustomTags.has(match[1]) || match[1].startsWith('UiKit')) continue
const definition = definitions.get(match[1])
addNode(
match.index,
match[1],
definition?.kind || 'custom',
parseGeneratedJsxProps(match[2], componentDefaultProps),
)
}
document.nodes = positionedNodes
.sort((left, right) => left.index - right.index)
.map(({ node }) => node)
return document.nodes.length ? document : null
}
/**
* Array-of-object properties otherwise fall back to a raw JSON textarea, which is
* unusable for anything but trivial values. Components listed here get a proper
* row editor instead, keyed by `<component>.<property>`.
*/
const OBJECT_LIST_PROPERTIES: Record<
string,
{ itemLabel: string; fields: Array<{ name: string; label: string; placeholder?: string }> }
> = {
'ImageViewer.images': {
itemLabel: 'App.ComponentDesignerImageList.ImageListItem',
fields: [
{
name: 'src',
label: 'App.ComponentDesignerImageList.Src',
placeholder: 'https://…',
},
{
name: 'thumbnail',
label: 'App.ComponentDesignerImageList.Thumbnail',
placeholder: 'App.ComponentDesignerImageList.ThumbnailPlaceholder',
},
{
name: 'alt',
label: 'App.ComponentDesignerImageList.Alt',
placeholder: 'App.ComponentDesignerImageList.AltPlaceholder',
},
{
name: 'caption',
label: 'App.Listform.ListformField.Description',
placeholder: 'App.ComponentDesignerImageList.CaptionPlaceholder',
},
],
},
}
const ObjectListEditor = ({
schema,
value,
onChange,
}: {
schema: (typeof OBJECT_LIST_PROPERTIES)[string]
value: unknown
onChange: (value: unknown) => void
}) => {
const { translate } = useLocalization()
const items = Array.isArray(value) ? (value as Array<Record<string, unknown>>) : []
const inputClass =
'w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] text-slate-800 outline-none focus:border-sky-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-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-gray-700"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-slate-500">
{translate('::' + schema.itemLabel)} {index + 1}
</span>
<div className="flex items-center gap-0.5">
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === 0}
title={translate('::App.Platform.MoveUp')}
type="button"
onClick={() => move(index, -1)}
>
<FaArrowUp className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-sky-600 disabled:opacity-30"
disabled={index === items.length - 1}
title={translate('::App.Platform.MoveDown')}
type="button"
onClick={() => move(index, 1)}
>
<FaArrowDown className="h-2.5 w-2.5" />
</button>
<button
className="rounded px-1 text-slate-400 hover:text-red-600"
title={translate('::App.Platform.Delete')}
type="button"
onClick={() => onChange(items.filter((_, i) => i !== index))}
>
<FaTrash className="h-2.5 w-2.5" />
</button>
</div>
</div>
{item.src ? (
<img
alt=""
className="h-16 w-full rounded border border-slate-200 object-cover dark:border-gray-700"
src={String(item.thumbnail || item.src)}
/>
) : null}
{schema.fields.map((field) => (
<label key={field.name} className="block">
<span className="mb-0.5 block text-[9px] font-medium uppercase tracking-wide text-slate-400">
{translate('::' + field.label)}
</span>
<Input
unstyle
className={inputClass}
placeholder={
field.placeholder?.startsWith('App.')
? translate('::' + field.placeholder)
: field.placeholder
}
type="text"
value={String(item[field.name] ?? '')}
onChange={(event) =>
replaceItem(index, { ...item, [field.name]: event.target.value })
}
/>
</label>
))}
</div>
))}
<button
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-gray-700"
type="button"
onClick={() => onChange([...items, { src: '' }])}
>
<FaPlus /> {schema.itemLabel} ekle
</button>
</div>
)
}
const PropertyEditor = ({
componentType,
name,
type,
value,
options,
onChange,
}: {
componentType?: string
name: string
type: string
value: unknown
options?: string[]
onChange: (value: unknown) => void
}) => {
const { translate } = useLocalization()
const [styleModalOpen, setStyleModalOpen] = useState(false)
const inputClass =
'w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs text-slate-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-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-gray-700">
<span className="text-xs text-slate-600 dark:text-gray-300">
{value ? 'Enabled' : 'Disabled'}
</span>
<Input
unstyle
checked={Boolean(value)}
className="h-4 w-4 accent-sky-600"
type="checkbox"
onChange={(event) => onChange(event.target.checked)}
/>
</label>
)
}
if (type === 'select' && options) {
const currentValue = String(value ?? '')
return (
/* 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. */
<Select
size="xs"
className="w-full"
menuPortalTarget={window.document.body}
options={options.map((option) => ({ value: option, label: option }))}
value={
options.includes(currentValue)
? { value: currentValue, label: currentValue }
: { value: '', label: translate('::Abp.Mailing.Default') }
}
onChange={(option) => onChange(option?.value ?? '')}
/>
)
}
// An icon prop holds a name; the picker is what makes that name findable and
// shows what it looks like, exactly as the menu editor does.
if (DESIGNER_ICON_PROPERTIES.has(name) && type === 'string') {
return <IconPickerField value={String(value ?? '')} onChange={onChange} />
}
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
}
if (type === 'object' || type === 'array') {
return (
<textarea
className={`${inputClass} min-h-20 font-mono`}
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
onChange={(event) => {
try {
onChange(JSON.parse(event.target.value))
} catch {
onChange(event.target.value)
}
}}
/>
)
}
const normalizedPropertyName = name.toLowerCase()
const isCssProperty = type === 'string' && normalizedPropertyName.includes('css')
const isClassProperty =
type === 'string' &&
(normalizedPropertyName === 'class' ||
normalizedPropertyName.endsWith('class') ||
normalizedPropertyName.endsWith('classname'))
if (isClassProperty || isCssProperty) {
const currentClasses = String(value ?? '')
return (
<>
<div className="flex gap-2">
{isCssProperty ? (
<textarea
className={`${inputClass} min-h-20 font-mono`}
placeholder={translate('::App.DeveloperKitComponentDesigner.CssPlaceholder')}
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<Input
unstyle
className={`${inputClass} font-mono`}
placeholder={translate('::App.DeveloperKitComponentDesigner.ClassPlaceholder')}
type="text"
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
)}
<Button
icon={<FaBolt />}
size="sm"
title={translate('::App.DeveloperKitComponentDesigner.EditWithStyleModal', { name })}
variant="default"
onClick={() => setStyleModalOpen(true)}
/>
</div>
<StyleModal
isOpen={styleModalOpen}
mode={isCssProperty ? 'css' : 'class'}
propertyName={name}
value={currentClasses}
onChange={onChange}
onClose={() => setStyleModalOpen(false)}
/>
</>
)
}
const multiline = name === 'children' || name === 'html' || String(value ?? '').length > 80
if (multiline) {
return (
<textarea
className={`${inputClass} min-h-20`}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
/>
)
}
return (
<Input
unstyle
className={inputClass}
placeholder={type === 'string' ? 'Metin veya ::LanguageKey' : undefined}
type={type === 'number' ? 'number' : name.toLowerCase().includes('color') ? 'color' : 'text'}
value={String(value ?? '')}
onChange={(event) =>
onChange(type === 'number' ? Number(event.target.value) : event.target.value)
}
/>
)
}
/**
* Operator labels. They are symbols and SQL keywords rather than sentences, so
* they read the same in every language the designer runs in.
*/
const FILTER_OPERATOR_LABELS: Record<string, string> = {
eq: '=',
ne: '≠',
contains: '⊃ contains',
startswith: 'starts',
endswith: 'ends',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
in: 'in (a,b)',
isnull: 'is null',
notnull: 'is not null',
}
const VisualComponentDesigner = () => {
const { id } = useParams()
const { translate } = useLocalization()
const { components, updateComponent, renderComponent } = useComponents()
const [componentDetails, setComponentDetails] = useState<CustomComponent | null>(null)
const [componentLoadError, setComponentLoadError] = useState('')
const [loadedId, setLoadedId] = useState<string | null>(null)
const [document, setDocument] = useState<DesignerDocument>(() => createEmptyDesignerDocument())
const [manualCode, setManualCode] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('design')
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('properties')
// Editing buffer of the selected component's ref; committed on blur/Enter so a
// half typed name never lands in the document.
const [refDraft, setRefDraft] = useState('')
const [refError, setRefError] = useState('')
const [scriptApiOpen, setScriptApiOpen] = useState(false)
const [scriptApiSearch, setScriptApiSearch] = useState('')
/** Last focused event editor, so a Script API example lands in the right one. */
const [focusedEventName, setFocusedEventName] = useState('')
const [search, setSearch] = useState('')
const [expandedToolboxGroups, setExpandedToolboxGroups] = useState<
Record<DesignerComponentDefinition['toolboxGroup'], boolean>
>({ layout: true, html: true, platform: true, data: true, ui: true, custom: true })
const [isSaving, setIsSaving] = useState(false)
const [savedCode, setSavedCode] = useState('')
const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('')
const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({})
const [dataSourceSamples, setDataSourceSamples] = useState<Record<string, unknown>>({})
/**
* Last known response shape of every endpoint, kept apart from the samples the
* canvas paints from. A required filter without a sampling value cancels the
* request — correctly, so no unfiltered rows are shown — but the field list,
* the collection picker and the column settings describe the endpoint, not the
* current result, so they keep reading this instead of going blank.
*/
const [dataSourceShapes, setDataSourceShapes] = 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,
)
/**
* Permission names offered while an endpoint is being edited. Loaded once, on
* the first time the editor is opened: the list is the tenant's whole
* permission tree, custom permissions included, and most designer sessions
* never open the endpoint editor at all.
*/
const [permissionOptions, setPermissionOptions] = useState<string[]>([])
/** `Custom` chosen but no permission picked yet, keyed by `<nodeId>:<prop>`. */
const [permissionModes, setPermissionModes] = useState<Record<string, 'auto' | 'custom'>>({})
/**
* Read permission of the menu that opens this component — the one the Wizard
* created. `Otomatik` resolves against it, so the commands land on the same
* `<code>.Create/.Update/.Delete` family the Wizard seeds.
*/
/**
* `null` until the lookup finishes: a document must not lose the permission
* code it was saved with just because the menu list has not arrived yet — or
* could not be read at all — which would silently turn every `Otomatik`
* command back into an unchecked one.
*/
const [wizardPermissionCode, setWizardPermissionCode] = useState<string | null>(null)
const permissionOptionsLoaded = useRef(false)
/** Free text filter over the reusable endpoint catalog. */
const [endpointSearch, setEndpointSearch] = useState('')
const [endpointMethodFilter, setEndpointMethodFilter] = useState<'all' | DesignerHttpMethod>(
'all',
)
const [endpointAttachedOnly, setEndpointAttachedOnly] = useState(false)
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
const activeDrag = useDesignerDrag()
/** Kural tabanlı script builder'ın düzenlediği event. */
const [scriptBuilderTarget, setScriptBuilderTarget] = useState<{
nodeId: string
eventName: string
} | null>(null)
const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
const undoStack = useRef<DesignerDocument[]>([])
const redoStack = useRef<DesignerDocument[]>([])
const component = componentDetails?.id === id ? componentDetails : undefined
const name = component?.name || 'VisualComponent'
// The tab shows what is being edited: the human readable description when the
// component carries one, the technical name otherwise.
const pageTitle =
component?.description?.trim() || component?.name || translate('::App.DeveloperKit.CustomComponents')
const customNames = useMemo(
() => components.filter((item) => item.id !== id && item.isActive).map((item) => item.name),
[components, id],
)
const catalog = useMemo(() => getDesignerCatalog(customNames), [customNames])
const catalogByName = useMemo(() => new Map(catalog.map((item) => [item.name, item])), [catalog])
const selectDesignerNode = useCallback((nodeId: string | null) => {
setSelectedId(nodeId || null)
setInspectorTab('properties')
}, [])
useEffect(() => {
let cancelled = false
setEndpointCatalogLoading(true)
setEndpointCatalogError('')
developerKitService
.getGeneratedListEndpoints()
.then((result) => {
if (!cancelled) setGeneratedEndpoints(result.items || [])
})
.catch((error) => {
if (!cancelled) setEndpointCatalogError(getSaveErrorMessage(error, translate))
})
.finally(() => {
if (!cancelled) setEndpointCatalogLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
setComponentDetails(null)
setComponentLoadError('')
setLoadedId(null)
setSelectedId(null)
if (!id) return () => undefined
const loadComponentDetails = async () => {
try {
const currentComponent = await developerKitService.getCustomComponent(id)
if (!cancelled) setComponentDetails(currentComponent)
} catch (error) {
if (cancelled) return
setComponentLoadError(getSaveErrorMessage(error, translate))
}
}
void loadComponentDetails()
return () => {
cancelled = true
}
}, [id])
useEffect(() => {
if (!id || !component || loadedId === id) return
const propsDocument = parseDesignerDocument(component.props)
const hasStoredDesignerNodes = Boolean(propsDocument?.nodes.length)
const codeBackupDocument = hasStoredDesignerNodes
? null
: parseDesignerDocumentFromCode(component.code)
const migratedCodeDocument =
hasStoredDesignerNodes || codeBackupDocument
? null
: migrateGeneratedCodeToDesignerDocument(component.code, catalogByName)
const visualDocument = hasStoredDesignerNodes
? propsDocument
: codeBackupDocument || migratedCodeDocument || propsDocument
const initialDocument = visualDocument || createEmptyDesignerDocument('code')
setDocument(initialDocument)
setDataTestResults({})
setDataSourceSamples({})
setManualCode(component.code || '')
setSavedCode(component.code || '')
setSavedDesignerFingerprint(JSON.stringify(initialDocument))
setWorkspaceTab('design')
setLoadedId(id)
undoStack.current = []
redoStack.current = []
}, [catalogByName, component, id, loadedId])
/**
* The permission code travels with the saved document so the runtime — which
* regenerates the component from its stored schema, without the menu list —
* resolves `Otomatik` to the same names the designer showed.
*/
const documentToGenerate = useMemo(
() => ({ ...document, permissionCode: wizardPermissionCode ?? document.permissionCode }),
[document, wizardPermissionCode],
)
const generatedCode = useMemo(
() => generateDesignerCode(name, documentToGenerate),
[documentToGenerate, name],
)
const effectiveCode = document.sourceMode === 'visual' ? generatedCode : manualCode
const isDirty =
effectiveCode.trim() !== savedCode.trim() ||
JSON.stringify(document) !== savedDesignerFingerprint
const commitDocument = useCallback((updater: (current: DesignerDocument) => DesignerDocument) => {
setDocument((current) => {
undoStack.current.push(clone(current))
if (undoStack.current.length > 80) undoStack.current.shift()
redoStack.current = []
const next = updater(current)
// Single choke point for ref assignment: whichever way a node reaches the
// document — toolbox drop, duplicate, drag or paste — it leaves here with a
// unique handle that the event scripts can address.
return { ...next, nodes: withDesignerRefs(next.nodes) }
})
}, [])
const undo = useCallback(() => {
const previous = undoStack.current.pop()
if (!previous) return
redoStack.current.push(clone(document))
setDocument(previous)
}, [document])
const redo = useCallback(() => {
const next = redoStack.current.pop()
if (!next) return
undoStack.current.push(clone(document))
setDocument(next)
}, [document])
const addComponent = useCallback(
(definitionName: string, parentId: string | null = null, slot?: string) => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition, translate)
if (slot) node.slot = slot
commitDocument((current) => ({
...current,
sourceMode: 'visual',
nodes: insertNodeTree(current.nodes, node, parentId),
}))
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
)
/**
* Toolbox drop next to an existing component rather than inside it — the only
* way to place something beside a node that cannot host children.
*/
const addComponentBeside = useCallback(
(definitionName: string, targetId: string, placement: 'before' | 'after') => {
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition, translate)
commitDocument((current) => {
// Table cells and tab panes address their children by slot, so the new
// node has to join the one it was dropped next to.
const target = findDesignerNode(current.nodes, targetId)
if (target?.slot) node.slot = target.slot
return {
...current,
sourceMode: 'visual',
nodes: insertRelativeToNode(current.nodes, targetId, node, placement),
}
})
selectDesignerNode(node.id)
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
)
const selectedNode = useMemo(
() => findDesignerNode(document.nodes, selectedId),
[document.nodes, selectedId],
)
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
/** Every addressable component of the page, keyed by ref. */
const documentRefs = useMemo(() => collectDesignerRefs(document.nodes), [document.nodes])
useEffect(() => {
setRefDraft(selectedNode?.ref || '')
setRefError('')
}, [selectedNode?.id, selectedNode?.ref])
/**
* Renames the selected component's handle and rewrites the scripts that use
* it, so `refs.button1` does not silently stop resolving once the ref is given
* a meaningful name.
*/
const commitRefRename = () => {
if (!selectedNode) return
const previous = selectedNode.ref || ''
const next = refDraft.trim()
if (!next || next === previous) {
setRefDraft(previous)
setRefError('')
return
}
if (!isValidDesignerRef(next)) {
setRefError(translate('::App.DeveloperKitComponentDesigner.RefInvalid'))
return
}
if (documentRefs.has(next)) {
setRefError(translate('::App.DeveloperKitComponentDesigner.RefTaken'))
return
}
setRefError('')
commitDocument((current) => ({
...current,
lifecycle: { onMount: renameDesignerRefInScript(current.lifecycle.onMount, previous, next) },
nodes: renameDesignerRefInNodes(
updateNodeTree(current.nodes, selectedNode.id, (node) => ({ ...node, ref: next })),
previous,
next,
),
}))
}
const copyToClipboard = (text: string, message: string) => {
void navigator.clipboard?.writeText(text)
toast.push(
<Notification type="success" duration={1800}>
{message}
</Notification>,
{ placement: 'bottom-end' },
)
}
const copyRefName = (ref: string) =>
copyToClipboard(
`refs.${ref}`,
translate('::App.DeveloperKitComponentDesigner.RefCopied', { ref: `refs.${ref}` }),
)
/**
* Fills the `{{ref}}` / `{{sql}}` / `{{url}}` placeholders of a Script API
* example with names that actually exist on this page, so a copied example
* runs without being edited first.
*/
const resolveRecipeCode = useCallback(
(code: string) => {
const entries = [...documentRefs.entries()]
const sqlRef = entries.find(([, node]) => isFormNode(node.type))?.[0]
const selectedRef =
selectedNode && !isFormNode(selectedNode.type) ? selectedNode.ref : undefined
const componentRef =
selectedRef ||
entries.find(([, node]) => !isFormNode(node.type) && node.kind === 'ui')?.[0] ||
entries.find(([, node]) => !isFormNode(node.type))?.[0]
const url = document.dataSources.find((source) => source.method === 'GET')?.url
return code
.replace(/\{\{ref\}\}/g, componentRef || 'button1')
.replace(/\{\{sql\}\}/g, sqlRef || 'form1')
.replace(/\{\{url\}\}/g, url || '/api/app/orders')
},
[document.dataSources, documentRefs, selectedNode],
)
const filteredScriptRecipes = useMemo(() => {
const query = scriptApiSearch.trim().toLocaleLowerCase('tr')
if (!query) return DESIGNER_SCRIPT_RECIPES
return DESIGNER_SCRIPT_RECIPES.filter((recipe) =>
`${translate('::' + recipe.group)} ${translate('::' + recipe.title)} ${translate(
'::' + recipe.description,
)} ${recipe.code}`
.toLocaleLowerCase('tr')
.includes(query),
)
}, [scriptApiSearch, translate])
/**
* Record of every Form 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 formRecords = useMemo(() => {
const records: Record<string, unknown> = {}
walkDesignerNodes(document.nodes, (node) => {
if (isFormNode(node.type)) {
records[node.id] = getFormRecord(node, dataSourceSamples)
}
})
return records
}, [dataSourceSamples, document.nodes])
/**
* Form containers a filter can read its value from. Only a named
* container is addressable: the generated code resolves the master through its
* ref, exactly like a script does.
*/
const sqlContainerRefs = useMemo(() => {
const refs: { ref: string; id: string }[] = []
walkDesignerNodes(document.nodes, (node) => {
if (isFormNode(node.type) && node.ref) refs.push({ ref: node.ref, id: node.id })
})
return refs
}, [document.nodes])
/**
* Filters belong to the data source, not to the component bound to it, so every
* component reading that endpoint is filtered the same way — which is what makes
* a master/detail page a matter of configuration.
*/
/**
* List forms a platform view can be pointed at, and the fields of the one it is
* pointed at. Both are looked up on demand — only a selected platform view needs
* them — and cached, so switching between views costs no extra request.
*/
const [listFormCodes, setListFormCodes] = useState<string[]>([])
const [listFormFields, setListFormFields] = useState<Record<string, string[]>>({})
const platformListFormCode =
selectedNode?.kind === 'platform' ? String(selectedNode.props.listFormCode || '') : ''
useEffect(() => {
if (selectedNode?.kind !== 'platform' || listFormCodes.length) return
let active = true
void getListForms({ maxResultCount: 1000, skipCount: 0, sorting: 'listFormCode' })
.then((response) => {
if (!active) return
const codes = (response?.data?.items || [])
.map((item) => String(item.listFormCode || '').trim())
.filter(Boolean)
setListFormCodes([...new Set(codes)].sort((left, right) => left.localeCompare(right)))
})
.catch((error) => console.error('List form list could not be loaded:', error))
return () => {
active = false
}
}, [selectedNode?.kind, listFormCodes.length])
useEffect(() => {
if (!platformListFormCode || listFormFields[platformListFormCode]) return
let active = true
// `getList` swallows its own errors and can resolve with the error payload, so
// the response is read defensively rather than through its declared shape.
void Promise.resolve(getList({ listFormCode: platformListFormCode }))
.then((response: { data?: GridDto } | undefined) => {
if (!active) return
const fields = (response?.data?.columnFormats || [])
.map((column: { fieldName?: string }) => String(column.fieldName || '').trim())
.filter(Boolean)
setListFormFields((current) => ({
...current,
// The key field is filterable but is not always a listed column.
[platformListFormCode]: [
...new Set(
[String(response?.data?.gridOptions?.keyFieldName || '').trim(), ...fields].filter(
Boolean,
),
),
].sort((left, right) => left.localeCompare(right)),
}))
})
.catch((error: unknown) => console.error('List form fields could not be loaded:', error))
return () => {
active = false
}
}, [platformListFormCode, listFormFields])
/**
* Whether a property is the list form code of the selected platform view. Until
* the list arrives the property stays a text box, so a code can still be typed.
*/
const isPlatformListFormProperty = (propertyName: string) =>
propertyName === 'listFormCode' && selectedNode?.kind === 'platform' && listFormCodes.length > 0
/** Per source debounce of the filter preview request. */
const filterPreviewTimers = useRef<Record<string, number>>({})
// Mirrors of the two memos a preview filter resolves against; see
// `withPreviewFilterValues` for why they are read through refs.
const sqlPreviewRecords = useRef<Record<string, unknown>>({})
const sqlPreviewRefs = useRef<{ ref: string; id: string }[]>([])
sqlPreviewRecords.current = formRecords
sqlPreviewRefs.current = sqlContainerRefs
const updateDataSourceFilters = useCallback(
(sourceId: string, next: DesignerDataSourceFilter[]) => {
commitDocument((current) => ({
...current,
dataSources: current.dataSources.map((source) =>
source.id === sourceId ? { ...source, filters: next } : source,
),
}))
},
[commitDocument],
)
const previewDataValues = useMemo(
() => ({ ...dataSourceSamples, ...formRecords }),
[dataSourceSamples, formRecords],
)
const selectedAncestors = useMemo(
() => findDesignerAncestors(document.nodes, selectedId) || [],
[document.nodes, selectedId],
)
const selectedIsForm = isFormNode(selectedNode?.type)
/** Nearest Form above the selection; its record is bindable. */
const sqlScopeNode = useMemo(
() => [...selectedAncestors].reverse().find((node) => isFormNode(node.type)),
[selectedAncestors],
)
/**
* The container's record presented as a data source, so the existing endpoint
* dropdown, field discovery and binding editors work on it unchanged.
*/
const sqlScopeSource = useMemo<DesignerDataSource | null>(
() =>
sqlScopeNode
? {
id: sqlScopeNode.id,
name: translate('::App.DeveloperKitComponentDesigner.FormRecord', {
field: getFormKeyField(sqlScopeNode),
}),
method: 'GET',
url: '',
responsePath: '',
}
: null,
[sqlScopeNode],
)
/**
* Only a GET endpoint returns rows, so only GET can feed a property binding —
* and only one that is directly callable: a `{id}` URL has no key to fill it
* with outside a Form, so it cannot back a Grid or a Select list.
*/
const bindableDataSources = useMemo(
() =>
document.dataSources.filter(
(source) => source.method === 'GET' && !hasFormUrlParams(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
* Form — the option list of a Select and the record the form edits are
* two different things. Everything else inside the container is a record field
* and may only bind to the container's Select result.
*/
const selectedIsCollectionComponent =
isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)
const sqlScopeLocked = Boolean(sqlScopeNode) && !selectedIsCollectionComponent
const inspectorDataSources = useMemo(
() => (sqlScopeLocked ? (sqlScopeSource ? [sqlScopeSource] : []) : bindableDataSources),
[bindableDataSources, sqlScopeLocked, sqlScopeSource],
)
const selectedIsDataComponent = Boolean(
selectedNode &&
// A platform view is data driven too: its list form supplies the rows and
// the Data tab is where its filters are configured.
(isDataCategoryComponent(selectedDefinition) ||
selectedNode.kind === 'platform' ||
selectedIsForm ||
sqlScopeNode),
)
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
if (!selectedNode) return []
const definedProperties = selectedDefinition?.properties || []
const definedNames = new Set(definedProperties.map((property) => property.name))
const storedProperties = Object.entries(selectedNode.props)
// `filters` and `columnLookups` are edited in the Data tab; as a raw JSON
// textarea here they are only a way to corrupt them.
.filter(
([propertyName]) =>
!definedNames.has(propertyName) &&
propertyName !== 'filters' &&
propertyName !== 'columnLookups' &&
propertyName !== 'columnCaptions',
)
.map(([propertyName, value]) => ({
name: propertyName,
type: (Array.isArray(value)
? 'array'
: value !== null && typeof value === 'object'
? 'object'
: typeof value === 'boolean'
? 'boolean'
: typeof value === 'number'
? 'number'
: 'string') as DesignerPropertyInfo['type'],
value,
category: 'properties' as const,
}))
const properties = [...definedProperties, ...storedProperties]
if (!properties.some((property) => property.name === 'className')) {
properties.push({
name: 'className',
type: 'string',
value: '',
category: 'styling',
})
}
return properties
}, [selectedDefinition, selectedNode])
const selectedBindingSourceId = selectedNode
? Object.values(selectedNode.bindings || {}).find((binding) =>
inspectorDataSources.some((source) => source.id === binding.sourceId),
)?.sourceId
: undefined
useEffect(() => {
setDataPanelSourceId((current) => {
if (selectedBindingSourceId) return selectedBindingSourceId
if (inspectorDataSources.some((source) => source.id === current)) return current
// Must fall back within the selectable set: a default the picker does not
// list leaves `activeDataSource` undefined while the dropdown still paints
// its first option, so the panel looks configured when nothing is selected.
return inspectorDataSources[0]?.id || ''
})
}, [inspectorDataSources, selectedBindingSourceId])
const dataSourceCatalog = useMemo<DataSourceCatalogItem[]>(() => {
const items = new Map<string, DataSourceCatalogItem>()
const catalogComponents = componentDetails
? [componentDetails, ...components.filter((storedComponent) => storedComponent.id !== id)]
: components
catalogComponents.forEach((storedComponent) => {
const storedDocument = parseDesignerDocument(storedComponent.props)
storedDocument?.dataSources.forEach((source) => {
const dataSourceKey = `${source.method}:${source.url.trim()}:${source.responsePath.trim()}`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(currentSource) =>
currentSource.method === source.method &&
currentSource.url.trim() === source.url.trim() &&
currentSource.responsePath.trim() === source.responsePath.trim(),
)
items.set(dataSourceKey, {
attached,
key: `component:${storedComponent.id}:${source.id}`,
origin: `${storedComponent.name} componenti`,
originType: 'component',
ownerComponentId: storedComponent.id,
source,
})
})
})
generatedEndpoints
// Every active endpoint is offered, including GetById: its `{id}` is filled
// by the Form from the page URL, exactly like Update and Delete.
.filter((endpoint) => endpoint.isActive)
.forEach((endpoint) => {
const method = toDesignerHttpMethod(endpoint.method)
const dataSourceKey = `${method}:${endpoint.path.trim()}:`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(source) => source.method === method && source.url.trim() === endpoint.path.trim(),
)
items.set(dataSourceKey, {
attached,
key: `generated:${endpoint.id}`,
origin: 'CRUD Endpoint Manager',
originType: 'crud',
source: {
id: `generated_${endpoint.id}`,
name: `${endpoint.entityName} · ${endpoint.operationType}`,
method,
url: endpoint.path,
responsePath: '',
},
})
})
return [...items.values()]
}, [componentDetails, components, document.dataSources, generatedEndpoints, id])
/**
* The catalog of a mature tenant runs to hundreds of endpoints, so the list is
* searched rather than scrolled: every whitespace separated word has to appear
* somewhere in the name, the URL, the method or the origin.
*/
const reusableDataSources = useMemo(() => {
const terms = endpointSearch.toLocaleLowerCase('tr').split(/\s+/).filter(Boolean)
return (
dataSourceCatalog
.filter(
(item) => endpointMethodFilter === 'all' || item.source.method === endpointMethodFilter,
)
.filter((item) => !endpointAttachedOnly || item.attached)
.filter((item) => {
if (!terms.length) return true
const haystack =
`${item.source.name} ${item.source.method} ${item.source.url} ${item.origin}`.toLocaleLowerCase(
'tr',
)
return terms.every((term) => haystack.includes(term))
})
// Alphabetical rather than catalog order: the operations of one entity are
// named alike, so sorting is what puts them next to each other.
.sort((left, right) => left.source.name.localeCompare(right.source.name, 'tr'))
)
}, [dataSourceCatalog, endpointAttachedOnly, endpointMethodFilter, endpointSearch])
const updateSelectedProp = (propertyName: string, value: unknown) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
}
const updateNodeProp = useCallback(
(nodeId: string, propertyName: string, value: unknown) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
},
[commitDocument],
)
const updateNodeEvent = (nodeId: string, eventName: string, value: string) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
events: { ...node.events, [eventName]: value },
})),
}))
}
const updateSelectedBinding = (propertyName: string, sourceId: string, path = '') => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
if (sourceId) bindings[propertyName] = { sourceId, path }
else delete bindings[propertyName]
return { ...node, bindings }
}),
}))
}
const updateSelectedBindingDetails = (
propertyName: string,
details: Partial<DesignerBinding>,
) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const binding = node.bindings?.[propertyName]
if (!binding) return node
return {
...node,
bindings: {
...node.bindings,
[propertyName]: { ...binding, ...details },
},
}
}),
}))
}
const addDataSource = () => {
const dataSource: DesignerDataSource = {
id: `source_${createDesignerId().slice(-8)}`,
name: `Endpoint ${document.dataSources.length + 1}`,
method: 'GET',
url: '',
responsePath: '',
}
setCatalogSourceEditor({ item: null, draft: dataSource })
setCatalogSourceTestResult(null)
}
const addReusableDataSource = (catalogItem: DataSourceCatalogItem) => {
const existingSource = document.dataSources.find(
(source) =>
source.method === catalogItem.source.method &&
source.url.trim() === catalogItem.source.url.trim() &&
source.responsePath.trim() === catalogItem.source.responsePath.trim(),
)
// Only a directly callable GET source produces rows. A write endpoint or a
// GetById is attached to a Form command slot from the inspector.
const bindsCollection =
catalogItem.source.method === 'GET' && !hasFormUrlParams(catalogItem.source.url)
const collectionProperty =
selectedId && isTabularDataComponent(selectedNode?.type)
? 'items'
: selectedId && isOptionDataComponent(selectedNode?.type)
? getOptionDataProperty(selectedNode?.type)
: ''
/**
* Attaching an endpoint to the page must not retarget a component that is
* already reading a different one — the selection here is incidental, the
* user is working on the endpoint list, not on the component. An unbound
* data component is still wired up, since there is nothing to lose.
*/
const bindsSelection =
bindsCollection &&
Boolean(collectionProperty) &&
!selectedNode?.bindings?.[collectionProperty]?.sourceId
if (existingSource) {
if (bindsSelection && selectedId) {
updateSelectedBinding(collectionProperty, existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
setDataPanelSourceId(existingSource.id)
return
}
// Nothing to add and nothing to bind, so the button would look broken
// without a word about why it did nothing.
toast.push(
<Notification type="info" duration={2400}>
{translate('::App.DeveloperKitComponentDesigner.EndpointAlreadyAttached')}
</Notification>,
{ placement: 'bottom-end' },
)
return
}
const dataSource: DesignerDataSource = {
...catalogItem.source,
id: `source_${createDesignerId().slice(-8)}`,
}
commitDocument((current) => ({
...current,
dataSources: [...current.dataSources, dataSource],
nodes:
bindsSelection && selectedId
? updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
[collectionProperty]: { sourceId: dataSource.id, path: '' },
},
}))
: current.nodes,
}))
if (bindsSelection && selectedId) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
setDataPanelSourceId(dataSource.id)
}
}
/**
* A `record` filter has no typed value: at design time it is resolved from the
* column of the master container's sampled record, so a master/detail pair
* previews without the designer having to repeat the key by hand. An explicit
* sampling value still wins.
*
* The latest records are read through a ref because `testDataSource` is created
* once; a closed over copy would sample against the record of an earlier run.
*/
const withPreviewFilterValues = useCallback((source: DesignerDataSource): DesignerDataSource => {
const filters = source.filters || []
if (!filters.length) return source
return {
...source,
filters: filters.map((filter) => {
if (filter.source !== 'record' || String(filter.previewValue ?? '').trim()) return filter
const [ref = '', ...columnParts] = String(filter.value || '').split('.')
const column = columnParts.join('.')
const nodeId = sqlPreviewRefs.current.find((item) => item.ref === ref)?.id
const record = nodeId ? sqlPreviewRecords.current[nodeId] : undefined
const value = column ? getDesignerValueByPath(record, column) : undefined
return value === undefined || value === null || value === ''
? filter
: { ...filter, previewValue: String(value) }
}),
}
}, [])
/**
* Learns the columns of an endpoint whose preview is blocked by a required
* filter, by running it once with the unmet filters dropped. The result is
* deliberately kept out of `dataSourceSamples`: it would be a collection that
* never passed the filter, and the canvas must not paint it as if it had.
*/
const probeDataSourceShape = useCallback(
async (source: DesignerDataSource, urlOverride?: string) => {
const relaxed = {
...source,
filters: (source.filters || []).map((filter) => ({ ...filter, required: false })),
}
const probeUrl = buildDesignerPreviewUrl(
withPreviewFilterValues(relaxed),
urlOverride,
)?.trim()
if (!probeUrl || !probeUrl.startsWith('/api/')) return
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) return
if (hasFormUrlParams(probeUrl)) return
try {
const response = await apiService.fetchData({ method: 'GET', url: probeUrl })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (result === undefined) return
setDataSourceShapes((current) => ({ ...current, [source.id]: result }))
} catch {
// The shape simply stays unknown; the panels fall back to their empty
// state, exactly as they did before the endpoint was ever reachable.
}
},
[withPreviewFilterValues],
)
/**
* `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 Form.
*/
const testDataSource = useCallback(
async (source: DesignerDataSource, showResult = false, urlOverride?: string) => {
// Running a POST/PUT/DELETE endpoint would mutate real data, so write sources
// are never executed from the designer — they are only wired to a command slot.
if (source.method !== 'GET') {
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.MethodNotRunnableBindToSql', {
method: source.method,
}),
},
}))
return
}
if (showResult) setEndpointResultModal(null)
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'loading',
message: translate('::App.DeveloperKitComponentDesigner.SendingRequest'),
},
}))
// Design time preview runs with the filters applied: a static one uses its
// real value, a record one the column of the master's sampled record, and a
// URL one the sampling value typed next to it.
const previewUrl = buildDesignerPreviewUrl(withPreviewFilterValues(source), urlOverride)
if (previewUrl === null) {
// The stale sample has to go with it, otherwise the canvas keeps painting
// the rows of the last unfiltered run as if they had passed the filter.
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.RequiredFilterPreviewMissing'),
},
}))
// The rows must not be shown, but the columns still have to be: the
// unmet filters are dropped for one probe request whose result only ever
// reaches the shape cache, never the canvas.
await probeDataSourceShape(source, urlOverride)
return
}
const requestUrl = previewUrl.trim()
try {
if (!requestUrl) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointUrlRequired'))
}
if (!requestUrl.startsWith('/api/')) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointMustStartWithApi'))
}
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNotRunnable'))
}
if (hasFormUrlParams(requestUrl)) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNeedsKey'))
}
const response = await apiService.fetchData({ method: source.method, url: requestUrl })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(
translate('::App.DeveloperKitComponentDesigner.ResponsePathNotFound', {
path: source.responsePath,
}),
)
}
const message =
typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result))
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'success', message },
}))
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
setDataSourceShapes((current) => ({ ...current, [source.id]: result }))
if (showResult) setEndpointResultModal({ source, result })
} catch (error) {
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'error', message: getSaveErrorMessage(error, translate) },
}))
}
},
[probeDataSourceShape],
)
const persistCatalogOwnerDocument = useCallback(
async (item: DataSourceCatalogItem, nextDocument: DesignerDocument) => {
if (item.originType !== 'component' || !item.ownerComponentId) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.CatalogReadOnly'))
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
if (!owner)
throw new Error(translate('::App.DeveloperKitComponentDesigner.OwnerComponentNotFound'))
let existingProps: Record<string, unknown> = {}
try {
const parsedProps = owner.props ? JSON.parse(owner.props) : {}
if (parsedProps && typeof parsedProps === 'object' && !Array.isArray(parsedProps)) {
existingProps = parsedProps
}
} catch {
existingProps = {}
}
const update = {
name: owner.name,
routePath: owner.routePath,
description: owner.description,
dependencies: owner.dependencies,
code:
nextDocument.sourceMode === 'visual'
? generateDesignerCode(owner.name, nextDocument)
: owner.code,
props: JSON.stringify({ ...existingProps, visualDesigner: nextDocument }),
isActive: owner.isActive,
}
await updateComponent(owner.id, update)
return update
},
[componentDetails, components, updateComponent],
)
/** Fills the permission picker the first time an endpoint editor is opened. */
const loadPermissionOptions = useCallback(() => {
if (permissionOptionsLoaded.current) return
permissionOptionsLoaded.current = true
getPermissionsList()
.then((response) => {
const names = (response.data || [])
.map((permission: PermissionDefinitionRecord) => permission.name)
.filter((permissionName): permissionName is string => Boolean(permissionName))
setPermissionOptions([...new Set(names)].sort((left, right) => left.localeCompare(right)))
})
.catch((error) => {
// A missing list is not fatal: the field still accepts a typed name.
permissionOptionsLoaded.current = false
console.error('Yetki listesi alınamadı:', error)
})
}, [])
/**
* Permission field of an endpoint, in two steps. `Otomatik` — the default —
* checks nothing in the browser: the endpoint's own authorization decides, so
* a bound URL is all a button needs. `Custom` reveals the permission list,
* which holds every permission the platform knows (the ones generated for the
* modules as well as the custom ones); the picked name then decides whether
* the button, or the whole component, is rendered at all.
*
* The mode is not stored: an empty permission *is* `Otomatik`. Only the
* unfinished state — `Custom` picked, nothing chosen yet — lives in component
* state, keyed by node and property.
*/
const orderedPermissionOptions = useMemo(() => {
const code = wizardPermissionCode ?? document.permissionCode ?? ''
if (!code) return permissionOptions
// Derived from the slot table rather than spelled out here: the suffixes the
// Wizard seeds are declared once, and `Otomatik` resolves against the same map.
const family = FORM_SLOTS.map(
(slot) => `${code}${FORM_AUTO_SUFFIXES[slot.permissionProperty] ?? ''}`,
).filter((permission) => permissionOptions.includes(permission))
const rest = permissionOptions.filter((permission) => !family.includes(permission))
return [...family, ...rest]
}, [document.permissionCode, permissionOptions, wizardPermissionCode])
const renderPermissionPicker = (
modeKey: string,
value: string,
onChange: (permission: string) => void,
hints: { auto: string; custom: string },
) => {
const mode = value ? 'custom' : permissionModes[modeKey] || 'auto'
const modeOptions = [
{
value: 'auto',
label: translate('::App.DeveloperKitComponentDesigner.PermissionTypeAuto'),
},
{
value: 'custom',
label: translate('::App.DeveloperKitComponentDesigner.PermissionTypeCustom'),
},
]
return (
<div className="mt-2">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.EndpointPermission')}
</span>
<Select
className="w-full"
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={modeOptions}
size="xs"
value={modeOptions.find((option) => option.value === mode)}
onChange={(option) => {
const nextMode = option?.value === 'custom' ? 'custom' : 'auto'
setPermissionModes((current) => ({ ...current, [modeKey]: nextMode }))
// Leaving Custom drops the permission, so the document never keeps a
// name that is no longer being checked.
if (nextMode === 'auto' && value) onChange('')
}}
/>
{mode === 'custom' && (
<Select
isClearable
className="mt-1.5 w-full"
componentAs={CreatableSelect}
/* The component's own Wizard family first: in a catalog of hundreds
of permissions those four are what a designer reaches for. */
formatCreateLabel={(input: string) =>
translate('::App.DeveloperKitComponentDesigner.UseCustomPermission', {
permission: input,
})
}
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={orderedPermissionOptions.map((permission) => ({
value: permission,
label: permission,
}))}
placeholder={translate('::App.DeveloperKitComponentDesigner.PermissionPlaceholder')}
size="xs"
value={value ? { value, label: value } : null}
onChange={(option) =>
onChange((option as { value?: string } | null)?.value?.trim() || '')
}
/>
)}
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{translate(mode === 'custom' ? hints.custom : hints.auto)}
</p>
</div>
)
}
useEffect(() => {
loadPermissionOptions()
}, [loadPermissionOptions])
useEffect(() => {
const route = normalizeRoutePath(component?.routePath)
if (!route) {
setWizardPermissionCode(null)
return
}
let cancelled = false
getMenus()
.then((response) => {
if (cancelled) return
const menu = (response.data?.items || []).find(
(item) => normalizeRoutePath(item.url) === route,
)
setWizardPermissionCode(menu?.requiredPermissionName?.trim() || '')
})
.catch((error) => {
// Leave the stored code in place: an unreadable menu list is not proof
// that the component has no permission.
console.error('Menü yetkisi okunamadı:', error)
})
return () => {
cancelled = true
}
}, [component?.routePath])
const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
setCatalogSourceEditor({ item, draft: { ...item.source } })
setCatalogSourceTestResult(null)
}
const updateCatalogSourceDraft = (updates: Partial<DesignerDataSource>) => {
const sourceId = catalogSourceEditor?.draft.id
if (sourceId) {
setDataSourceSamples((samples) => {
const next = { ...samples }
delete next[sourceId]
return next
})
}
setCatalogSourceEditor((current) =>
current ? { ...current, draft: { ...current.draft, ...updates } } : current,
)
setCatalogSourceTestResult(null)
}
const testCatalogSource = async () => {
if (!catalogSourceEditor) return
const source = catalogSourceEditor.draft
if (source.method !== 'GET') {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.MethodNotRunnableWrites', {
method: source.method,
}),
})
return
}
setCatalogSourceTestResult({
status: 'loading',
message: translate('::App.DeveloperKitComponentDesigner.SendingRequest'),
})
try {
if (!source.name.trim())
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNameRequired'))
if (!isRunnableDataSourceUrl(source.url)) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointUrlInvalid'))
}
const response = await apiService.fetchData({ method: source.method, url: source.url.trim() })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(
translate('::App.DeveloperKitComponentDesigner.ResponsePathNotFound', {
path: source.responsePath,
}),
)
}
setCatalogSourceTestResult({
status: 'success',
message:
typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result)),
})
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
setEndpointResultModal({ source, result })
} catch (error) {
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setCatalogSourceTestResult({
status: 'error',
message: getSaveErrorMessage(error, translate),
})
}
}
const saveCatalogSource = async () => {
if (!catalogSourceEditor) return
const { item, draft } = catalogSourceEditor
if (!draft.name.trim()) {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.EndpointNameRequired'),
})
return
}
if (!isRunnableDataSourceUrl(draft.url)) {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.EndpointUrlInvalid'),
})
return
}
// A write endpoint cannot be verified without side effects, so the successful
// run is only demanded from GET sources.
if (draft.method === 'GET' && catalogSourceTestResult?.status !== 'success') {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.RunEndpointBeforeSave'),
})
return
}
if (!item) {
if (!id || !componentDetails) return
setIsCatalogSourceSaving(true)
try {
const storedDocument = parseDesignerDocument(componentDetails.props)
const baseDocument = storedDocument || { ...document, dataSources: [] }
const nextDocument: DesignerDocument = {
...baseDocument,
dataSources: [
...baseDocument.dataSources.filter((source) => source.id !== draft.id),
draft,
],
}
const catalogItem: DataSourceCatalogItem = {
attached: true,
key: `component:${id}:${draft.id}`,
origin: translate('::App.DeveloperKitComponentDesigner.OriginComponent', {
name: componentDetails.name,
}),
originType: 'component',
ownerComponentId: id,
source: draft,
}
const update = await persistCatalogOwnerDocument(catalogItem, nextDocument)
const localDocument: DesignerDocument = {
...document,
dataSources: [...document.dataSources.filter((source) => source.id !== draft.id), draft],
}
setDocument(localDocument)
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
toast.push(
<Notification type="success" duration={2400}>
{translate('::App.DeveloperKitComponentDesigner.EndpointAdded')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({
status: 'error',
message: getSaveErrorMessage(error, translate),
})
} finally {
setIsCatalogSourceSaving(false)
}
return
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
const ownerDocument = parseDesignerDocument(owner?.props)
if (!ownerDocument) {
setCatalogSourceTestResult({
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.DesignSchemaUnreadable'),
})
return
}
setIsCatalogSourceSaving(true)
try {
const nextDocument = {
...ownerDocument,
dataSources: ownerDocument.dataSources.map((source) =>
source.id === item.source.id ? { ...draft, id: source.id } : source,
),
}
const update = await persistCatalogOwnerDocument(item, nextDocument)
if (item.ownerComponentId === id) {
setDocument((current) => ({
...current,
dataSources: current.dataSources.map((source) =>
source.id === item.source.id ? { ...draft, id: source.id } : source,
),
}))
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
}
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
toast.push(
<Notification type="success" duration={2400}>
{translate('::App.DeveloperKitComponentDesigner.EndpointUpdated')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({
status: 'error',
message: getSaveErrorMessage(error, translate),
})
} finally {
setIsCatalogSourceSaving(false)
}
}
const deleteCatalogSource = async (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
const ownerDocument =
item.ownerComponentId === id ? document : parseDesignerDocument(owner?.props)
if (!ownerDocument) return
if (
!window.confirm(
translate('::App.DeveloperKitComponentDesigner.EndpointDeleteConfirm', {
name: item.source.name,
}),
)
)
return
try {
const nextDocument = {
...ownerDocument,
dataSources: ownerDocument.dataSources.filter((source) => source.id !== item.source.id),
nodes: removeDataSourceBindings(ownerDocument.nodes, item.source.id),
}
const update = await persistCatalogOwnerDocument(item, nextDocument)
if (item.ownerComponentId === id) {
setDocument(nextDocument)
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
}
toast.push(
<Notification type="success" duration={2400}>
{translate('::App.DeveloperKitComponentDesigner.EndpointDeleted')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
toast.push(
<Notification type="danger" duration={4000}>
{getSaveErrorMessage(error, translate)}
</Notification>,
{ placement: 'bottom-end' },
)
}
}
const deleteNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: removeNodeTree(current.nodes, nodeId) }))
setSelectedId((current) => (current === nodeId ? null : current))
},
[commitDocument],
)
const duplicateNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: duplicateInTree(current.nodes, nodeId) }))
},
[commitDocument],
)
const moveNode = useCallback(
(nodeId: string, direction: -1 | 1) => {
commitDocument((current) => ({
...current,
nodes: moveInTree(current.nodes, nodeId, direction),
}))
},
[commitDocument],
)
const moveNodeIntoContainerNode = useCallback(
(nodeId: string, parentId: string | null, slot?: string) => {
commitDocument((current) => ({
...current,
nodes: moveNodeIntoContainer(current.nodes, nodeId, parentId, slot),
}))
selectDesignerNode(nodeId)
},
[commitDocument, selectDesignerNode],
)
const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({
...current,
nodes: reorderNodeTree(current.nodes, sourceId, targetId, placement),
}))
selectDesignerNode(sourceId)
},
[commitDocument, selectDesignerNode],
)
const collectDependencies = useCallback(() => {
const names = new Set<string>()
walkDesignerNodes(document.nodes, (node) => {
if (node.kind === 'custom') names.add(node.type)
})
return [...names]
}, [document.nodes])
const handleSave = useCallback(
async (successMessage = translate('::App.DeveloperKitComponentDesigner.ComponentSaved')) => {
if (!id || !component) return
setIsSaving(true)
try {
const invalidDataSource = document.dataSources.find(
(source) => source.url.trim() && !source.url.trim().startsWith('/api/'),
)
if (invalidDataSource) {
throw new Error(
translate('::App.DeveloperKitComponentDesigner.DataSourceMustStartWithApi', {
name: invalidDataSource.name,
}),
)
}
let existingProps: Record<string, unknown> = {}
try {
const parsed = component.props ? JSON.parse(component.props) : {}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) existingProps = parsed
} catch {
existingProps = {}
}
const codeToSave = document.sourceMode === 'visual' ? generatedCode : manualCode
const dependenciesToSave = JSON.stringify(
document.sourceMode === 'visual'
? collectDependencies()
: (() => {
try {
return JSON.parse(component.dependencies || '[]')
} catch {
return []
}
})(),
)
const propsToSave =
document.sourceMode === 'visual'
? JSON.stringify({ ...existingProps, visualDesigner: documentToGenerate })
: component.props
const update = {
name: component.name,
routePath: component.routePath,
description: component.description,
dependencies: dependenciesToSave,
code: codeToSave.trim(),
props: propsToSave,
isActive: component.isActive,
}
await updateComponent(id, update)
setComponentDetails((current) =>
current?.id === id
? { ...current, ...update, lastModificationTime: new Date().toISOString() }
: current,
)
setSavedCode(codeToSave)
setSavedDesignerFingerprint(JSON.stringify(document))
toast.push(
<Notification type="success" duration={2400}>
{successMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
console.error('Visual component save failed:', error)
const errorMessage = getSaveErrorMessage(error, translate)
toast.push(
<Notification type="danger" duration={4000}>
{errorMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsSaving(false)
}
},
[
collectDependencies,
component,
document,
documentToGenerate,
generatedCode,
id,
manualCode,
updateComponent,
],
)
useEffect(() => {
const keyHandler = (event: KeyboardEvent) => {
const target = event.target
const isPropertyEditing =
target instanceof HTMLElement &&
Boolean(
target.closest(
'[data-property-panel], input, textarea, select, [contenteditable="true"], [role="textbox"], .monaco-editor',
),
)
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void handleSave()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) redo()
else undo()
}
if (event.key === 'Delete' && !isPropertyEditing && selectedId && workspaceTab === 'design') {
deleteNode(selectedId)
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
setScriptApiOpen((current) => !current)
}
if (event.key === 'Escape') setScriptApiOpen(false)
}
window.addEventListener('keydown', keyHandler)
return () => window.removeEventListener('keydown', keyHandler)
}, [deleteNode, handleSave, redo, selectedId, undo, workspaceTab])
const filteredCatalog = useMemo(() => {
const term = search.trim().toLocaleLowerCase('tr')
return catalog.filter(
(item) =>
!term ||
`${item.name} ${translate('::' + item.description)} ${item.category}`
.toLocaleLowerCase('tr')
.includes(term),
)
}, [catalog, search, translate])
const groupedCatalog = useMemo(() => {
const groups = new Map<
DesignerComponentDefinition['toolboxGroup'],
DesignerComponentDefinition[]
>(TOOLBOX_GROUP_ORDER.map((group) => [group, []]))
filteredCatalog.forEach((item) => {
groups.get(item.toolboxGroup)?.push(item)
})
return TOOLBOX_GROUP_ORDER.map(
(group) =>
[
group,
[...(groups.get(group) || [])].sort((left, right) => {
if (group === 'layout') {
const leftIndex = LAYOUT_TOOLBOX_ORDER.indexOf(left.name)
const rightIndex = LAYOUT_TOOLBOX_ORDER.indexOf(right.name)
if (leftIndex !== rightIndex) {
if (leftIndex < 0) return 1
if (rightIndex < 0) return -1
return leftIndex - rightIndex
}
}
return left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' })
}),
] as const,
).filter(([, definitions]) => definitions.length > 0)
}, [filteredCatalog])
const activeDataSource = inspectorDataSources.find((source) => source.id === dataPanelSourceId)
// Falls back to the shape cache so the field list, the collection picker and
// the column settings stay usable while a required filter is holding the live
// request back — configuring the filter is exactly when they are needed.
const activeDataSample = activeDataSource
? previewDataValues[activeDataSource.id] !== undefined
? previewDataValues[activeDataSource.id]
: dataSourceShapes[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 Form'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 (!isFormNode(node.type)) return
const previewKey = getFormPreviewKey(node)
if (!previewKey) return
const source = document.dataSources.find(
(item) => item.id === getFormEndpointId(node, 'selectEndpoint'),
)
if (!source) return
const parameterName = getFormKeyParam(node)
const bound = bindFormUrl(source.url, { [parameterName]: previewKey }, parameterName)
previewUrls.set(
source.id,
bound.keyBound ? bound.url : appendFormQueryParam(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 (hasFormUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
void testDataSource(source, false, requestUrl)
})
}, [
dataSourceSamples,
dataTestResults,
document.dataSources,
document.nodes,
id,
loadedId,
testDataSource,
])
const optionDataProperty = getOptionDataProperty(selectedNode?.type)
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
? selectedNode?.bindings?.[optionDataProperty]
: undefined
const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding)
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
const selectDataMode =
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
? selectDataModes[selectedId] ||
(selectOptionsBinding?.sourceId || tabularItemsBinding?.sourceId ? 'endpoint' : 'static')
: 'static'
const staticSelectOptions: StaticSelectOption[] =
isOptionDataComponent(selectedNode?.type) &&
Array.isArray(selectedNode?.props[optionDataProperty])
? (selectedNode.props[optionDataProperty] as unknown[]).map((option, index) => {
if (option && typeof option === 'object' && !Array.isArray(option)) {
const record = option as Record<string, unknown>
return {
label: String(
record.label ??
record.children ??
record.title ??
translate('::App.DeveloperKitComponentDesigner.OptionN', { index: index + 1 }),
),
value: record.value ?? record.eventKey ?? record.key ?? index + 1,
// Only a tab carries its own text; keeping it here is what lets the
// row editor change or clear it instead of silently dropping it.
content: typeof record.content === 'string' ? record.content : undefined,
// Same for a menu item icon: it is a name, and an empty one means
// the item simply has no icon.
icon: typeof record.icon === 'string' ? record.icon : undefined,
}
}
return { label: String(option ?? ''), value: option ?? index + 1 }
})
: []
const selectCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const tabularCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const selectCollectionSample = useMemo(() => {
if (activeDataSample === undefined) return undefined
const path = selectOptionsBinding?.path || ''
const value = getDesignerValueByPath(activeDataSample, path)
return Array.isArray(value) ? value : undefined
}, [activeDataSample, selectOptionsBinding?.path])
const selectColumnFields = useMemo(
() =>
selectCollectionSample === undefined
? []
: discoverDataFields(selectCollectionSample).filter(
(field) => !['array', 'object'].includes(field.type),
),
[selectCollectionSample],
)
const setSelectDataMode = (mode: 'static' | 'endpoint') => {
if (
!selectedId ||
(!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
)
return
setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
if (isTabularDataComponent(selectedNode?.type)) {
// Only a source the picker lists can be bound; a POST/PUT entry or the
// Form record would silently produce an unusable collection.
const source = activeDataSource || inspectorDataSources[0]
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
const props = { ...node.props }
delete props.dataColumns
// The lookups and captions are keyed by column; a different source
// means different columns, so stale entries would match nothing.
delete props.columnLookups
delete props.columnCaptions
if (mode === 'endpoint' && source) {
bindings.items = { sourceId: source.id, path: '' }
} else {
delete bindings.items
}
return { ...node, bindings, props }
}),
}))
if (mode === 'static') {
const staticItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
setStaticCollectionDrafts((current) => ({
...current,
[selectedId]: JSON.stringify(staticItems, null, 2),
}))
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
} else if (source && dataSourceSamples[source.id] === undefined) {
void testDataSource(source)
}
return
}
if (mode === 'static') {
updateSelectedBinding(optionDataProperty, '')
return
}
const source = activeDataSource || inspectorDataSources[0]
if (source) {
updateSelectedBinding(optionDataProperty, source.id, '')
if (dataSourceSamples[source.id] === undefined) void testDataSource(source)
}
}
const updateStaticSelectOptions = (options: StaticSelectOption[]) => {
updateSelectedBinding(optionDataProperty, '')
updateSelectedProp(optionDataProperty, options)
if (selectedId) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
}
}
const updateStaticCollection = (rawValue: string) => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type)) return
setStaticCollectionDrafts((current) => ({ ...current, [selectedId]: rawValue }))
try {
const value = JSON.parse(rawValue)
if (!Array.isArray(value))
throw new Error(translate('::App.DeveloperKitComponentDesigner.StaticDataMustBeArray'))
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
delete bindings.items
const previousFields = discoverDataFields(node.props.items)
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
const nextFields = discoverDataFields(value)
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
const props: Record<string, unknown> = { ...node.props, items: value }
if (previousFields.join('|') !== nextFields.join('|')) {
delete props.dataColumns
delete props.columnLookups
delete props.columnCaptions
}
return { ...node, bindings, props }
}),
}))
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
} catch (error) {
setStaticCollectionErrors((current) => ({
...current,
[selectedId]:
error instanceof Error
? error.message
: translate('::App.DeveloperKitComponentDesigner.InvalidJson'),
}))
}
}
const repeatedGridAncestor = [...selectedAncestors]
.reverse()
.find((node) => node.type === 'Grid' && node.bindings?.items?.sourceId)
const isActiveRepeatedSource =
repeatedGridAncestor?.bindings.items.sourceId === activeDataSource?.id
const activeBindingSample =
isTabularDataComponent(selectedNode?.type) && selectDataMode === 'static'
? selectedNode?.props.items
: isTabularDataComponent(selectedNode?.type) &&
tabularItemsBinding?.sourceId === activeDataSource?.id &&
activeDataSample !== undefined
? getDesignerValueByPath(activeDataSample, tabularItemsBinding?.path || '')
: activeDataSample !== undefined && isActiveRepeatedSource
? getDesignerValueByPath(
activeDataSample,
repeatedGridAncestor?.bindings.items.path || '',
)
: activeDataSample
const activeDataFields = useMemo(
() => (activeBindingSample === undefined ? [] : discoverDataFields(activeBindingSample)),
[activeBindingSample],
)
const gridColumnFields = useMemo(
() => activeDataFields.filter((field) => !['array', 'object'].includes(field.type)),
[activeDataFields],
)
const configuredGridColumns =
isTabularDataComponent(selectedNode?.type) && Array.isArray(selectedNode?.props.dataColumns)
? selectedNode.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: null
const selectedGridColumns = configuredGridColumns
? configuredGridColumns.filter((column) =>
gridColumnFields.some((field) => field.path === column),
)
: gridColumnFields.map((field) => field.path)
const allGridColumnsSelected =
gridColumnFields.length > 0 && selectedGridColumns.length === gridColumnFields.length
const toggleGridColumn = (columnPath: string) => {
const nextColumns = selectedGridColumns.includes(columnPath)
? selectedGridColumns.filter((column) => column !== columnPath)
: [...selectedGridColumns, columnPath]
updateSelectedProp('dataColumns', nextColumns)
}
const toggleAllGridColumns = () => {
updateSelectedProp(
'dataColumns',
allGridColumnsSelected ? [] : gridColumnFields.map((field) => field.path),
)
}
/**
* Display rules of the columns that carry a foreign key. They live on the node
* rather than on the endpoint, because the same endpoint can feed two grids
* that resolve different columns.
*/
const gridColumnLookups = getDesignerColumnLookups(selectedNode)
/** Header overrides; an emptied box removes the entry rather than storing ''. */
const gridColumnCaptions = getDesignerColumnCaptions(selectedNode)
const writeColumnCaption = (column: string, caption: string) => {
const next = { ...gridColumnCaptions }
if (caption.trim()) next[column] = caption
else delete next[column]
updateSelectedProp('columnCaptions', next)
}
/** Sample of a lookup endpoint; the shape cache stands in for a blocked run. */
const getLookupSample = (sourceId: string) =>
previewDataValues[sourceId] !== undefined
? previewDataValues[sourceId]
: dataSourceShapes[sourceId]
const getLookupCollectionPaths = (sourceId: string) => {
const sample = getLookupSample(sourceId)
if (sample === undefined) return []
const paths = discoverDataFields(sample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(sample) ? ['', ...paths] : paths
}
const getLookupRowFields = (sourceId: string, path: string) => {
const collection = getDesignerValueByPath(getLookupSample(sourceId), path)
const row = Array.isArray(collection) ? collection[0] : undefined
if (!row || typeof row !== 'object' || Array.isArray(row)) return []
return discoverDataFields([row])
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
}
const writeColumnLookup = (column: string, patch: Partial<DesignerColumnLookup> | null) => {
const next: Record<string, DesignerColumnLookup> = { ...gridColumnLookups }
if (patch === null) delete next[column]
else {
const base: DesignerColumnLookup = next[column] ?? {
sourceId: '',
path: '',
valueField: '',
textField: '',
}
next[column] = { ...base, ...patch }
}
updateSelectedProp('columnLookups', next)
}
/**
* Picking the endpoint clears the field choices made against the previous one,
* samples it when it has never run, and settles on the only collection it
* carries — a response with a single array needs no path decision.
*/
const selectColumnLookupSource = (column: string, sourceId: string) => {
if (!sourceId) {
writeColumnLookup(column, null)
return
}
const source = document.dataSources.find((item) => item.id === sourceId)
if (source && getLookupSample(sourceId) === undefined) void testDataSource(source)
const paths = getLookupCollectionPaths(sourceId)
writeColumnLookup(column, {
sourceId,
path: paths.length === 1 ? paths[0] : '',
valueField: '',
textField: '',
})
}
useEffect(() => {
if (
!selectedId ||
!isTabularDataComponent(selectedNode?.type) ||
configuredGridColumns !== null ||
!gridColumnFields.length
) {
return
}
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, dataColumns: gridColumnFields.map((field) => field.path) },
})),
}))
}, [commitDocument, configuredGridColumns, gridColumnFields, selectedId, selectedNode?.type])
useEffect(() => {
const itemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
if (
!selectedId ||
!itemsBinding?.sourceId ||
itemsBinding.sourceId !== activeDataSource?.id ||
activeDataSample === undefined
) {
return
}
const currentValue = getDesignerValueByPath(activeDataSample, itemsBinding.path)
if (Array.isArray(currentValue)) return
const collectionPath = Array.isArray(activeDataSample)
? ''
: discoverDataFields(activeDataSample).find((field) => field.type === 'array')?.path
if (collectionPath === undefined || collectionPath === itemsBinding.path) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
items: { sourceId: itemsBinding.sourceId, path: collectionPath },
},
})),
}))
}, [activeDataSample, activeDataSource?.id, commitDocument, selectedId, selectedNode])
const dataBindableProperties =
selectedNode &&
selectedDefinition &&
(isDataCategoryComponent(selectedDefinition) || Boolean(sqlScopeNode))
? selectedDefinition.properties.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
(isTabularDataComponent(selectedNode.type)
? property.name === 'items'
: isOptionDataComponent(selectedNode.type)
? property.name === getOptionDataProperty(selectedNode.type)
: sqlScopeLocked
? // A record field only needs the props that carry data.
getSqlRecordFieldOrder(property.name) >= 0
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) ||
property.type === 'array' ||
['number', 'boolean', 'string', 'select'].includes(property.type)),
)
: []
const orderedBindableProperties = sqlScopeLocked
? [...dataBindableProperties].sort(
(left, right) => getSqlRecordFieldOrder(left.name) - getSqlRecordFieldOrder(right.name),
)
: dataBindableProperties
const isCollectionProperty = (propertyName: string, propertyType: string) =>
propertyType === 'array' || ['items', 'data', 'dataSource', 'options'].includes(propertyName)
const getBindingChoice = (propertyName: string) => {
const binding = selectedNode?.bindings?.[propertyName]
if (!binding || binding.sourceId !== activeDataSource?.id) return ''
const path = binding.path.trim()
if (!path || path === '$item' || path === '0') return '__root__'
if (isActiveRepeatedSource && path.startsWith('$item.')) return path.slice(6)
if (Array.isArray(activeBindingSample) && path.startsWith('0.')) return path.slice(2)
return path
}
const getBindingPath = (fieldPath: string, collection: boolean) => {
if (fieldPath === '__root__') {
if (isActiveRepeatedSource) return '$item'
if (Array.isArray(activeBindingSample) && !collection) return '0'
return ''
}
if (isActiveRepeatedSource) return `$item.${fieldPath}`
if (Array.isArray(activeBindingSample)) return `0.${fieldPath}`
return fieldPath
}
const renderReusableDataSources = (compact = false) => {
const visibleSources = reusableDataSources
const hasFilter =
Boolean(endpointSearch.trim()) || endpointMethodFilter !== 'all' || endpointAttachedOnly
return (
<div className="space-y-2">
{/* Search first, then the list: with a large catalog the filter is the
primary control, not an afterthought at the bottom. */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<FaSearch className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[10px] text-slate-400" />
<Input
unstyle
className="w-full rounded-md border border-slate-300 bg-white py-2 pl-7 pr-2.5 text-xs dark:border-gray-700 dark:bg-gray-900"
placeholder={translate('::App.DeveloperKitComponentDesigner.SearchEndpoint')}
type="search"
value={endpointSearch}
onChange={(event) => setEndpointSearch(event.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md bg-slate-100 p-1 dark:bg-gray-950">
{(['all', ...DESIGNER_HTTP_METHODS] as const).map((method) => (
<button
key={method}
className={`rounded px-2 py-1 text-[10px] font-semibold uppercase ${
endpointMethodFilter === method
? 'bg-white text-sky-700 shadow-sm dark:bg-gray-800 dark:text-sky-300'
: 'text-slate-500'
}`}
type="button"
onClick={() => setEndpointMethodFilter(method)}
>
{method === 'all'
? translate('::App.DeveloperKitComponentDesigner.AllMethods')
: method}
</button>
))}
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] font-semibold text-slate-500">
<Input
unstyle
checked={endpointAttachedOnly}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) => setEndpointAttachedOnly(event.target.checked)}
/>
{translate('::App.DeveloperKitComponentDesigner.AttachedOnly')}
</label>
<span className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[10px] font-semibold text-slate-500 dark:bg-gray-950">
{visibleSources.length} / {dataSourceCatalog.length}
</span>
</div>
{endpointCatalogLoading && !visibleSources.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs text-slate-500 dark:border-gray-700">
{translate('::App.DeveloperKitComponentDesigner.LoadingSavedEndpoints')}
</p>
) : visibleSources.length ? (
// The workspace panel is full width, so the cards are laid out in as
// many columns as fit; the inspector keeps its single narrow column.
<div
className={
compact ? 'space-y-2' : 'grid gap-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
}
>
{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-gray-700 dark:bg-gray-900"
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-slate-700 dark:text-gray-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">
{translate('::App.DeveloperKitComponentDesigner.Attached')}
</span>
)}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-sky-600 px-2.5 py-1.5 text-[10px] font-semibold text-white hover:bg-sky-700"
type="button"
onClick={() => addReusableDataSource(item)}
>
{translate('::App.DeveloperKitComponentDesigner.Use')}
</button>
{item.originType === 'component' && (
<>
<button
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-amber-400 hover:text-amber-600 dark:border-gray-700"
title={translate('::App.DeveloperKitComponentDesigner.EditEndpoint')}
type="button"
onClick={() => openCatalogSourceEditor(item)}
>
<FaEdit />
</button>
<button
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-red-400 hover:text-red-600 dark:border-gray-700"
title={translate('::App.DeveloperKitComponentDesigner.DeleteEndpoint')}
type="button"
onClick={() => void deleteCatalogSource(item)}
>
<FaTrash />
</button>
</>
)}
</div>
</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-gray-700">
{translate(
hasFilter
? '::App.DeveloperKitComponentDesigner.NoMatchingEndpoint'
: '::App.DeveloperKitComponentDesigner.NoMoreEndpoints',
)}
</p>
)}
{endpointCatalogError && (
<p className="rounded bg-amber-50 p-2 text-[10px] leading-4 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{translate('::App.DeveloperKitComponentDesigner.CatalogReadFailed')}:{' '}
{endpointCatalogError}
</p>
)}
</div>
)
}
const renderSelectDataModeSelector = () => {
if (!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.Listform.ListformField.DataSourceType')}
</div>
<div className="grid grid-cols-2 gap-1 rounded-md bg-slate-100 p-1 dark:bg-gray-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-gray-800 dark:text-sky-300'
: 'text-slate-500'
}`}
type="button"
onClick={() => setSelectDataMode(mode)}
>
{mode === 'static'
? isOptionDataComponent(selectedNode?.type)
? translate('::App.DeveloperKitComponentDesigner.StaticOptions')
: translate('::App.DeveloperKitComponentDesigner.StaticData')
: 'Endpoint'}
</button>
))}
</div>
</div>
)
}
const renderSelectDataConfiguration = () => {
if (!isOptionDataComponent(selectedNode?.type)) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
{selectDataMode === 'static' ? (
<div className="space-y-2">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.StaticOptions')}
</div>
{staticSelectOptions.map((option, index) => (
<div key={index} className="space-y-1">
<div className="grid grid-cols-[1fr_1fr_auto] gap-1">
<Input
unstyle
aria-label={translate('::App.DeveloperKitComponentDesigner.OptionLabelAria', {
index: index + 1,
})}
className="min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate('::App.DeveloperKitComponentDesigner.LabelHeader')}
value={option.label}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index
? { ...current, label: event.target.value }
: current,
),
)
}
/>
<Input
unstyle
aria-label={translate('::App.DeveloperKitComponentDesigner.OptionValueAria', {
index: index + 1,
})}
className="min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate('::App.Listform.ListformField.Value')}
value={String(option.value ?? '')}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index
? { ...current, value: event.target.value }
: current,
),
)
}
/>
<button
aria-label={translate('::App.DeveloperKitComponentDesigner.OptionDeleteAria', {
index: index + 1,
})}
className="rounded px-2 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
type="button"
onClick={() =>
updateStaticSelectOptions(
staticSelectOptions.filter((_, optionIndex) => optionIndex !== index),
)
}
>
<FaTrash />
</button>
</div>
{selectedNode?.type === 'Menu' && (
<IconPickerField
value={option.icon ?? ''}
onChange={(iconKey) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, icon: iconKey } : current,
),
)
}
/>
)}
{selectedNode?.type === 'Tabs' && (
<Input
unstyle
className="w-full min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.TabContentPlaceholder',
)}
value={option.content ?? ''}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index
? { ...current, content: event.target.value }
: current,
),
)
}
/>
)}
</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-gray-700"
type="button"
onClick={() =>
updateStaticSelectOptions([
...staticSelectOptions,
{
label: translate('::App.DeveloperKitComponentDesigner.OptionN', {
index: staticSelectOptions.length + 1,
}),
value: String(staticSelectOptions.length + 1),
},
])
}
>
<FaPlus /> {translate('::App.DeveloperKitComponentDesigner.AddStaticOption')}
</button>
</div>
) : (
<div className="space-y-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.LabelValueMapping')}
</div>
<p className="text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.LabelValueMappingHint')}
</p>
{activeDataSample !== undefined && selectOptionsBinding?.sourceId && (
<>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
Koleksiyon
</span>
<Select
size="xs"
className="w-full"
placeholder={translate('::App.DeveloperKitComponentDesigner.NoCollectionFound')}
menuPortalTarget={window.document.body}
options={selectCollectionPaths.map((path) => ({
value: path,
label:
path || translate('::App.DeveloperKitComponentDesigner.WholeResponseArray'),
}))}
value={{
value: selectOptionsBinding.path,
label:
selectOptionsBinding.path ||
translate('::App.DeveloperKitComponentDesigner.WholeResponseArray'),
}}
onChange={(option) =>
updateSelectedBindingDetails(optionDataProperty, {
path: option?.value ?? '',
labelPath: '',
valuePath: '',
})
}
/>
</label>
{selectCollectionSample && (
<div className="grid grid-cols-2 gap-2">
{(
[
['labelPath', 'App.DeveloperKitComponentDesigner.LabelColumn'],
['valuePath', 'App.DeveloperKitComponentDesigner.ValueColumn'],
] as const
).map(([key, label]) => (
<label key={key} className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
{translate('::' + label)}
</span>
<Select
size="xs"
className="w-full"
isClearable
placeholder={translate(
selectColumnFields.length
? '::App.DeveloperKitComponentDesigner.SelectColumn'
: '::App.DeveloperKitComponentDesigner.ItemItself',
)}
menuPortalTarget={window.document.body}
options={selectColumnFields.map((field) => ({
value: field.path,
label: field.path,
}))}
value={
selectOptionsBinding[key]
? {
value: selectOptionsBinding[key] as string,
label: selectOptionsBinding[key] as string,
}
: null
}
onChange={(option) =>
updateSelectedBindingDetails(optionDataProperty, {
[key]: option?.value ?? '',
})
}
/>
</label>
))}
</div>
)}
{/*
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
başka bir komponente ya da Form kaydına yazabilir.
*/}
{selectCollectionSample && selectColumnFields.length > 0 && (
<div>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.ExtraColumns')}
<code className="font-normal normal-case text-sky-600">
refs.{selectedNode?.ref}.getColumn(&apos;&apos;)
</code>
</span>
<div className="flex flex-wrap items-center gap-1">
{selectOptionColumns.map((column) => (
<span
key={column}
className="flex items-center gap-1 rounded-full bg-sky-100 px-2 py-[2px] text-[10px] text-sky-700 dark:bg-sky-950 dark:text-sky-200"
>
{column}
<button
className="text-sky-400 hover:text-red-500"
title={translate('::App.DeveloperKitComponentDesigner.RemoveColumn')}
type="button"
onClick={() =>
updateSelectedBindingDetails(optionDataProperty, {
columns: selectOptionColumns.filter((item) => item !== column),
})
}
>
<FaTimes size={8} />
</button>
</span>
))}
<Select
size="xs"
className="min-w-[8rem]"
placeholder={translate('::App.DeveloperKitComponentDesigner.AddColumn')}
menuPortalTarget={window.document.body}
options={selectColumnFields
.filter((field) => !selectOptionColumns.includes(field.path))
.map((field) => ({ value: field.path, label: field.path }))}
value={null}
onChange={(option) => {
const next = option?.value
if (!next || selectOptionColumns.includes(next)) return
updateSelectedBindingDetails(optionDataProperty, {
columns: [...selectOptionColumns, next],
})
}}
/>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.ExtraColumnsHint')}{' '}
<code>
refs.{selectedNode?.ref || 'select1'}.getColumn(&apos;sutun&apos;)
</code>{' '}
{translate('::App.DeveloperKitComponentDesigner.ExtraColumnsHintSuffix')}
</p>
</div>
)}
{selectCollectionSample &&
selectColumnFields.length > 0 &&
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
<p className="rounded bg-amber-50 p-2 text-[10px] text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{translate('::App.DeveloperKitComponentDesigner.SelectLabelValueWarning', {
type: selectedNode?.type ?? '',
})}
</p>
)}
</>
)}
{activeDataSample === undefined && (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-gray-950">
{translate('::App.DeveloperKitComponentDesigner.SelectEndpointFirst')}
</p>
)}
</div>
)}
</div>
)
}
const renderTabularDataConfiguration = () => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type) || selectDataMode !== 'static')
return null
const currentItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
const draft = staticCollectionDrafts[selectedId] ?? JSON.stringify(currentItems, null, 2)
const error = staticCollectionErrors[selectedId]
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.StaticRowsJson')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.StaticRowsHint')}
</p>
<textarea
className="min-h-40 w-full rounded-md border border-slate-300 bg-white p-2 font-mono text-[10px] leading-4 dark:border-gray-700 dark:bg-gray-900"
spellCheck={false}
value={draft}
onChange={(event) => updateStaticCollection(event.target.value)}
/>
{error && (
<p className="mt-2 rounded bg-red-50 p-2 text-[10px] text-red-600 dark:bg-red-950">
{error}
</p>
)}
{!error && (
<p className="mt-2 text-[10px] text-emerald-600">
{translate('::App.DeveloperKitComponentDesigner.RowsReady', {
count: currentItems.length,
})}
</p>
)}
</div>
)
}
/**
* Per column display rule: a column holding a key can be resolved through
* another endpoint and painted as the related text instead. It reads like the
* option list of a select box — a value column and a text column — which is
* exactly what the cell then shows.
*/
const renderColumnLookupConfiguration = () => {
if (!isTabularDataComponent(selectedNode?.type)) return null
const columns = selectedGridColumns.length
? selectedGridColumns
: Object.keys(gridColumnLookups)
if (!columns.length) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ColumnDisplay')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ColumnDisplayHint')}
</p>
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ColumnCaptionHint')}
</p>
<div className="space-y-2">
{columns.map((column) => {
const lookup = gridColumnLookups[column]
const collectionPaths = lookup?.sourceId
? getLookupCollectionPaths(lookup.sourceId)
: []
const rowFields = lookup?.sourceId
? getLookupRowFields(lookup.sourceId, lookup.path)
: []
return (
<div
key={column}
className="rounded border border-slate-200 bg-slate-50 p-2 dark:border-gray-700 dark:bg-gray-950"
>
<code className="block truncate text-[10px] text-sky-700 dark:text-sky-300">
{column}
</code>
{/* The header text is independent of the lookup: a column can be
renamed without being resolved, and resolved without being
renamed. Left empty it keeps the field name. */}
<Input
unstyle
className="mt-1.5 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.ColumnCaptionPlaceholder',
{ column },
)}
value={gridColumnCaptions[column] ?? ''}
onChange={(event) => writeColumnCaption(column, event.target.value)}
/>
<label className="mt-1.5 flex cursor-pointer items-center gap-2">
<Input
unstyle
checked={Boolean(lookup)}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => writeColumnLookup(column, lookup ? null : {})}
/>
<span className="min-w-0 flex-1 text-[9px] text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ShowRelatedText')}
</span>
</label>
{lookup && (
<div className="mt-2 space-y-1.5">
<Select
size="xs"
className="w-full"
isClearable
placeholder={translate('::App.DeveloperKitComponentDesigner.SelectEndpoint')}
menuPortalTarget={window.document.body}
options={bindableDataSources.map((source) => ({
value: source.id,
label: source.name,
}))}
value={bindableDataSources
.filter((source) => source.id === lookup.sourceId)
.map((source) => ({ value: source.id, label: source.name }))}
onChange={(option) => selectColumnLookupSource(column, option?.value ?? '')}
/>
{lookup.sourceId && collectionPaths.length > 1 && (
<Select
size="xs"
className="w-full"
isClearable
maxMenuHeight={200}
placeholder={translate(
'::App.DeveloperKitComponentDesigner.SelectCollection',
)}
menuPortalTarget={window.document.body}
options={collectionPaths.map((path) => ({
value: path,
label:
path ||
translate('::App.DeveloperKitComponentDesigner.WholeResponseArray'),
}))}
value={lookup.path ? { value: lookup.path, label: lookup.path } : null}
onChange={(option) =>
writeColumnLookup(column, {
path: option?.value ?? '',
valueField: '',
textField: '',
})
}
/>
)}
{lookup.sourceId && (
<div className="grid grid-cols-2 gap-1.5">
{(
[
['valueField', '::App.DeveloperKitComponentDesigner.LookupValueField'],
['textField', '::App.DeveloperKitComponentDesigner.LookupTextField'],
] as const
).map(([field, label]) => (
<label key={field} className="block">
<span className="mb-0.5 block text-[9px] text-slate-400">
{translate(label)}
</span>
<Select
size="xs"
className="w-full"
isClearable
maxMenuHeight={200}
placeholder={
rowFields.length
? translate('::App.DeveloperKitComponentDesigner.SelectField')
: translate(
'::App.DeveloperKitComponentDesigner.NoColumnInResponse',
)
}
menuPortalTarget={window.document.body}
options={rowFields.map((path) => ({ value: path, label: path }))}
value={
lookup[field]
? { value: lookup[field], label: lookup[field] }
: null
}
onChange={(option) =>
writeColumnLookup(column, { [field]: option?.value ?? '' })
}
/>
</label>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
const renderTabularColumnConfiguration = () => {
if (!isTabularDataComponent(selectedNode?.type)) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.VisibleColumns')}
</span>
<button
className="text-[9px] font-semibold text-sky-600"
type="button"
onClick={toggleAllGridColumns}
>
{translate(
allGridColumnsSelected ? '::App.Platform.RemoveAll' : '::App.Platform.SelectAll',
)}
</button>
</div>
{gridColumnFields.length ? (
<div className="max-h-44 space-y-1 overflow-y-auto">
{gridColumnFields.map((field) => (
<label
key={field.path}
className="flex cursor-pointer items-center gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-gray-950"
>
<Input
unstyle
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-gray-950">
{translate('::App.DeveloperKitComponentDesigner.AddObjectForColumns')}
</p>
)}
</div>
)
}
/**
* Column filters of a data source. They are sent as query parameters — `Column`
* for an equality, `Column.operator` for anything else — which is what the CRUD
* GetList endpoint parses, so any column can be filtered, not just the key.
*
* A value comes from a static literal, from the page URL (query string or route
* segment) or from the record of another Form, which is how a detail
* list follows the master container without a line of script.
*/
/**
* Filters of a data source: they belong to the endpoint, so every component
* bound to it is filtered the same way.
*/
const renderDataSourceFilters = (source?: DesignerDataSource | null) => {
if (!source || source.method !== 'GET') return null
// The filter columns are a property of the endpoint, so they are read from
// the shape cache when the live sample is gone — otherwise adding the first
// required filter would remove the very list the next one is picked from.
const sample =
dataSourceSamples[source.id] !== undefined
? dataSourceSamples[source.id]
: dataSourceShapes[source.id]
const sampleRow = Array.isArray(sample)
? sample[0]
: sample &&
typeof sample === 'object' &&
Array.isArray((sample as Record<string, unknown>).items)
? ((sample as Record<string, unknown>).items as unknown[])[0]
: sample
const columns =
sampleRow && typeof sampleRow === 'object' && !Array.isArray(sampleRow)
? Object.keys(sampleRow as Record<string, unknown>)
: []
return renderFilterEditor({
scopeId: source.id,
filters: (source.filters || []) as DesignerDataSourceFilter[],
columns,
onChange: (next) => {
updateDataSourceFilters(source.id, next)
// The preview is only meaningful with the new filters applied, but a value
// is typed one character at a time — so the sample is refreshed once the
// typing stops instead of on every keystroke.
window.clearTimeout(filterPreviewTimers.current[source.id])
filterPreviewTimers.current[source.id] = window.setTimeout(
() => void testDataSource({ ...source, filters: next }),
600,
)
},
})
}
/**
* Filters of a platform view. The view owns its data through its list form, so
* the filters live on the node and are carried into the view as the same
* `filter` expression a list form page already reads from its address.
*/
const renderPlatformViewFilters = (node?: DesignerNode | null) => {
if (!node || node.kind !== 'platform') return null
const fields = listFormFields[String(node.props.listFormCode || '')] || []
return renderFilterEditor({
scopeId: node.id,
filters: (Array.isArray(node.props.filters)
? node.props.filters
: []) as DesignerDataSourceFilter[],
columns: fields,
// The list form knows its fields, and a name that is not one of them is
// dropped server side without a trace — so the column is picked, not typed.
strictColumns: fields.length > 0,
onChange: (next) => updateNodeProp(node.id, 'filters', next),
})
}
const renderFilterEditor = ({
scopeId,
filters,
columns,
strictColumns = false,
onChange,
}: {
scopeId: string
filters: DesignerDataSourceFilter[]
/** Column names offered as suggestions; empty when nothing was sampled. */
columns: string[]
/** The columns are the complete list, so the field is picked rather than typed. */
strictColumns?: boolean
onChange: (next: DesignerDataSourceFilter[]) => void
}) => {
const listId = `filter-columns-${scopeId}`
const writeFilters = onChange
const patchFilter = (id: string, updates: Partial<DesignerDataSourceFilter>) =>
writeFilters(filters.map((filter) => (filter.id === id ? { ...filter, ...updates } : filter)))
const filterSourceOptions = DESIGNER_FILTER_SOURCES.map((item) => ({
value: item,
label: translate(
`::App.DeveloperKitComponentDesigner.FilterSource${
item.charAt(0).toUpperCase() + item.slice(1)
}`,
),
}))
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.Filters')}
</span>
<button
className="rounded border border-sky-300 px-2 py-0.5 text-[10px] font-semibold text-sky-600 hover:bg-sky-50 dark:border-sky-800 dark:hover:bg-sky-950"
type="button"
onClick={() =>
writeFilters([
...filters,
createDesignerFilter(`filter_${createDesignerId().slice(-8)}`),
])
}
>
+ {translate('::App.DeveloperKitComponentDesigner.AddFilter')}
</button>
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.FiltersHint')}
</p>
<datalist id={listId}>
{columns.map((column) => (
<option key={column} value={column} />
))}
</datalist>
{!filters.length && (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-gray-950">
{translate('::App.DeveloperKitComponentDesigner.NoFilter')}
</p>
)}
<div className="space-y-2">
{filters.map((filter) => {
const valueless = isValuelessDesignerFilter({
...filter,
operator: filter.operator as DesignerFilterOperator,
})
const [masterRef = '', ...columnParts] = String(filter.value || '').split('.')
const masterColumn = columnParts.join('.')
const masterRecord =
formRecords[sqlContainerRefs.find((item) => item.ref === masterRef)?.id || '']
const masterColumns =
masterRecord && typeof masterRecord === 'object' && !Array.isArray(masterRecord)
? Object.keys(masterRecord as Record<string, unknown>)
: []
return (
<div
key={filter.id}
className="space-y-1.5 rounded-md border border-slate-200 bg-slate-50 p-2 dark:border-gray-700 dark:bg-gray-950"
>
<div className="flex items-center gap-1.5">
{strictColumns ? (
<Select
size="xs"
className="min-w-0 flex-1"
isClearable
maxMenuHeight={200}
placeholder={translate('::App.DeveloperKitComponentDesigner.FilterColumn')}
menuPortalTarget={window.document.body}
options={(filter.field && !columns.includes(filter.field)
? [filter.field, ...columns]
: columns
).map((column) => ({ value: column, label: column }))}
value={filter.field ? { value: filter.field, label: filter.field } : null}
onChange={(option) => patchFilter(filter.id, { field: option?.value ?? '' })}
/>
) : (
<Input
unstyle
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-gray-700 dark:bg-gray-900"
list={listId}
placeholder={translate('::App.DeveloperKitComponentDesigner.FilterColumn')}
value={filter.field || ''}
onChange={(event) => patchFilter(filter.id, { field: event.target.value })}
/>
)}
<Select
size="xs"
className="w-24"
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={DESIGNER_FILTER_OPERATORS.map((operator) => ({
value: operator,
label: FILTER_OPERATOR_LABELS[operator],
}))}
value={{
value: filter.operator,
label: FILTER_OPERATOR_LABELS[filter.operator],
}}
onChange={(option) =>
option &&
patchFilter(filter.id, {
operator: option.value as DesignerFilterOperator,
})
}
/>
<button
className="rounded p-1 text-slate-400 hover:text-red-600"
title={translate('::App.Platform.Delete')}
type="button"
onClick={() => writeFilters(filters.filter((entry) => entry.id !== filter.id))}
>
<FaTrash size={10} />
</button>
</div>
{!valueless && (
<div className="flex items-center gap-1.5">
<Select
size="xs"
className="w-24"
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={filterSourceOptions}
value={filterSourceOptions.filter((option) => option.value === filter.source)}
onChange={(option) =>
option &&
patchFilter(filter.id, {
source: option.value as DesignerFilterSource,
value: '',
})
}
/>
{filter.source !== 'record' && (
<Input
unstyle
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate(
filter.source === 'static'
? '::App.DeveloperKitComponentDesigner.FilterValue'
: '::App.DeveloperKitComponentDesigner.FilterUrlParam',
)}
value={filter.value || ''}
onChange={(event) => patchFilter(filter.id, { value: event.target.value })}
/>
)}
</div>
)}
{/* The master reference needs a row of its own: the container and
the column it is read from are two picks, and squeezing them
next to the source selector left neither of them usable. */}
{!valueless && filter.source === 'record' && (
<div className="flex items-center gap-1.5">
<Select
size="xs"
className="min-w-0 flex-1"
isClearable
maxMenuHeight={200}
placeholder={translate('::App.DeveloperKitComponentDesigner.FilterMaster')}
menuPortalTarget={window.document.body}
options={sqlContainerRefs.map((item) => ({
value: item.ref,
label: item.ref,
}))}
value={masterRef ? { value: masterRef, label: masterRef } : null}
onChange={(option) =>
patchFilter(filter.id, {
value: masterColumn
? `${option?.value ?? ''}.${masterColumn}`
: (option?.value ?? ''),
})
}
/>
<Input
unstyle
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-gray-700 dark:bg-gray-900"
list={`${listId}-${filter.id}-master`}
placeholder={translate(
'::App.DeveloperKitComponentDesigner.FilterMasterColumn',
)}
value={masterColumn}
onChange={(event) =>
patchFilter(filter.id, { value: `${masterRef}.${event.target.value}` })
}
/>
<datalist id={`${listId}-${filter.id}-master`}>
{masterColumns.map((column) => (
<option key={column} value={column} />
))}
</datalist>
</div>
)}
{/* Both halves are needed; without the column the filter resolves
to nothing and silently stops filtering. */}
{!valueless && filter.source === 'record' && (!masterRef || !masterColumn) && (
<p className="text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKitComponentDesigner.FilterMasterIncomplete')}
</p>
)}
{!valueless && filter.source !== 'static' && (
<Input
unstyle
className="w-full rounded border border-dashed border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.FilterPreviewValue',
)}
value={filter.previewValue || ''}
onChange={(event) =>
patchFilter(filter.id, { previewValue: event.target.value })
}
/>
)}
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-slate-500">
<Input
unstyle
checked={Boolean(filter.required)}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) => patchFilter(filter.id, { required: event.target.checked })}
/>
{translate('::App.DeveloperKitComponentDesigner.FilterRequired')}
</label>
</div>
)
})}
</div>
</div>
)
}
/**
* Form 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.
*/
/** Key kaynağı seçenekleri; etiketleri dil anahtarı olarak saklanır. */
const keySourceOptions = useMemo(
() =>
FORM_KEY_SOURCES.map((item) => ({
value: item.value,
label: translate('::' + item.label),
})),
[translate],
)
const renderFormConfiguration = () => {
if (!selectedNode || !selectedIsForm) 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 = formRecords[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 && hasFormUrlParams(selectSource.url))
return (
<div className="space-y-3">
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
{translate('::App.DeveloperKitComponentDesigner.CommandSlotsHint')}
</div>
{FORM_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 (
// Deliberately not a <label>: it holds two selects, and a label
// forwards every click to the first control in it — which would
// close the permission menu the instant it opens.
<div
key={slot.property}
className="block rounded-lg border border-slate-200 p-3 dark:border-gray-700"
>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-400">
<span>{slot.label}</span>
{currentValue && (
<span className="text-emerald-600">
{translate('::App.DeveloperKitComponentDesigner.Bound')}
</span>
)}
</span>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::' + slot.description)}
</p>
<Select
size="xs"
className="w-full"
isClearable
maxMenuHeight={200}
placeholder={translate('::App.DeveloperKitComponentDesigner.NotDefined')}
menuPortalTarget={window.document.body}
options={options.map((source) => ({
value: source.id,
label: `${source.name} · ${source.url}`,
}))}
value={options
.filter((source) => source.id === currentValue)
.map((source) => ({
value: source.id,
label: `${source.name} · ${source.url}`,
}))}
onChange={(option) => {
const sourceId = option?.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)
}
}}
/>
{renderPermissionPicker(
`${selectedNode.id}:${slot.permissionProperty}`,
getFormPermission(selectedNode, slot),
(permission) => updateSelectedProp(slot.permissionProperty, permission),
{
auto: '::App.DeveloperKitComponentDesigner.PermissionHintAuto',
custom: PERMISSION_HINT_KEYS[slot.permissionProperty],
},
)}
{!options.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKitComponentDesigner.NoEndpointForMethod', {
method: slot.method,
})}
</p>
)}
</div>
)
})}
<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
size="xs"
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-gray-700 dark:bg-gray-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
size="xs"
className="w-full"
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={collectionPaths.map((path) => ({
value: path,
label: path || translate('::App.DeveloperKitComponentDesigner.ResponseItself'),
}))}
value={{
value: String(selectedNode.props.collectionPath ?? ''),
label:
String(selectedNode.props.collectionPath ?? '') ||
translate('::App.DeveloperKitComponentDesigner.ResponseItself'),
}}
onChange={(option) => updateSelectedProp('collectionPath', option?.value ?? '')}
/>
</label>
</div>
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Select key parametresi
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.KeyParamHint')}
</p>
<Select
size="xs"
className="mb-2 w-full"
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={keySourceOptions}
value={keySourceOptions.filter((item) => item.value === getFormKeySource(selectedNode))}
onChange={(option) => option && updateSelectedProp('keySource', option.value)}
/>
<Input
unstyle
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate('::App.DeveloperKitComponentDesigner.KeyParamPlaceholder', {
field: getFormKeyField(selectedNode),
})}
value={String(selectedNode.props.keyParamName ?? '')}
onChange={(event) => updateSelectedProp('keyParamName', event.target.value)}
/>
</div>
{renderDataSourceFilters(selectSource)}
{selectNeedsPreviewKey && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.PreviewKeyValue')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.PreviewKeyValueHint')}
</p>
<Input
unstyle
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-gray-700 dark:bg-gray-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.PreviewKeyValuePlaceholder',
)}
value={String(selectedNode.props.previewKeyValue ?? '')}
onChange={(event) => updateSelectedProp('previewKeyValue', event.target.value)}
/>
</div>
)}
<div className="space-y-1.5 rounded-lg border border-slate-200 p-3 dark:border-gray-700">
{(
[
['autoLoad', 'App.DeveloperKitComponentDesigner.AutoLoad', true],
['showToolbar', 'App.DeveloperKitComponentDesigner.ShowToolbar', true],
] as const
).map(([property, label, defaultChecked]) => (
<label key={property} className="flex cursor-pointer items-center gap-2 text-[10px]">
<Input
unstyle
checked={
defaultChecked
? selectedNode.props[property] !== false
: selectedNode.props[property] === true
}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) => updateSelectedProp(property, event.target.checked)}
/>
{translate('::' + label)}
</label>
))}
</div>
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.RecordColumns')}
</div>
{recordFields.length ? (
<div className="max-h-40 space-y-1 overflow-y-auto">
{recordFields.map((field) => (
<div
key={field.path}
className="flex items-center justify-between gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-gray-950"
title={field.preview}
>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
</div>
))}
</div>
) : (
<p className="text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.RunSelectToListColumns')}
</p>
)}
</div>
</div>
)
}
/**
* Banner shown for anything inside a Form: 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(getFormEndpointId(sqlScopeNode, 'selectEndpoint'))
const record = formRecords[sqlScopeNode.id]
const hasColumns = record !== undefined && discoverDataFields(record).length > 0
if (!hasSelect) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-[10px] leading-4 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
{translate('::App.DeveloperKitComponentDesigner.DefineSelectEndpointFirst')}
</div>
)
}
return (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
{translate('::App.DeveloperKitComponentDesigner.BoundToForm')}
{!hasColumns && ` ${translate('::App.DeveloperKitComponentDesigner.RunSelectOnce')}`}
</div>
)
}
/**
* Every column the record can offer, including the ones a sampled row cannot
* reveal: an object column (`extraProperties`) has no leaf to discover, and a
* column added after the last Select run is not in the sample at all. Both stay
* typeable, so a field is never blocked by a stale or empty sample.
*/
const sqlRecordColumns = useMemo(() => {
const record = sqlScopeNode ? formRecords[sqlScopeNode.id] : undefined
if (!record || typeof record !== 'object') return []
const columns = new Map<string, string>()
Object.entries(record as Record<string, unknown>).forEach(([path, value]) =>
columns.set(path, getDataValueType(value)),
)
discoverDataFields(record)
.filter((field) => field.type !== 'array')
.forEach((field) => columns.set(field.path, field.type))
return [...columns.entries()]
.map(([path, type]) => ({ path, type }))
.sort((left, right) => left.path.localeCompare(right.path))
}, [formRecords, sqlScopeNode])
/**
* The prop a Form reads from and writes back to. An already bound node
* keeps its prop; an unbound one is offered the prop it actually carries, which
* is `checked` for the toggles and `value` for every other editor.
*/
const sqlRecordProperty = useMemo<SqlFormValueProperty | undefined>(() => {
if (!sqlScopeNode || !selectedNode) return undefined
const names = new Set((selectedDefinition?.properties || []).map((property) => property.name))
return getSqlRecordProperty(selectedNode, sqlScopeNode.id, names)
}, [selectedDefinition, selectedNode, sqlScopeNode])
/**
* Record column and its new-record default, for anything editable dropped into
* a Form. The column is a free text field backed by a datalist rather
* than a dropdown: the sampled row is a convenience, not the list of columns
* that exist.
*/
const renderSqlRecordField = () => {
if (!sqlScopeNode || !selectedNode || !sqlRecordProperty) return null
const binding = selectedNode.bindings?.[sqlRecordProperty]
const column = binding?.sourceId === sqlScopeNode.id ? binding.path : ''
const rawDefault = selectedNode.props?.[SQL_DEFAULT_VALUE_PROP]
const currentDefault = rawDefault === undefined || rawDefault === null ? '' : String(rawDefault)
const sqlDefaultValueOptions = [
{ value: 'true', label: translate('::App.Platform.Yes') },
{ value: 'false', label: translate('::App.Platform.No') },
]
const columnListId = `sql-columns-${sqlScopeNode.id}`
// A picker gets a real date editor, unless it holds a token — `@today` is not
// a date the browser can render, and typing it needs a plain text field.
const isDateDefault =
isDesignerDateComponent(selectedNode.type) && !currentDefault.startsWith('@')
const defaultInputType = isDateDefault
? isDesignerDateOnlyComponent(selectedNode.type)
? 'date'
: 'datetime-local'
: selectedNode.props?.type === 'number'
? 'number'
: 'text'
return (
<div className="space-y-3 rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.RecordField')} · {sqlRecordProperty}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.RecordFieldHint')}
</p>
<Input
unstyle
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-gray-700 dark:bg-gray-900"
list={columnListId}
placeholder={translate('::App.DeveloperKitComponentDesigner.DoNotBindToRecord')}
value={column}
onChange={(event) => {
const path = event.target.value.trim()
updateSelectedBinding(sqlRecordProperty, path ? sqlScopeNode.id : '', path)
}}
/>
<datalist id={columnListId}>
{sqlRecordColumns.map((column) => (
<option key={column.path} label={column.type} value={column.path} />
))}
</datalist>
{!sqlRecordColumns.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKitComponentDesigner.DefineAndRunSelect')}
</p>
)}
</div>
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.DefaultValue')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.DefaultValueHint', {
column: column || '—',
})}
</p>
{sqlRecordProperty === 'checked' ? (
<Select
size="xs"
className="w-full"
isClearable
isDisabled={!column}
maxMenuHeight={200}
placeholder={translate('::App.DeveloperKitComponentDesigner.NoDefaultValue')}
menuPortalTarget={window.document.body}
options={sqlDefaultValueOptions}
value={sqlDefaultValueOptions.filter((option) => option.value === currentDefault)}
onChange={(option) => updateSelectedProp(SQL_DEFAULT_VALUE_PROP, option?.value ?? '')}
/>
) : (
<Input
unstyle
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900"
disabled={!column}
placeholder={translate('::App.DeveloperKitComponentDesigner.NoDefaultValue')}
type={defaultInputType}
value={
defaultInputType === 'datetime-local' ? currentDefault.slice(0, 16) : currentDefault
}
onChange={(event) => updateSelectedProp(SQL_DEFAULT_VALUE_PROP, event.target.value)}
/>
)}
<div className="mt-1.5 flex flex-wrap gap-1">
{SQL_DEFAULT_VALUE_TOKENS.map((token) => (
<button
key={token}
className="rounded border border-slate-300 px-1.5 py-0.5 font-mono text-[9px] text-slate-600 hover:border-sky-400 hover:text-sky-700 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300"
disabled={!column || sqlRecordProperty === 'checked'}
type="button"
onClick={() => updateSelectedProp(SQL_DEFAULT_VALUE_PROP, `@${token}`)}
>
@{token}
</button>
))}
</div>
</div>
</div>
)
}
const renderDataInspector = () => {
if (selectedNode?.kind === 'platform') {
return (
<div className="space-y-3">
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs leading-5 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
<strong className="block">
{translate('::App.DeveloperKitComponentDesigner.PlatformDataModel')}
</strong>
{translate('::App.DeveloperKitComponentDesigner.PlatformDataModelHint', {
type: selectedNode.type,
})}
</div>
{/* The list form supplies the data; the filters narrow it, so a view can
follow a Form record or a page parameter without the list form being
duplicated for every case. */}
{renderPlatformViewFilters(selectedNode)}
</div>
)
}
if (selectedIsForm) return renderFormConfiguration()
const scopeNotice = renderSqlScopeNotice()
const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration()
if (selectedIsCollectionComponent && selectDataMode === 'static') {
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
{renderSqlRecordField()}
</div>
)
}
if (!inspectorDataSources.length) {
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
<div className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-gray-700">
{translate('::App.DeveloperKitComponentDesigner.NoSelectableGetEndpoint')}
</div>
{renderSqlRecordField()}
</div>
)
}
return (
<div className="space-y-3">
{scopeNotice}
{selectModeSelector}
{/* Inside a Form a record field has exactly one legal source, so
the picker is replaced by a read-only statement of that source. */}
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{sqlScopeLocked
? translate('::App.Platform.DataSource')
: isOptionDataComponent(selectedNode?.type)
? '1. Endpoint'
: 'Endpoint'}
</span>
{sqlScopeLocked ? (
<div className="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-2 text-xs text-slate-600 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300">
{sqlScopeSource?.name}
</div>
) : (
<Select
size="sm"
className="w-full"
isClearable
maxMenuHeight={200}
placeholder={translate('::App.DeveloperKitComponentDesigner.SelectEndpoint')}
menuPortalTarget={window.document.body}
options={inspectorDataSources.map((source) => ({
value: source.id,
label: source.name,
}))}
value={inspectorDataSources
.filter((source) => source.id === (activeDataSource?.id || ''))
.map((source) => ({ value: source.id, label: source.name }))}
onChange={(option) => {
const sourceId = option?.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
delete props.columnLookups
delete props.columnCaptions
return {
...node,
props,
bindings: {
...node.bindings,
items: { sourceId, path: '' },
},
}
}),
}))
} else {
updateSelectedBinding(optionDataProperty, sourceId, '')
}
}
const source = document.dataSources.find((item) => item.id === sourceId)
if (source && dataSourceSamples[sourceId] === undefined) {
void testDataSource(source)
}
}}
/>
)}
</label>
{/* Read permission of the component itself: without it the component is
not rendered, which is what hides a Grid whose rows the user may
not see. */}
{selectedNode &&
renderPermissionPicker(
`${selectedNode.id}:${DESIGNER_PERMISSION_PROP}`,
getDesignerNodePermission(selectedNode),
(permission) => updateSelectedProp(DESIGNER_PERMISSION_PROP, permission),
{
// A plain component has no Wizard managed command of its own, so
// `Otomatik` here means the page's menu permission decides.
auto: '::App.DeveloperKitComponentDesigner.PermissionHintComponentAuto',
custom: '::App.DeveloperKitComponentDesigner.PermissionHintComponent',
},
)}
</div>
{!sqlScopeLocked && renderDataSourceFilters(activeDataSource)}
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) &&
activeDataSample !== undefined &&
tabularItemsBinding?.sourceId && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Koleksiyon
</span>
<Select
size="xs"
className="w-full"
maxMenuHeight={200}
placeholder={translate('::App.DeveloperKitComponentDesigner.NoCollectionFound')}
menuPortalTarget={window.document.body}
options={tabularCollectionPaths.map((path) => ({
value: path,
label:
path || translate('::App.DeveloperKitComponentDesigner.WholeResponseArray'),
}))}
value={{
value: tabularItemsBinding.path,
label:
tabularItemsBinding.path ||
translate('::App.DeveloperKitComponentDesigner.WholeResponseArray'),
}}
onChange={(option) =>
updateSelectedBindingDetails('items', { path: option?.value ?? '' })
}
/>
</label>
</div>
)}
{!isOptionDataComponent(selectedNode?.type) && activeBindingSample !== undefined && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-gray-700">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.AvailableFields')}
</span>
<div className="flex items-center gap-2">
{isTabularDataComponent(selectedNode?.type) && gridColumnFields.length > 0 && (
<label className="flex cursor-pointer items-center gap-1 text-[9px] font-semibold text-sky-600">
<Input
unstyle
checked={allGridColumnsSelected}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={toggleAllGridColumns}
/>
{translate(
allGridColumnsSelected
? '::App.Platform.RemoveAll'
: '::App.Platform.SelectAll',
)}
</label>
)}
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[9px] text-emerald-700">
{isTabularDataComponent(selectedNode?.type)
? `${selectedGridColumns.length}/${gridColumnFields.length}`
: `${activeDataFields.length} alan`}
</span>
</div>
</div>
{Array.isArray(activeBindingSample) && (
<div className="mb-2 rounded bg-indigo-50 px-2 py-1.5 text-[10px] text-indigo-700 dark:bg-indigo-950 dark:text-indigo-200">
{translate(
isActiveRepeatedSource
? '::App.DeveloperKitComponentDesigner.GridRows'
: '::App.DeveloperKitComponentDesigner.Collection',
)}{' '}
·{' '}
{translate('::App.Platform.RecordCount', {
count: activeBindingSample.length,
})}
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto">
{activeDataFields.length ? (
activeDataFields.map((field) => (
<label
key={field.path}
className="grid cursor-pointer grid-cols-[auto_1fr_auto] gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-gray-950"
title={field.preview}
>
{isTabularDataComponent(selectedNode?.type) &&
!['array', 'object'].includes(field.type) ? (
<Input
unstyle
checked={selectedGridColumns.includes(field.path)}
className="mt-0.5 h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => toggleGridColumn(field.path)}
/>
) : (
<span />
)}
<code className="truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
<span className="col-span-2 col-start-2 truncate text-[9px] text-slate-400">
{field.preview}
</span>
</label>
))
) : (
<p className="text-[10px] text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.NoColumnInResponse')}
</p>
)}
</div>
</div>
)}
{isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
{!isOptionDataComponent(selectedNode?.type) &&
!isTabularDataComponent(selectedNode?.type) && (
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.PropertyBindings')}
</div>
{!orderedBindableProperties.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-gray-700">
{translate('::App.DeveloperKitComponentDesigner.NoBindableProperty')}
</p>
) : (
orderedBindableProperties.map((property) => {
const choice = getBindingChoice(property.name)
const collection = isCollectionProperty(property.name, property.type)
const selectableFields = collection
? activeDataFields.filter((field) => field.type === 'array')
: activeDataFields
const currentBinding = selectedNode?.bindings?.[property.name]
const currentBindingSource = document.dataSources.find(
(source) => source.id === currentBinding?.sourceId,
)
// A binding saved before the Form lock existed can still
// point at an unrelated endpoint; the dropdown cannot show it, so
// it is called out explicitly instead of failing silently.
const foreignBinding =
sqlScopeLocked &&
Boolean(currentBinding?.sourceId) &&
currentBinding?.sourceId !== sqlScopeSource?.id
const knownChoice =
choice === '' ||
choice === '__root__' ||
selectableFields.some((field) => field.path === choice)
const bindingChoiceOptions = [
...(activeBindingSample !== undefined &&
(collection || !Array.isArray(activeBindingSample) || isActiveRepeatedSource)
? [
{
value: '__root__',
label: translate(
isActiveRepeatedSource
? '::App.DeveloperKitComponentDesigner.CurrentGridRow'
: collection
? '::App.DeveloperKitComponentDesigner.WholeCollection'
: '::App.DeveloperKitComponentDesigner.WholeResponse',
),
},
]
: []),
...(!knownChoice ? [{ value: choice, label: `${choice} (mevcut path)` }] : []),
...selectableFields.map((field) => ({
value: field.path,
label: `${field.path} · ${field.type}`,
})),
]
return (
<label
key={property.name}
className="mb-2 block rounded-lg border border-slate-200 p-2 dark:border-gray-700"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-gray-300">
<span>{property.name}</span>
<span className="text-[9px] font-normal text-slate-400">
{property.tsType || property.type}
</span>
</span>
<Select
size="xs"
className="w-full"
isClearable
maxMenuHeight={200}
placeholder={translate(
'::App.DeveloperKitComponentDesigner.StaticValueNoBinding',
)}
menuPortalTarget={window.document.body}
options={bindingChoiceOptions}
value={bindingChoiceOptions.filter((option) => option.value === choice)}
onChange={(option) => {
const nextChoice = option?.value
if (!nextChoice) {
updateSelectedBinding(property.name, '')
return
}
if (!activeDataSource) return
updateSelectedBinding(
property.name,
activeDataSource.id,
getBindingPath(nextChoice, collection),
)
}}
/>
{currentBinding?.sourceId && !foreignBinding && (
<div className="mt-1.5 truncate font-mono text-[9px] text-emerald-600">
{currentBindingSource?.name || currentBinding.sourceId}:{' '}
{currentBinding.path || '(root)'}
</div>
)}
{foreignBinding && (
<div className="mt-1.5 rounded bg-amber-50 p-1.5 text-[9px] leading-3 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
<span className="block truncate font-mono">
{currentBindingSource?.name || currentBinding?.sourceId}:{' '}
{currentBinding?.path || '(root)'}
</span>
<button
className="mt-1 font-semibold underline"
type="button"
onClick={() => updateSelectedBinding(property.name, '')}
>
{translate('::App.DeveloperKitComponentDesigner.ClearNonSqlBinding')}
</button>
</div>
)}
</label>
)
})
)}
</div>
)}
{renderSqlRecordField()}
</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-gray-300 dark:hover:bg-gray-800'}`}
style={{ paddingLeft: 8 + depth * 14 }}
type="button"
onClick={() => selectDesignerNode(node.id)}
>
<FaLayerGroup className="shrink-0" />
<span className="truncate">{node.type}</span>
<span className="ml-auto text-[9px] text-slate-400">{node.id.slice(-4)}</span>
</button>
{renderTree(node.children, depth + 1)}
</React.Fragment>
))
const pageHelmet = <PageTitle title={pageTitle} />
if (!component) {
return (
<div className="flex min-h-[70vh] items-center justify-center text-sm text-slate-500">
{pageHelmet}
{componentLoadError || translate('::App.DeveloperKitComponentDesigner.LoadingComponent')}
</div>
)
}
const canvas = (
<VisualCanvas
interactive
canvasWidth={document.canvas.width}
nodes={document.nodes}
selectedId={selectedId}
dataValues={previewDataValues}
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
onDropComponent={addComponent}
onDropComponentBeside={addComponentBeside}
onMoveIntoContainer={moveNodeIntoContainerNode}
onMove={moveNode}
onReorder={reorderNode}
onDuplicate={duplicateNode}
onDelete={deleteNode}
/>
)
const javascriptEventTargets: Array<{ node: DesignerNode; eventNames: string[] }> = []
walkDesignerNodes(document.nodes, (node) => {
const definition = catalogByName.get(node.type)
const primaryEvents = DESIGNER_PRIMARY_EVENTS[node.type]
const savedEventNames = Object.entries(node.events || {})
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName)
const definitionEventNames = (definition?.properties || [])
.filter((property) => property.category === 'events' || property.type === 'function')
.map((property) => property.name)
const eventNames = Array.from(
new Set([...(primaryEvents || definitionEventNames), ...savedEventNames]),
)
if (eventNames.length) javascriptEventTargets.push({ node, eventNames })
})
const activeJavaScriptTarget =
javascriptEventTargets.find((target) => target.node.id === selectedId) ||
javascriptEventTargets[0]
// A Script API example is appended to the editor the user last worked in,
// falling back to the first event of the selected component.
const activeEventName = activeJavaScriptTarget?.eventNames.includes(focusedEventName)
? focusedEventName
: activeJavaScriptTarget?.eventNames[0] || ''
const appendScriptToEvent = (snippet: string) => {
if (!activeJavaScriptTarget || !activeEventName) return
const current = activeJavaScriptTarget.node.events[activeEventName] || ''
updateNodeEvent(
activeJavaScriptTarget.node.id,
activeEventName,
current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet,
)
}
/**
* Builder context of the event being edited: the Form 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
? isFormNode(scriptBuilderNode.type)
? scriptBuilderNode
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
.reverse()
.find((node) => isFormNode(node.type))
: undefined
const scriptBuilderRecordFields = scriptBuilderSqlNode
? Object.keys((formRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {})
: []
const endpointResultText = endpointResultModal
? typeof endpointResultModal.result === 'string'
? endpointResultModal.result
: (JSON.stringify(endpointResultModal.result, null, 2) ?? String(endpointResultModal.result))
: ''
const endpointResultCount = endpointResultModal
? Array.isArray(endpointResultModal.result)
? translate('::App.Platform.RecordCount', {
count: endpointResultModal.result.length,
})
: endpointResultModal.result && typeof endpointResultModal.result === 'object'
? `${Object.keys(endpointResultModal.result).length} alan`
: typeof endpointResultModal.result
: ''
return (
<div className="flex h-[calc(100vh-72px)] min-h-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-100 shadow-sm dark:border-gray-700 dark:bg-gray-800">
{pageHelmet}
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-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-gray-800"
title={translate('::App.DeveloperKitComponentEditor.Back')}
to={ROUTES_ENUM.protected.saas.developerKit.components}
>
<FaArrowLeft />
</Link>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-indigo-600 text-white">
<FaMousePointer />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-sm font-semibold text-slate-800 dark:text-white">
{name}
</h1>
{isDirty ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">
Unsaved
</span>
) : (
<FaCheckCircle className="text-emerald-500" />
)}
</div>
<p className="truncate text-[11px] text-slate-500">
Visual Component Designer · {component.routePath}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<Button
disabled={!undoStack.current.length}
icon={<FaUndo />}
size="xs"
title={translate('::App.Platform.UndoWithShortcut')}
variant="plain"
onClick={undo}
/>
<Button
disabled={!redoStack.current.length}
icon={<FaRedo />}
size="xs"
title="Yinele"
variant="plain"
onClick={redo}
/>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-gray-700" />
<Button
icon={<FaSync />}
size="sm"
title={translate('::App.DeveloperKitComponentDesigner.ResetCanvas')}
variant="plain"
onClick={() => {
commitDocument(() => createEmptyDesignerDocument('visual'))
setSelectedId(null)
}}
>
{translate('::App.Platform.Reset')}
</Button>
<Button
color="green-600"
icon={<FaEye />}
size="sm"
title={translate('::App.DeveloperKitComponentDesigner.OpenInNewTab')}
variant="solid"
onClick={() => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank')
}}
>
{translate('::App.Listform.ListformField.Preview')}
</Button>
<Button
loading={isSaving}
icon={<FaRegSave />}
size="sm"
variant="solid"
onClick={() => void handleSave()}
>
{translate('::App.Platform.Save')}
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{workspaceTab === 'design' && (
<aside className="flex w-80 shrink-0 flex-col border-r border-slate-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<div className="border-b border-slate-200 p-3 dark:border-gray-700">
<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
unstyle
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-gray-700 dark:bg-gray-950"
placeholder={translate('::App.DeveloperKitComponentDesigner.SearchComponent')}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
</div>
<div className="flex-1 overflow-y-auto p-2">
{groupedCatalog.map(([group, definitions]) => (
<section key={group} className="mb-5">
<button
aria-expanded={expandedToolboxGroups[group] || Boolean(search.trim())}
className="mb-2 flex w-full items-center gap-2 rounded bg-slate-100 px-2 py-1.5 text-left text-[10px] font-bold uppercase tracking-widest text-slate-600 dark:bg-gray-800 dark:text-gray-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-gray-900/60">
{definitions.length}
</span>
</button>
{(expandedToolboxGroups[group] || Boolean(search.trim())) && (
<div className="grid grid-cols-3 gap-1.5">
{definitions.map((definition) => (
<button
key={`${definition.toolboxGroup}-${definition.name}`}
draggable
className={`group min-w-0 rounded-lg border bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:bg-gray-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-gray-700'
}`}
title={
definition.acceptsChildren
? `${translate('::' + definition.description)}\n\n${translate('::App.DeveloperKitComponentDesigner.AcceptsChildren')}`
: translate('::' + definition.description)
}
type="button"
onClick={() => addComponent(definition.name, null)}
onDragStart={(event) => {
const payload = JSON.stringify({
source: 'library',
name: definition.name,
})
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
event.dataTransfer.setData('text/plain', payload)
// Canvas bırakma hedeflerini vurgulayabilmek için
// sürüklemenin başladığını duyuruyoruz — senkron bir
// state güncellemesi sürüklemeyi iptal ettirebildiği
// için bir tık sonraya bırakarak.
beginDesignerDragSoon({ source: 'library', name: definition.name })
}}
onDragEnd={() => endDesignerDrag()}
>
<div className="mb-1 flex h-6 w-6 items-center justify-center rounded bg-slate-100 text-[10px] font-bold text-slate-500 group-hover:bg-sky-100 group-hover:text-sky-600 dark:bg-gray-800">
{definition.name.slice(0, 2).toUpperCase()}
</div>
<div className="truncate text-[11px] font-medium text-slate-700 dark:text-gray-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-gray-700 dark:bg-gray-900">
<div className="flex h-full items-center gap-1">
{(
[
['design', 'Design', <FaMousePointer key="design" />],
['data', 'Data', <FaDatabase key="data" />],
['code', 'React', <FaCode key="code" />],
['event', 'Event', <FaBolt key="js" />],
] as [WorkspaceTab, string, React.ReactNode][]
).map(([tab, label, icon]) => (
<button
key={tab}
className={`flex h-full items-center gap-1.5 border-b-2 px-3 text-xs font-medium ${workspaceTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-white'}`}
type="button"
onClick={() => setWorkspaceTab(tab)}
>
{icon}
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
{workspaceTab === 'design' && (
<div className="flex items-center rounded-md border border-slate-200 p-0.5 dark:border-gray-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-gray-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-gray-700 dark:hover:text-white'
}`}
title={translate('::App.DeveloperKitComponentDesigner.ScriptApiExamples')}
type="button"
onClick={() => setScriptApiOpen((current) => !current)}
>
<FaBolt />
Script API
<span className="rounded bg-sky-100 px-1 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-900 dark:text-sky-300">
{DESIGNER_SCRIPT_RECIPES.length}
</span>
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-slate-200 p-5 dark:bg-gray-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-gray-900 dark:text-gray-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-gray-700 dark:bg-gray-900">
<div className="min-w-0">
<h2 className="flex items-center gap-2 font-semibold text-slate-800 dark:text-white">
<FaDatabase className="text-sky-600" /> Data sources
</h2>
<p className="mt-1 text-xs text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.ApiAuthHint')}{' '}
{translate('::App.DeveloperKitComponentDesigner.GetPostHint')}{' '}
{translate('::App.DeveloperKitComponentDesigner.EndpointSettingsHint')}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button icon={<FaPlus />} size="sm" variant="solid" onClick={addDataSource}>
{translate('::App.DeveloperKitComponentDesigner.NewEndpoint')}
</Button>
</div>
</div>
{(dataSourceCatalog.length > 0 || endpointCatalogError) && (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="mb-3">
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
{translate('::App.DeveloperKitComponentDesigner.AvailableSources')}
</h3>
<p className="mt-1 text-[10px] text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.AvailableSourcesHint')}
</p>
</div>
{renderReusableDataSources()}
</div>
)}
</div>
) : workspaceTab === 'code' ? (
<div className="h-full min-h-[560px] overflow-hidden rounded-lg border border-slate-700 bg-[#1e1e1e]">
<div className="flex items-center justify-between border-b border-slate-700 bg-slate-900 px-3 py-2 text-xs text-slate-300">
<span>
{document.sourceMode === 'visual'
? 'Generated React · read-only'
: 'Advanced React source'}
</span>
{document.sourceMode === 'visual' && (
<span className="text-emerald-400">Schema synchronized</span>
)}
</div>
<Editor
height="calc(100% - 36px)"
language="typescript"
options={{
automaticLayout: true,
fontSize: 13,
minimap: { enabled: false },
readOnly: document.sourceMode === 'visual',
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.sourceMode === 'visual' ? generatedCode : manualCode}
onChange={(value) => document.sourceMode === 'code' && setManualCode(value || '')}
/>
</div>
) : (
<div className="flex min-h-[620px] w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="w-72 shrink-0 border-r border-slate-200 bg-slate-50 p-3 dark:border-gray-700 dark:bg-gray-950">
<div className="mb-3">
<h2 className="flex items-center gap-2 font-semibold text-slate-800 dark:text-white">
<FaBolt className="text-amber-500" /> Event workspace
</h2>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.EventsHint')}
</p>
</div>
<div className="space-y-1">
{javascriptEventTargets.length ? (
javascriptEventTargets.map((target) => (
<button
key={target.node.id}
className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left transition ${activeJavaScriptTarget?.node.id === target.node.id ? 'border-amber-400 bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200' : 'border-transparent text-slate-600 hover:border-slate-200 hover:bg-white dark:text-gray-300 dark:hover:border-gray-700 dark:hover:bg-gray-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-gray-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-gray-700">
{translate('::App.DeveloperKitComponentDesigner.NoEventComponent')}
</p>
)}
</div>
{/* Every component on the page is addressable from any script,
which is what makes cross component behaviour possible. */}
<div className="mt-4 border-t border-slate-200 pt-3 dark:border-gray-700">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Referanslar
</h3>
<p className="mb-2 mt-1 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.RefsAccessHint')}{' '}
<code>setValue</code>, <code>setVisible</code>, <code>setEnabled</code>,{' '}
<code>setReadOnly</code>, <code>setText</code>, <code>setProps</code>,{' '}
<code>getValue</code>.
</p>
<div className="max-h-64 space-y-1 overflow-y-auto">
{[...documentRefs.entries()].map(([ref, node]) => (
<button
key={ref}
className="flex w-full items-center gap-2 rounded border border-transparent px-2 py-1 text-left hover:border-slate-200 hover:bg-white dark:hover:border-gray-700 dark:hover:bg-gray-900"
title={translate('::App.DeveloperKitComponentDesigner.CopyRefName')}
type="button"
onClick={() => copyRefName(ref)}
>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-600">
{ref}
</code>
<span className="shrink-0 text-[9px] text-slate-400">{node.type}</span>
</button>
))}
{!documentRefs.size && (
<p className="text-[10px] text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.NoComponentAdded')}
</p>
)}
</div>
</div>
</div>
<div className="min-w-0 flex-1 overflow-y-auto p-5">
<div className="mb-5 rounded-lg border border-slate-200 p-4 dark:border-gray-700">
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
Component lifecycle
</h3>
<p className="mt-0.5 text-[10px] text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.OnMountHint')}
</p>
</div>
<code className="text-[10px] text-sky-600">onMount()</code>
</div>
<Editor
height="150px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.lifecycle.onMount}
onChange={(value) =>
commitDocument((current) => ({
...current,
lifecycle: { onMount: value || '' },
}))
}
/>
</div>
{activeJavaScriptTarget ? (
<div>
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">
{activeJavaScriptTarget.node.type}
</h3>
<code className="text-[10px] text-slate-400">
{activeJavaScriptTarget.node.id}
</code>
</div>
<span className="rounded bg-amber-100 px-2 py-1 text-[10px] font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{activeJavaScriptTarget.eventNames.length} event
</span>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{activeJavaScriptTarget.eventNames.map((eventName) => (
<label
key={eventName}
className={`overflow-hidden rounded-lg border dark:border-gray-700 ${eventName === activeEventName ? 'border-amber-400' : 'border-slate-200'}`}
onFocus={() => setFocusedEventName(eventName)}
>
<span className="flex items-center justify-between bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-700 dark:bg-gray-950 dark:text-gray-200">
<span
className="min-w-0 truncate"
title={
getFormEvent(eventName)?.description
? translate('::' + getFormEvent(eventName)!.description)
: undefined
}
>
<code>{eventName}(event)</code>
{getFormEvent(eventName) &&
isFormNode(activeJavaScriptTarget.node.type) && (
<span className="ml-2 font-normal text-slate-400">
{getFormEvent(eventName)?.label}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[9px] font-normal text-slate-400">
{activeJavaScriptTarget.node.type}
</span>
<button
className="rounded bg-indigo-100 px-2 py-1 text-[9px] font-semibold text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950 dark:text-indigo-300"
title={translate(
'::App.DeveloperKitComponentDesigner.RuleBasedScriptBuilder',
)}
type="button"
onClick={() =>
setScriptBuilderTarget({
nodeId: activeJavaScriptTarget.node.id,
eventName,
})
}
>
Builder
</button>
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-sky-950 dark:text-sky-300"
disabled={Boolean(
activeJavaScriptTarget.node.events[eventName]?.trim(),
)}
type="button"
onClick={() =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
resolveRecipeCode(
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
),
),
)
}
>
{translate('::App.DeveloperKitComponentDesigner.AddSnippet')}
</button>
</span>
</span>
<Editor
height="190px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={activeJavaScriptTarget.node.events[eventName] || ''}
onChange={(value) =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
value || '',
)
}
/>
</label>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-gray-700">
{translate('::App.DeveloperKitComponentDesigner.NoEditableEvent')}
</div>
)}
</div>
</div>
)}
</div>
</main>
{workspaceTab === 'design' && (
<aside
data-property-panel
className="flex w-72 shrink-0 flex-col border-l border-slate-200 bg-white dark:border-gray-700 dark:bg-gray-900"
>
<div className="flex h-12 shrink-0 border-b border-slate-200 dark:border-gray-700">
{(
[
['properties', <FaSlidersH key="p" />, 'Properties'],
['data', <FaDatabase key="d" />, 'Data'],
['tree', <FaLayerGroup key="t" />, 'Tree'],
] as [InspectorTab, React.ReactNode, string][]
).map(([tab, icon, label]) => (
<button
key={tab}
className={`flex flex-1 items-center justify-center gap-1 border-b-2 text-[11px] ${tab === 'data' && !selectedIsDataComponent ? 'cursor-not-allowed border-transparent text-slate-300 dark:text-gray-700' : inspectorTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500'}`}
disabled={tab === 'data' && !selectedIsDataComponent}
title={
tab === 'data' && !selectedIsDataComponent
? translate('::App.DeveloperKitComponentDesigner.DataTabOnlyForDataCategory')
: undefined
}
type="button"
onClick={() => {
if (tab !== 'data' || selectedIsDataComponent) setInspectorTab(tab)
}}
>
{icon}
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3">
{inspectorTab === 'tree' ? (
<div>
{document.nodes.length ? (
renderTree(document.nodes)
) : (
<p className="p-3 text-xs text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.NoComponentYet')}
</p>
)}
</div>
) : selectedNode ? (
<>
<div className="mb-4 rounded-lg bg-slate-50 p-3 dark:bg-gray-950">
<div className="text-[10px] uppercase tracking-wider text-slate-400">
Selected component
</div>
<div className="mt-1 flex items-center justify-between">
<strong className="text-sm text-slate-800 dark:text-white">
{selectedNode.type}
</strong>
<button
className="rounded p-1.5 text-red-500 hover:bg-red-50"
title={translate('::App.ComponentAction.Delete')}
type="button"
onClick={() => deleteNode(selectedNode.id)}
>
<FaTrash />
</button>
</div>
<code className="text-[10px] text-slate-400">{selectedNode.id}</code>
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-gray-700">
<span className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Ref
<button
className="font-mono text-[10px] font-normal normal-case tracking-normal text-sky-600 hover:underline"
title={translate('::App.Platform.CopyToClipboard')}
type="button"
onClick={() => copyRefName(selectedNode.ref || '')}
>
refs.{selectedNode.ref}
</button>
</span>
<Input
unstyle
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-gray-700 dark:bg-gray-900 dark:text-gray-200"
spellCheck={false}
value={refDraft}
onBlur={commitRefRename}
onChange={(event) => setRefDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
{refError ? (
<p className="mt-1 text-[10px] leading-4 text-red-500">{refError}</p>
) : (
<p className="mt-1 text-[10px] leading-4 text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.RefRenameHint')}
</p>
)}
</div>
{selectedDefinition?.interfaceName && (
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-gray-700">
<div className="text-[10px] font-semibold text-sky-600">
{selectedDefinition.interfaceName}
</div>
<div
className="truncate text-[9px] text-slate-400"
title={selectedDefinition.sourceFile}
>
{selectedDefinition.sourceFile}
</div>
</div>
)}
</div>
{inspectorTab === 'properties' ? (
<>
{selectedProperties
.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
!(
isOptionDataComponent(selectedNode.type) &&
property.name === getOptionDataProperty(selectedNode.type)
) &&
!(
isTabularDataComponent(selectedNode.type) &&
['items', 'data', 'columns', 'dataColumns'].includes(property.name)
) &&
// Command slots and the key/collection settings are
// owned by the Form panel in the Data tab.
!(
selectedIsForm &&
[
...FORM_SLOTS.map((slot) => slot.property),
...FORM_SLOTS.map((slot) => slot.permissionProperty),
'keyFieldName',
'collectionPath',
'keySource',
'keyParamName',
'previewKeyValue',
'autoLoad',
'showToolbar',
'showNavigation',
'filterByUrlKey',
].includes(property.name)
),
)
.map((property) => {
const propertyValue = selectedNode.props[property.name] ?? property.value
const helperValue = getPropertyExampleValue(
property,
property.value,
translate,
)
const snippet = getPropertySnippet(property, helperValue, translate)
return (
<div
key={property.name}
className="group/property mb-4 rounded-md border border-slate-200 p-2 dark:border-gray-700"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-gray-300">
<span>
{property.name}
{property.required ? (
<b className="ml-0.5 text-red-500">*</b>
) : null}
</span>
<span className="flex items-center gap-1 text-[9px] font-normal text-slate-400">
<span>{property.tsType || property.type}</span>
<span className="group/snippet relative">
<button
className="rounded p-1 text-sky-500 opacity-0 transition hover:bg-sky-50 hover:text-sky-700 focus:opacity-100 group-hover/property:opacity-100 dark:hover:bg-sky-950"
title={translate(
'::App.DeveloperKitComponentDesigner.ApplyHelperValue',
)}
type="button"
onClick={() =>
updateSelectedProp(property.name, clone(helperValue))
}
>
<FaBolt />
</button>
<span className="pointer-events-none absolute right-0 top-full z-20 mt-1 hidden w-56 rounded-md border border-slate-200 bg-white p-2 text-left font-mono text-[9px] leading-4 text-sky-700 shadow-lg group-hover/snippet:block dark:border-gray-700 dark:bg-gray-950 dark:text-sky-300">
{snippet}
<span className="mt-1 block font-sans text-[9px] text-slate-400">
{translate(
'::App.DeveloperKitComponentDesigner.ApplyHelperValueHint',
)}
</span>
</span>
</span>
</span>
</span>
{property.description && (
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{translate('::' + property.description)}
</p>
)}
<PropertyEditor
componentType={selectedNode?.type}
name={property.name}
// The list forms are known, so a platform view picks
// its code instead of having it typed from memory.
type={
isPlatformListFormProperty(property.name)
? 'select'
: property.type
}
value={propertyValue}
options={
isPlatformListFormProperty(property.name)
? listFormCodes
: property.options
}
onChange={(value) => updateSelectedProp(property.name, value)}
/>
</div>
)
})}
</>
) : (
renderDataInspector()
)}
</>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-slate-300 p-5 text-center dark:border-gray-700">
<FaMousePointer className="mx-auto mb-2 text-slate-400" />
<p className="text-xs text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.SelectComponentForProps')}
</p>
</div>
</div>
)}
</div>
<div className="border-t border-slate-200 bg-slate-50 px-3 py-2 text-[10px] text-slate-500 dark:border-gray-700 dark:bg-gray-950">
<span className="font-semibold">{document.nodes.length}</span> root component ·{' '}
<span className="font-semibold">Ctrl+S</span> save ·{' '}
<span className="font-semibold">Del</span> delete
</div>
</aside>
)}
</div>
{/* Everything an event script can do, as copy/paste examples filled in
with this page's own ref names and endpoints. It is a drawer rather
than a dialog on purpose: no backdrop, so the editor underneath stays
usable while an example is being read or pasted. */}
{scriptApiOpen && (
<aside className="fixed inset-y-0 right-0 z-[90] flex w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-gray-700">
<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
<span className="shrink-0 rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300">
{translate('::App.DeveloperKitComponentDesigner.ExampleCount', {
count: DESIGNER_SCRIPT_RECIPES.length,
})}
</span>
</h2>
<code className="mt-1 block text-[10px] text-slate-400">
event · component · props · refs · api · notify
</code>
</div>
<button
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-gray-800 dark:hover:text-white"
title={translate('::App.Platform.CloseWithEsc')}
type="button"
onClick={() => setScriptApiOpen(false)}
>
×
</button>
</div>
<div className="shrink-0 border-b border-slate-200 px-4 py-3 dark:border-gray-700">
<Input
unstyle
className="w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-700 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200"
placeholder={translate('::App.DeveloperKitComponentDesigner.SearchExample')}
value={scriptApiSearch}
onChange={(event) => setScriptApiSearch(event.target.value)}
/>
{activeEventName ? (
<p className="mt-2 text-[10px] text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.AppendExampleHintPrefix')}{' '}
<code className="text-amber-600 dark:text-amber-400">{activeEventName}</code>{' '}
{translate('::App.DeveloperKitComponentDesigner.AppendExampleHintSuffix')}
</p>
) : (
<p className="mt-2 text-[10px] text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ExampleInsertHint')}
</p>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
{DESIGNER_SCRIPT_RECIPE_GROUPS.map((group) => {
const recipes = filteredScriptRecipes.filter((recipe) => recipe.group === group)
if (!recipes.length) return null
return (
<div key={group} className="mb-4 last:mb-0">
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{group}
</h4>
<div className="grid gap-2">
{recipes.map((recipe) => {
const recipeCode = resolveRecipeCode(recipe.code)
return (
<div
key={recipe.title}
className="overflow-hidden rounded-md border border-slate-200 dark:border-gray-700"
>
<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-gray-200">
{translate('::' + recipe.title)}
</div>
<p className="text-[10px] leading-4 text-slate-400">
{translate('::' + recipe.description)}
</p>
</div>
<span className="flex shrink-0 items-center gap-1">
{activeEventName && (
<button
className="rounded bg-amber-100 px-2 py-1 text-[9px] font-semibold text-amber-700 hover:bg-amber-200 dark:bg-amber-950 dark:text-amber-300"
title={translate(
'::App.DeveloperKitComponentDesigner.AppendToEventScript',
{ event: activeEventName },
)}
type="button"
onClick={() => appendScriptToEvent(recipeCode)}
>
{translate('::App.Platform.Add')}
</button>
)}
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 dark:bg-sky-950 dark:text-sky-300"
type="button"
onClick={() =>
copyToClipboard(
recipeCode,
translate('::App.DeveloperKitComponentDesigner.ExampleCopied'),
)
}
>
{translate('::App.Platform.Copy')}
</button>
</span>
</div>
<pre className="mt-2 overflow-x-auto bg-slate-950 px-3 py-2 font-mono text-[10px] leading-4 text-sky-200">
{recipeCode}
</pre>
</div>
)
})}
</div>
</div>
)
})}
{!filteredScriptRecipes.length && (
<p className="rounded-md border border-dashed border-slate-300 p-4 text-center text-xs text-slate-500 dark:border-gray-700">
{translate('::App.DeveloperKitComponentDesigner.NoMatchingExample')}
</p>
)}
</div>
</aside>
)}
{catalogSourceEditor && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/70 p-4">
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-slate-200 px-5 py-4 dark:border-gray-700">
<div>
<h2 className="text-sm font-semibold text-slate-800 dark:text-white">
{translate(
catalogSourceEditor.item
? '::App.DeveloperKitComponentDesigner.EditEndpointTitle'
: '::App.DeveloperKitComponentDesigner.NewEndpoint',
)}
</h2>
<p className="mt-1 text-[10px] text-slate-500">
{catalogSourceEditor.item?.origin ||
translate('::App.DeveloperKitComponentDesigner.NewEndpointHint')}
</p>
</div>
<button
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-gray-800 dark:hover:text-white"
title={translate('::App.Platform.Close')}
type="button"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
×
</button>
</div>
<div className="space-y-4 p-5">
<div className="grid gap-4 md:grid-cols-[1fr_110px]">
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Ad
</span>
<Input
unstyle
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-gray-700 dark:bg-gray-950 dark:text-gray-100"
value={catalogSourceEditor.draft.name}
onChange={(event) => updateCatalogSourceDraft({ name: event.target.value })}
/>
</label>
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Metot
</span>
<Select
size="sm"
className="w-full"
maxMenuHeight={200}
menuPortalTarget={window.document.body}
options={DESIGNER_HTTP_METHODS.map((method) => ({
value: method,
label: method,
}))}
value={{
value: catalogSourceEditor.draft.method,
label: catalogSourceEditor.draft.method,
}}
onChange={(option) =>
option &&
updateCatalogSourceDraft({
method: option.value as DesignerHttpMethod,
})
}
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Endpoint URL
</span>
<Input
unstyle
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-gray-700 dark:bg-gray-950 dark:text-gray-100"
placeholder="/api/app/customers"
value={catalogSourceEditor.draft.url}
onChange={(event) => updateCatalogSourceDraft({ url: event.target.value })}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.ResponseRootPath')}{' '}
<span className="font-normal normal-case">
({translate('::App.PublicCommon.Optional')})
</span>
</span>
<Input
unstyle
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-gray-700 dark:bg-gray-950 dark:text-gray-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-gray-700 dark:bg-gray-950">
<Button
size="sm"
variant="plain"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
{translate('::App.Platform.Cancel')}
</Button>
<Button
disabled={
catalogSourceEditor.draft.method !== 'GET' ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url)
}
icon={<FaPlay />}
loading={catalogSourceTestResult?.status === 'loading'}
size="sm"
title={
catalogSourceEditor.draft.method === 'GET'
? undefined
: translate('::App.DeveloperKitComponentDesigner.OnlyGetRunnable')
}
variant="default"
onClick={() => void testCatalogSource()}
>
{translate('::App.Platform.Execute')}
</Button>
<Button
disabled={
!catalogSourceEditor.draft.name.trim() ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url) ||
(catalogSourceEditor.draft.method === 'GET' &&
catalogSourceTestResult?.status !== 'success')
}
icon={<FaRegSave />}
loading={isCatalogSourceSaving}
size="sm"
variant="solid"
onClick={() => void saveCatalogSource()}
>
{translate(
catalogSourceEditor.item
? '::App.Platform.SaveChanges'
: '::App.DeveloperKitComponentDesigner.AddEndpoint',
)}
</Button>
</div>
</div>
</div>
)}
{endpointResultModal && (
<div
aria-label="Endpoint sonucu"
aria-modal="true"
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/75 p-4"
role="dialog"
onMouseDown={(event) => {
if (event.currentTarget === event.target) setEndpointResultModal(null)
}}
>
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
<div className="flex items-start justify-between border-b border-slate-700 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<FaDatabase className="text-sky-400" />
<h2 className="truncate text-sm font-semibold text-white">
{endpointResultModal.source.name}
</h2>
<span className="rounded bg-emerald-950 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
{translate('::App.Platform.Success')}
</span>
</div>
<div className="mt-1 break-all font-mono text-[10px] text-slate-400">
{endpointResultModal.source.method} {endpointResultModal.source.url}
</div>
</div>
<button
aria-label={translate('::App.DeveloperKitComponentDesigner.CloseResultModal')}
className="ml-4 rounded-md px-2 py-1 text-xl text-slate-400 hover:bg-slate-800 hover:text-white"
type="button"
onClick={() => setEndpointResultModal(null)}
>
×
</button>
</div>
<div className="flex items-center gap-3 border-b border-slate-800 bg-slate-950/60 px-5 py-2 text-[10px] text-slate-400">
<span>
Response path:{' '}
<code className="text-sky-300">
{endpointResultModal.source.responsePath || '(root)'}
</code>
</span>
<span className="rounded bg-slate-800 px-2 py-0.5 text-slate-300">
{endpointResultCount}
</span>
</div>
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-5 font-mono text-xs leading-5 text-emerald-300">
{endpointResultText}
</pre>
<div className="flex justify-end border-t border-slate-700 bg-slate-950 px-5 py-3">
<Button size="sm" variant="default" onClick={() => setEndpointResultModal(null)}>
{translate('::App.Platform.Close')}
</Button>
</div>
</div>
</div>
)}
{scriptBuilderTarget && scriptBuilderNode && (
<DesignerScriptBuilderDialog
isOpen
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
eventInfo={getFormEvent(scriptBuilderTarget.eventName)}
eventName={scriptBuilderTarget.eventName}
recordFields={scriptBuilderRecordFields}
refNames={[...documentRefs.keys()]}
sqlRef={scriptBuilderSqlNode?.ref || ''}
value={scriptBuilderNode.events[scriptBuilderTarget.eventName] || ''}
onApply={(next) =>
updateNodeEvent(scriptBuilderTarget.nodeId, scriptBuilderTarget.eventName, next)
}
onClose={() => setScriptBuilderTarget(null)}
/>
)}
</div>
)
}
export default VisualComponentDesigner