import Editor from '@monaco-editor/react' import axios from 'axios' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import PageTitle from '@/components/shared/PageTitle' import { Link, useParams } from 'react-router-dom' import { FaArrowLeft, FaBolt, FaCheckCircle, FaArrowDown, FaArrowUp, FaChevronDown, FaChevronRight, FaCode, FaDesktop, FaDatabase, FaEye, FaEdit, FaLayerGroup, FaMobileAlt, FaMousePointer, FaPlus, FaPlay, FaRedo, FaRegSave, FaSearch, FaSlidersH, FaSync, FaTabletAlt, FaTimes, FaTrash, FaUndo, } from 'react-icons/fa' import { useComponents } from '@/contexts/ComponentContext' import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models' import { ROUTES_ENUM } from '@/routes/route.constant' import apiService from '@/services/api.service' import { developerKitService } from '@/services/developerKit.service' import { Button, Notification, toast } from '@/components/ui' import StyleModal from '@/components/codeLayout/StyleModal' import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas' import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag, } from '@/components/visualDesigner/designerDrag' import { DESIGNER_PRIMARY_EVENTS, DESIGNER_SCRIPT_RECIPES, DESIGNER_SCRIPT_RECIPE_GROUPS, getDesignerCatalog, getDesignerEventSnippet, } from '@/components/visualDesigner/catalog' import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator' import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog' import { collectDesignerRefs, getSqlDataSourceEvent, createDesignerId, createEmptyDesignerDocument, findDesignerNode, isValidDesignerRef, renameDesignerRefInNodes, renameDesignerRefInScript, withDesignerRefs, getDesignerBindingColumns, getDesignerCollectionProperty, getDesignerValueByPath, getSqlDataSourceEndpointId, getSqlDataSourceKeyField, getSqlDataSourceKeyParam, getSqlDataSourceKeySource, getSqlDataSourcePreviewKey, getSqlDataSourceRecord, getSqlRecordProperty, isDesignerDateComponent, isDesignerDateOnlyComponent, isDesignerOptionComponent, isDesignerTabularComponent, isSqlDataSourceNode, resolveDesignerResponse, toDesignerHttpMethod, walkDesignerNodes, appendSqlDataSourceQueryParam, bindSqlDataSourceUrl, hasSqlDataSourceUrlParams, DESIGNER_HTTP_METHODS, SQL_DATA_SOURCE_KEY_SOURCES, SQL_DATA_SOURCE_SLOTS, SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_TOKENS, type DesignerComponentDefinition, type DesignerBinding, type DesignerDataSource, type DesignerDocument, type DesignerHttpMethod, type DesignerNode, type DesignerPropertyInfo, type SqlFormValueProperty, } from '@/components/visualDesigner/types' import { useLocalization } from '@/utils/hooks/useLocalization' type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript' type InspectorTab = 'properties' | 'data' | 'tree' type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string } type EndpointResultModal = { source: DesignerDataSource; result: unknown } type DataField = { path: string; type: string; preview: string } type StaticSelectOption = { label: string; value: unknown } type DataSourceCatalogItem = { attached: boolean key: string origin: string originType: 'current' | 'component' | 'crud' ownerComponentId?: string source: DesignerDataSource } const DATA_BINDABLE_PROPERTY_NAMES = new Set([ 'items', 'data', 'dataSource', 'options', 'value', 'checked', 'children', 'content', 'text', 'label', 'title', 'header', 'headerExtra', 'footer', 'description', 'placeholder', 'src', 'alt', 'disabled', 'loading', ]) /** * Properties worth binding to a SqlDataSource record column, most used first. * A component exposes dozens of props; offering every one of them turns the * panel into noise, so only the ones that actually carry record data are listed. */ const SQL_RECORD_FIELD_PROPERTIES = [ 'value', 'checked', 'children', 'text', 'label', 'title', 'placeholder', 'src', 'url', 'href', 'alt', 'content', 'description', 'header', 'footer', 'defaultValue', 'disabled', ] const getSqlRecordFieldOrder = (propertyName: string) => SQL_RECORD_FIELD_PROPERTIES.indexOf(propertyName) // Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is // covered by the static/endpoint panel by construction. const isOptionDataComponent = isDesignerOptionComponent const getOptionDataProperty = getDesignerCollectionProperty const isTabularDataComponent = isDesignerTabularComponent /** * The inspector's Data tab follows the toolbox category, not the collection * registry: everything filed under `data` is expected to be data driven, even * when it carries no collection property of its own. */ const isDataCategoryComponent = (definition?: DesignerComponentDefinition) => definition?.toolboxGroup === 'data' type TranslateFn = (key: string, params?: Record) => string const getPropertyExampleValues = (translate: TranslateFn): Record => ({ 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).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).forEach(([key, child]) => visit(child, key, depth), ) } return } const valueType = getDataValueType(value) if (valueType === 'object' && depth < 4) { Object.entries(value as Record).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).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') ) } const isRunnableDataSourceUrl = (url: string) => { const normalizedUrl = url.trim().replace(/\/+$/, '') return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app' } const TOOLBOX_GROUP_LABELS: Record = { layout: 'Layouts', html: 'HTML Elements', platform: 'Platform Views', data: 'Data', ui: 'Sozsoft UI', custom: 'My Components', } const TOOLBOX_GROUP_ORDER: DesignerComponentDefinition['toolboxGroup'][] = [ 'layout', 'html', 'platform', 'data', 'ui', 'custom', ] const CANVAS_WIDTHS: Record = { responsive: '100%', desktop: '1180px', tablet: '768px', mobile: '390px', } const clone = (value: T): T => JSON.parse(JSON.stringify(value)) const normalizeTableDimension = (value: unknown, fallback: number) => Math.min(20, Math.max(1, Math.floor(Number(value) || fallback))) const updateNodeTree = ( nodes: DesignerNode[], id: string, updater: (node: DesignerNode) => DesignerNode, ): DesignerNode[] => nodes.map((node) => { if (node.id === id) return updater(node) return { ...node, children: updateNodeTree(node.children, id, updater) } }) const removeNodeTree = (nodes: DesignerNode[], id: string): DesignerNode[] => nodes .filter((node) => node.id !== id) .map((node) => ({ ...node, children: removeNodeTree(node.children, id), })) const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] => nodes.map((node) => ({ ...node, bindings: Object.fromEntries( Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId), ), children: removeDataSourceBindings(node.children, sourceId), })) const insertNodeTree = ( nodes: DesignerNode[], nodeToInsert: DesignerNode, parentId: string | null, ): DesignerNode[] => { if (!parentId) return [...nodes, nodeToInsert] return updateNodeTree(nodes, parentId, (node) => ({ ...node, children: [...node.children, nodeToInsert], })) } const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => { const result: DesignerNode[] = [] nodes.forEach((node) => { result.push({ ...node, children: duplicateInTree(node.children, id) }) if (node.id === id) { const copy = clone(node) const renewIds = (item: DesignerNode) => { item.id = createDesignerId() item.children.forEach(renewIds) } renewIds(copy) result.push(copy) } }) return result } const insertRelativeToNode = ( nodes: DesignerNode[], targetId: string, nodeToInsert: DesignerNode, placement: 'before' | 'after', ): DesignerNode[] => { const targetIndex = nodes.findIndex((node) => node.id === targetId) if (targetIndex >= 0) { const next = [...nodes] next.splice(targetIndex + (placement === 'after' ? 1 : 0), 0, nodeToInsert) return next } return nodes.map((node) => ({ ...node, children: insertRelativeToNode(node.children, targetId, nodeToInsert, placement), })) } /** * Moves a node one step up/down. Swapping with a sibling only works while the * node has one, so a node that sits alone in a container (or at the edge of one) * is lifted out and placed next to its parent instead — otherwise the arrows * would silently do nothing, which is how they used to behave. */ const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => { const ancestors = findDesignerAncestors(nodes, id) if (!ancestors) return nodes const parent = ancestors[ancestors.length - 1] const siblings = parent ? parent.children : nodes const index = siblings.findIndex((node) => node.id === id) if (index < 0) return nodes // Tabs children live in separate tabs, so only components sharing a tab are // neighbours — otherwise the arrows would swap with something not on screen. const group = parent?.type === 'Tabs' ? siblings.filter((node) => (node.slot || '') === (siblings[index].slot || '')) : siblings const groupIndex = group.findIndex((node) => node.id === id) const targetNode = group[groupIndex + direction] if (targetNode) { const target = siblings.findIndex((node) => node.id === targetNode.id) const next = [...siblings] ;[next[index], next[target]] = [next[target], next[index]] // In a Table the visible cell comes from `slot`, not from array order, so the // slots have to stay with the position for the swap to be visible. if (parent?.type === 'Table') { next[index] = { ...next[index], slot: siblings[index].slot } next[target] = { ...next[target], slot: siblings[target].slot } } return parent ? updateNodeTree(nodes, parent.id, (node) => ({ ...node, children: next })) : next } // Already first/last: at the root there is nowhere left to go. if (!parent) return nodes const movingNode = { ...siblings[index], slot: undefined } const withoutNode = updateNodeTree(nodes, parent.id, (node) => ({ ...node, children: node.children.filter((child) => child.id !== id), })) return insertRelativeToNode( withoutNode, parent.id, movingNode, direction === -1 ? 'before' : 'after', ) } /** * Table cells are addressed by `slot`, and children without one fall back to * their positional index. Materialising the effective slots keeps every existing * cell in place while a node is added to or removed from the table. */ const withResolvedTableSlots = (table: DesignerNode): DesignerNode => { const columnCount = normalizeTableDimension(table.props.columnCount, 3) return { ...table, children: table.children.map((child, childIndex) => ({ ...child, slot: child.slot || `table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`, })), } } const resolveAllTableSlots = (nodes: DesignerNode[]): DesignerNode[] => nodes.map((node) => { const resolved = { ...node, children: resolveAllTableSlots(node.children) } return resolved.type === 'Table' ? withResolvedTableSlots(resolved) : resolved }) /** * Moves an existing node into a container (or to the root when `parentId` is * null). Used by drag & drop so a component can leave a Table cell and be put * back into one. */ const moveNodeIntoContainer = ( nodes: DesignerNode[], nodeId: string, parentId: string | null, slot?: string, ): DesignerNode[] => { const source = findDesignerNode(nodes, nodeId) if (!source || parentId === nodeId) return nodes // Dropping a node inside its own subtree would detach that subtree. if (parentId && findDesignerNode(source.children, parentId)) return nodes // Pin every table cell before the tree changes, so removing the node does not // shift the siblings that still rely on their positional fallback. const pinned = resolveAllTableSlots(nodes) const movingNode = { ...(findDesignerNode(pinned, nodeId) || source), slot } const remaining = removeNodeTree(pinned, nodeId) if (!parentId) return [...remaining, movingNode] return updateNodeTree(remaining, parentId, (parent) => ({ ...parent, children: [...parent.children, movingNode], })) } const reorderNodeTree = ( nodes: DesignerNode[], sourceId: string, targetId: string, placement: 'before' | 'after', ): DesignerNode[] => { const source = findDesignerNode(nodes, sourceId) const target = findDesignerNode(nodes, targetId) if (!source || !target || sourceId === targetId || findDesignerNode(source.children, targetId)) { return nodes } const movingNode = { ...source, slot: target.slot } return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement) } const definitionToNode = (definition: DesignerComponentDefinition): DesignerNode => { const props: Record = {} const events: Record = {} definition.properties.forEach((property) => { if (property.category === 'events' || property.type === 'function') { events[property.name] = typeof property.value === 'string' ? property.value : '' } else { props[property.name] = clone(property.value) } }) return { id: createDesignerId(), type: definition.name, kind: definition.kind, props, events, bindings: {}, children: [], } } const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => { const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] => nodes.map((node) => { const props = { ...(node.props || {}) } const events = { ...(node.events || {}) } const bindings = { ...(node.bindings || {}) } if (node.type === 'Select') { const legacyPropAliases: Record = { clearable: 'isClearable', disabled: 'isDisabled', multiple: 'isMulti', searchable: 'isSearchable', } Object.entries(legacyPropAliases).forEach(([legacyName, runtimeName]) => { if (props[runtimeName] === undefined && props[legacyName] !== undefined) { props[runtimeName] = props[legacyName] } delete props[legacyName] }) if (events.onInputChange === undefined && events.onSearch !== undefined) { events.onInputChange = events.onSearch } delete events.onSearch } if (node.type === 'Menu') { if (!Array.isArray(props.items) && Array.isArray(props.options)) { props.items = props.options } if (!bindings.items && bindings.options) bindings.items = bindings.options delete props.options delete props.variant delete bindings.options } if (node.type === 'Table') { const legacyRows = Array.isArray(props.items) ? props.items : Array.isArray(props.data) ? props.data : [] const firstLegacyRow = legacyRows.find( (item) => item && typeof item === 'object' && !Array.isArray(item), ) as Record | undefined const legacyColumns = Array.isArray(props.dataColumns) ? props.dataColumns : Array.isArray(props.columns) ? props.columns : firstLegacyRow ? Object.keys(firstLegacyRow) : [] props.rowCount = normalizeTableDimension(props.rowCount, legacyRows.length || 2) props.columnCount = normalizeTableDimension(props.columnCount, legacyColumns.length || 3) delete props.data delete props.columns delete props.items delete props.dataColumns delete bindings.data delete bindings.items } let children = normalizeNodes(node.children || []) if (node.type === 'Table') { const columnCount = normalizeTableDimension(props.columnCount, 3) children = children.map((child, index) => ({ ...child, slot: child.slot || `table:${Math.floor(index / columnCount)}:${index % columnCount}`, })) } return { ...node, props, events, bindings, children, } }) return { ...document, // Documents saved before refs existed get theirs here, so every component is // addressable from a script the moment it is opened. nodes: withDesignerRefs(normalizeNodes(document.nodes)), canvas: { width: document.canvas?.width || 'responsive' }, // Documents saved before non-GET sources existed have no usable method value. dataSources: (Array.isArray(document.dataSources) ? document.dataSources : []).map( (source) => ({ ...source, method: toDesignerHttpMethod(source.method) }), ), } } const parseDesignerDocument = (rawProps: string | undefined): DesignerDocument | null => { if (!rawProps) return null try { const parsed = JSON.parse(rawProps) const document = parsed?.visualDesigner if (document?.version === 1 && Array.isArray(document.nodes)) { return normalizeDesignerDocument(document) } } catch { return null } return null } const parseDesignerDocumentFromCode = (code: string | undefined): DesignerDocument | null => { const encodedDocument = code?.match(/\/\*__SOZSOFT_VISUAL_DESIGNER__(.*?)__\*\//)?.[1] if (!encodedDocument) return null try { const document = JSON.parse(decodeURIComponent(encodedDocument)) as DesignerDocument return document?.version === 1 && Array.isArray(document.nodes) ? normalizeDesignerDocument(document) : null } catch { return null } } const parseComponentDefaultProps = (code: string): Record => { const defaults: Record = {} 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 = {}, ): Record => { const props: Record = {} 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, ): 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 = {}, 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( /]*?)(?:\/>|>)/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]*?]*)>/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(/
]*>\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(/]*?)\/>/g)) { const props = parseGeneratedJsxProps(match[1], componentDefaultProps) const view = String(props.view || '') const platformTypes: Record = { 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(/]*?)\/>/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 `.`. */ 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>) : [] const inputClass = 'w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100' const replaceItem = (index: number, next: Record) => 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 (
{items.map((item, index) => (
{translate('::' + schema.itemLabel)} {index + 1}
{item.src ? ( ) : null} {schema.fields.map((field) => ( ))}
))}
) } const PropertyEditor = ({ componentType, name, type, value, options, onChange, }: { componentType?: string name: string type: string value: unknown options?: string[] onChange: (value: unknown) => void }) => { const { translate } = useLocalization() const [styleModalOpen, setStyleModalOpen] = useState(false) const inputClass = 'w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs text-slate-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100' if (type === 'boolean') { return ( ) } if (type === 'select' && options) { const currentValue = String(value ?? '') return ( ) } const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`] if (objectListSchema) { return } if (type === 'object' || type === 'array') { return (