import Editor from '@monaco-editor/react' import axios from 'axios' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { FaArrowLeft, FaBolt, FaCheckCircle, FaChevronDown, FaChevronRight, FaCode, FaDesktop, FaDatabase, FaEye, FaEdit, FaLayerGroup, FaMobileAlt, FaMousePointer, FaPlus, FaPlay, FaRedo, FaRegCopy, FaRegSave, FaSearch, FaSlidersH, FaSync, FaTabletAlt, 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 TailwindModal from '@/components/codeLayout/TailwindModal' import { useStoreState } from '@/store/store' import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas' import { DESIGNER_PRIMARY_EVENTS, getDesignerCatalog, getDesignerEventSnippet, } from '@/components/visualDesigner/catalog' import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator' import { createDesignerId, createEmptyDesignerDocument, findDesignerNode, getDesignerValueByPath, resolveDesignerResponse, walkDesignerNodes, type DesignerComponentDefinition, type DesignerBinding, type DesignerDataSource, type DesignerDocument, type DesignerNode, type DesignerPropertyInfo, } from '@/components/visualDesigner/types' 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', ]) const OPTION_DATA_COMPONENT_NAMES = new Set(['Select', 'AutoComplete', 'Menu']) const TABULAR_DATA_COMPONENT_NAMES = new Set(['Grid', 'Table']) const isOptionDataComponent = (type?: string) => Boolean(type && OPTION_DATA_COMPONENT_NAMES.has(type)) const getOptionDataProperty = (type?: string) => (type === 'Menu' ? 'items' : 'options') const isTabularDataComponent = (type?: string) => Boolean(type && TABULAR_DATA_COMPONENT_NAMES.has(type)) const PROPERTY_EXAMPLE_VALUES: Record = { children: 'Örnek içerik', className: 'p-4 rounded-lg shadow-sm', id: 'component-id', label: 'Örnek başlık', name: 'exampleName', placeholder: 'Bir değer girin…', src: '/img/example.png', title: 'Örnek başlık', url: '/api/app/example', } const getPropertyExampleValue = ( property: DesignerPropertyInfo, currentValue: unknown, ): 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] if (PROPERTY_EXAMPLE_VALUES[property.name] !== undefined) { return PROPERTY_EXAMPLE_VALUES[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: 'Örnek', value: 'example' }] if (property.type === 'object') { return property.name === 'style' ? { padding: 16, borderRadius: 8 } : { key: 'value' } } return 'Örnek değer' } const getPropertySnippet = (property: DesignerPropertyInfo, currentValue: unknown) => { const exampleValue = getPropertyExampleValue(property, currentValue) 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) => { if (!axios.isAxiosError(error)) { return error instanceof Error ? error.message : 'Bileşen kaydedilemedi.' } 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 || 'Bileşen kaydedilemedi.' ) } 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 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 moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => { const index = nodes.findIndex((node) => node.id === id) if (index >= 0) { const target = index + direction if (target < 0 || target >= nodes.length) return nodes const next = [...nodes] ;[next[index], next[target]] = [next[target], next[index]] return next } return nodes.map((node) => ({ ...node, children: moveInTree(node.children, id, direction) })) } 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') { if ( Array.isArray(props.data) && (!Array.isArray(props.items) || props.items.length === 0) ) { props.items = props.data } if (Array.isArray(props.columns) && !Array.isArray(props.dataColumns)) { props.dataColumns = props.columns .map((column) => { if (typeof column === 'string') return column if (!column || typeof column !== 'object' || Array.isArray(column)) return '' const record = column as Record return String(record.dataIndex ?? record.key ?? '') }) .filter(Boolean) } delete props.data delete props.columns if (!bindings.items && bindings.data) bindings.items = bindings.data delete bindings.data } return { ...node, props, events, bindings, children: normalizeNodes(node.children || []), } }) return { ...document, nodes: normalizeNodes(document.nodes), dataSources: Array.isArray(document.dataSources) ? document.dataSources : [], } } 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 { addNode( match.index, 'Table', 'ui', { ...parseGeneratedJsxProps(match[4], componentDefaultProps), items: JSON.parse(match[2]), dataColumns: JSON.parse(match[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 } const PropertyEditor = ({ name, type, value, options, onChange, }: { name: string type: string value: unknown options?: string[] onChange: (value: unknown) => void }) => { const [tailwindModalOpen, setTailwindModalOpen] = 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) { return ( ) } if (type === 'object' || type === 'array') { return (