import React from 'react' import * as UiKit from '@/components/ui' import PlatformViewHost, { type PlatformViewName, } from '@/components/componentEditor/PlatformViewHost' import apiService from '@/services/api.service' import { useLocalization } from '@/utils/hooks/useLocalization' import { formatLocaleValue } from '@/utils/localeFormat' import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa' import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag' import { buildDesignerFilterParams, buildSqlDefaultRecord, fromDesignerDate, getDesignerNodeFilters, getDesignerBindingColumns, getDesignerCollectionProperty, getDesignerTabSlot, getDesignerValueByPath, getSqlDataSourceEndpointId, getSqlDataSourceColumnCount, getDesignerColumnCaptions, getUsableDesignerColumnLookups, buildDesignerLookupIndex, resolveDesignerLookupText, getSqlDataSourceKeyField, getSqlFormValueProperty, isDesignerDateComponent, isDesignerDateProperty, isDesignerOptionComponent, isSqlDataSourceNode, LEGACY_SQL_DATA_SOURCE_TYPE, normalizeDesignerKeyList, readSqlDataSourceField, resolveDesignerDropdownTitle, resolveDesignerPreviewFilterValue, resolveDesignerTabValue, resolveSqlDataSourceRows, setDesignerRecordField, SQL_DATA_SOURCE_TYPE, SQL_DEFAULT_VALUE_PROP, toDesignerDate, type DesignerBinding, type DesignerDataSourceFilter, type DesignerNode, type SqlDataSourceMode, } from './types' /** * Editing scope opened by a SqlDataSource: a descendant whose `value`/`checked` * is bound to `sourceId` writes back into the record instead of into its own * static prop, which is what makes the container behave like an ASP.NET FormView. */ export interface DesignerFormScope { sourceId: string onFieldChange: (path: string, value: unknown) => void } export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer' interface DesignerDragPayload { source?: 'library' | 'canvas' name?: string nodeId?: string } /** Drag payload of the designer; anything else dropped on the canvas is ignored. */ const readDesignerDragPayload = ( event: React.DragEvent, ): DesignerDragPayload | null => { const raw = event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain') if (!raw) return null try { const payload = JSON.parse(raw) as DesignerDragPayload return payload?.source === 'library' || payload?.source === 'canvas' ? payload : null } catch { return null } } /** `dropEffect` must match the source's `effectAllowed` or the drop never fires. */ const acceptDesignerDrag = (event: React.DragEvent) => { event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move' } /** * "Sürüklenen düğümün içindeyiz" bilgisi ağaçtan aşağı akar. Bir bileşen kendi * alt ağacının içine taşınamaz; onun dışındaki her hedef — kardeşler, üst * konteynerler, kök — geçerlidir ve toolbox'tan bırakmayla birebir aynı geri * bildirimi alır. */ const DesignerDraggedSubtreeContext = React.createContext(false) /** * Açık bir bırakma bölgesi: üzerine gelindiğinde kendini vurgular. Boş konteyner * yer tutucuları ve Tabs sekme gövdeleri bunu kullanır. */ const useDesignerDropZone = ( interactive: boolean, onDropPayload: (payload: DesignerDragPayload) => void, ) => { const [over, setOver] = React.useState(false) const drag = useDesignerDrag() React.useEffect(() => { if (!drag) setOver(false) }, [drag]) return { over: over && Boolean(drag), dragging: Boolean(drag) && interactive, props: { onDragOver: (event: React.DragEvent) => { if (!interactive) return acceptDesignerDrag(event) setOver(true) }, onDragLeave: () => setOver(false), onDrop: (event: React.DragEvent) => { if (!interactive) return event.preventDefault() event.stopPropagation() setOver(false) const payload = readDesignerDragPayload(event) if (payload) onDropPayload(payload) endDesignerDrag() }, }, } } const getTableDimension = (value: unknown, fallback: number) => Math.min(20, Math.max(1, Math.floor(Number(value) || fallback))) const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => { if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate)) // Only plain objects are walked: `Object.entries` on a Date yields nothing and // would silently turn it into `{}`. if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { return Object.fromEntries( Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]), ) } return value } interface VisualCanvasProps { nodes: DesignerNode[] selectedId: string | null interactive?: boolean onSelect?: (id: string) => void onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void /** Adds a toolbox component next to an existing node instead of inside it. */ onDropComponentBeside?: ( definitionName: string, targetId: string, placement: 'before' | 'after', ) => void /** Moves an existing node into a container/table cell, or to the root. */ onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void onMove?: (id: string, direction: -1 | 1) => void onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void onDuplicate?: (id: string) => void onDelete?: (id: string) => void onNodePropChange?: (id: string, propertyName: string, value: unknown) => void renderCustomComponent?: (name: string, props?: Record) => React.ReactNode dataValues?: Record } class PreviewBoundary extends React.Component< { name: string; resetKey: string; children: React.ReactNode }, { failed: boolean } > { state = { failed: false } static getDerivedStateFromError() { return { failed: true } } componentDidUpdate(previousProps: Readonly<{ resetKey: string }>) { if (this.state.failed && previousProps.resetKey !== this.props.resetKey) { this.setState({ failed: false }) } } render() { if (this.state.failed) { return (
{this.props.name} önizlemesi için ek veri veya alt bileşen gerekiyor.
) } return this.props.children } } const resolveUiComponent = (name: string): React.ElementType | null => { const parts = name.split('.') let component: unknown = (UiKit as Record)[parts[0]] for (const part of parts.slice(1)) { if ((typeof component !== 'object' && typeof component !== 'function') || !component) { return null } component = (component as Record)[part] } return component ? (component as React.ElementType) : null } /** Prop that turns a component off; react-select spells it differently. */ const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled') export interface DesignerRefOverride { props?: Record hidden?: boolean } /** * Design time counterpart of the `refs` object the code generator emits. It lets * an event script drive the other components on the canvas — value, visibility, * enabled state, any prop — so the behaviour can be tried out before the * component is ever saved and compiled. */ interface DesignerRefStore { state: Record nodes: Record patch: (ref: string, override: DesignerRefOverride) => void } const DesignerRefContext = React.createContext(null) const buildDesignerRefs = (store: DesignerRefStore | null) => { const refs: Record = {} if (!store) return refs Object.entries(store.nodes).forEach(([ref, node]) => { const overrideProps = () => store.state[ref]?.props || {} const disabledProperty = getDisabledProperty(node.type) const valueProperty = 'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children' const designTimeOnly = (action: string) => () => console.info(`refs.${ref}.${action}() yalnızca çalışma zamanında endpoint çağırır.`) refs[ref] = { name: ref, type: node.type, getValue: () => overrideProps()[valueProperty] ?? node.props[valueProperty], setValue: (value: unknown) => store.patch(ref, { props: { [valueProperty]: value } }), getProps: () => overrideProps(), setProps: (patch: Record) => store.patch(ref, { props: patch || {} }), setProp: (property: string, value: unknown) => store.patch(ref, { props: { [property]: value } }), isVisible: () => !store.state[ref]?.hidden, setVisible: (visible: unknown) => store.patch(ref, { hidden: visible === false }), show: () => store.patch(ref, { hidden: false }), hide: () => store.patch(ref, { hidden: true }), isEnabled: () => (overrideProps()[disabledProperty] ?? Boolean(node.props[disabledProperty])) !== true, setEnabled: (enabled: unknown) => store.patch(ref, { props: { [disabledProperty]: enabled === false } }), setReadOnly: (readOnly: unknown) => store.patch(ref, { props: { readOnly: readOnly !== false } }), setText: (text: unknown) => store.patch(ref, { props: { children: text } }), reset: () => store.patch(ref, { props: null as unknown as Record }), ...(isSqlDataSourceNode(node.type) ? { getRecord: () => ({}), setRecord: designTimeOnly('setRecord'), getField: () => undefined, setField: designTimeOnly('setField'), setFields: designTimeOnly('setFields'), clearFields: designTimeOnly('clearFields'), getOriginal: () => ({}), getChanges: () => ({}), hasChanges: () => false, getKey: () => undefined, getKeyField: () => getSqlDataSourceKeyField(node), getRows: () => [], getRowCount: () => 0, getIndex: () => 0, getMode: () => 'edit', isNew: () => false, isBusy: () => false, getError: () => '', setError: designTimeOnly('setError'), goToRow: designTimeOnly('goToRow'), nextRow: designTimeOnly('nextRow'), prevRow: designTimeOnly('prevRow'), firstRow: designTimeOnly('firstRow'), lastRow: designTimeOnly('lastRow'), reload: designTimeOnly('reload'), newRecord: designTimeOnly('newRecord'), save: designTimeOnly('save'), remove: designTimeOnly('remove'), } : {}), // Select/AutoComplete/Menu: seçilen kaydın görünmeyen sütunları. Tasarım // ekranında yalnızca statik seçenekler ve önizleme değeri elde olduğu için // erişimciler bunlarla çalışır; çalışma zamanında aynı isimler endpoint // sonucundaki tüm sütunları okur. ...(isDesignerOptionComponent(node.type) ? (() => { const currentOption = () => { const value = overrideProps()[valueProperty] ?? node.props[valueProperty] if (value && typeof value === 'object') return value as Record const options = node.props[getDesignerCollectionProperty(node.type)] if (!Array.isArray(options)) return null return ( (options.find( (option) => option && typeof option === 'object' && (option as Record).value === value, ) as Record) ?? null ) } return { getOptions: () => { const options = node.props[getDesignerCollectionProperty(node.type)] return Array.isArray(options) ? options : [] }, getSelectedOption: () => currentOption(), getColumn: (path: string) => getDesignerValueByPath(currentOption(), String(path || '')), getLabel: () => String(currentOption()?.label ?? ''), getColumnNames: () => getDesignerBindingColumns( node.bindings?.[getDesignerCollectionProperty(node.type)], ), } })() : {}), } }) return refs } const notifyFromScript = (message: unknown, type: unknown = 'info') => UiKit.toast.push( {typeof message === 'string' ? message : JSON.stringify(message)} , { placement: 'bottom-end' }, ) /** * Design time `api`. Reads go through, exactly like the endpoint test button in * the data panel does, so a script can be tried out on the canvas. Writes are * refused: the designer must never insert, update or delete real records while * a component is only being laid out. */ const blockedDesignTimeCall = (method: string) => (url: string) => { console.warn(`Tasarım modunda ${method} çağrısı yapılmaz: ${url}`) return Promise.resolve(null) } const designTimeApi = { get: (url: string, params?: Record) => apiService.fetchData({ url, method: 'GET', params }).then((response) => response.data), post: blockedDesignTimeCall('POST'), put: blockedDesignTimeCall('PUT'), patch: blockedDesignTimeCall('PATCH'), delete: blockedDesignTimeCall('DELETE'), remove: blockedDesignTimeCall('DELETE'), request: (config: { url: string; method?: string }) => String(config?.method || 'GET').toUpperCase() === 'GET' ? apiService.fetchData(config).then((response) => response.data) : blockedDesignTimeCall(String(config?.method))(config?.url), errorMessage: (error: { response?: { data?: { error?: { message?: string }; message?: string } } message?: string }) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || 'App.Platform.OperationCouldNotBeCompleted', } const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as FunctionConstructor const executeEvent = ( script: string, event: unknown, node: DesignerNode, refs: Record, ) => { if (!script.trim()) return try { // `await` is only legal inside an async function, and scripts that call the // API are expected to use it. const construct = /\bawait\b/.test(script) ? AsyncFunction : Function const run = construct('event', 'component', 'props', 'refs', 'api', 'notify', script) const result = run(event, node, node.props, refs, designTimeApi, notifyFromScript) if (result instanceof Promise) { result.catch((error) => console.error(`Designer event error (${node.type}):`, error)) } } catch (error) { console.error(`Designer event error (${node.type}):`, error) } } const getBindingValue = ( binding: DesignerBinding, dataValues: Record, currentItem?: unknown, ) => { const path = binding.path.trim() if (currentItem !== undefined && (path === '$item' || path.startsWith('$item.'))) { return getDesignerValueByPath(currentItem, path === '$item' ? '' : path.slice(6)) } return getDesignerValueByPath(dataValues[binding.sourceId], path) } const toSelectOptions = ( value: unknown, labelPath = '', valuePath = '', ): Array> => { if (!Array.isArray(value)) return [] return value.map((item, index) => { if (item === null || typeof item !== 'object' || Array.isArray(item)) { return { label: String(item ?? ''), value: item ?? index } } const record = item as Record const primitiveKeys = Object.keys(record).filter( (key) => record[key] === null || ['string', 'number', 'boolean'].includes(typeof record[key]), ) const labelKey = ['label', 'children', 'name', 'title', 'text', 'description'].find( (key) => record[key] !== undefined, ) const valueKey = ['value', 'eventKey', 'id', 'code', 'key'].find( (key) => record[key] !== undefined, ) const labelValue = labelPath ? getDesignerValueByPath(record, labelPath) : labelKey ? record[labelKey] : record[primitiveKeys[0]] const optionValue = valuePath ? getDesignerValueByPath(record, valuePath) : valueKey ? record[valueKey] : record[primitiveKeys[1] || primitiveKeys[0]] return { ...record, label: String(labelValue ?? `#${index + 1}`), value: optionValue ?? index, } }) } /** * Select renders its menu inline, so any ancestor with `overflow: hidden/auto` * (the canvas page, a Grid/Table scroll wrapper) clips it — the menu of a Select * in the last row would be cut off. Rendering it in a body portal keeps it * visible; explicit props still win. */ const getSelectMenuProps = (props: Record) => { const menuProps: Record = {} if (props.menuPosition === undefined) menuProps.menuPosition = 'fixed' if (props.menuPortalTarget === undefined && typeof window !== 'undefined') { menuProps.menuPortalTarget = window.document.body } if (props.styles === undefined) { menuProps.styles = { menuPortal: (base: Record) => ({ ...base, zIndex: 60 }), } } return menuProps } /** Resolved tab list of a Tabs node, from static items or an endpoint binding. */ const getTabOptions = (props: Record) => Array.isArray(props.items) ? (props.items as Array>) : [] /** Prop of a node that a SqlDataSource scope is allowed to write back to. */ const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) => getSqlFormValueProperty(node, formScope?.sourceId) const getPreviewProps = ( node: DesignerNode, dataValues: Record, currentItem?: unknown, onNodePropChange?: (id: string, propertyName: string, value: unknown) => void, translate: (key: string) => string = (key) => key, formScope?: DesignerFormScope, refs: Record = {}, refOverrides: Record = {}, ) => { const props: Record = {} Object.entries(node.props).forEach(([key, value]) => { // Design time metadata, not a component prop: it seeds a new record instead // of being rendered. if (key === SQL_DEFAULT_VALUE_PROP) return if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return if (key.startsWith('on') && typeof value === 'string') return props[key] = resolveStaticLanguageKeys(value, translate) }) Object.entries(node.events).forEach(([name, script]) => { if (!script.trim()) return props[name] = (...args: unknown[]) => { const event = node.type === 'Checkbox' && name === 'onChange' ? { checked: Boolean(args[0]), originalEvent: args[1], target: args[1] && typeof args[1] === 'object' && 'target' in args[1] ? (args[1] as { target: unknown }).target : undefined, } : args[0] executeEvent(script, event, node, refs) } }) Object.entries(node.bindings || {}).forEach(([propertyName, binding]) => { if (propertyName !== 'children' && binding.sourceId) { props[propertyName] = getBindingValue(binding, dataValues, currentItem) } }) // Stored as ISO strings; pickers only accept real Date instances. if (isDesignerDateComponent(node.type)) { Object.keys(props).forEach((propertyName) => { if (isDesignerDateProperty(node.type, propertyName)) { props[propertyName] = toDesignerDate(props[propertyName], node.type) } }) } if (isDesignerOptionComponent(node.type)) { const collectionProperty = getDesignerCollectionProperty(node.type) if (node.type === 'Select') { const legacyAliases: Record = { clearable: 'isClearable', disabled: 'isDisabled', multiple: 'isMulti', searchable: 'isSearchable', } Object.entries(legacyAliases).forEach(([legacyName, runtimeName]) => { if (props[runtimeName] === undefined && props[legacyName] !== undefined) { props[runtimeName] = props[legacyName] } delete props[legacyName] }) Object.assign(props, getSelectMenuProps(props)) } const optionsBinding = node.bindings?.[collectionProperty] props[collectionProperty] = toSelectOptions( props[collectionProperty], optionsBinding?.labelPath, optionsBinding?.valuePath, ) if (node.type === 'Pagination') { const pages = props.items as Array> delete props.items if (pages.length) props.total = pages.length } if (node.type === 'Select' && 'value' in props) { const options = props.options as Array> const selectedValue = props.value props.value = props.isMulti ? options.filter( (option) => Array.isArray(selectedValue) && selectedValue.includes(option.value), ) : options.find((option) => option.value === selectedValue) || null } } const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => { const storedHandler = typeof props[eventName] === 'function' ? (props[eventName] as (...args: unknown[]) => void) : undefined props[eventName] = (...args: unknown[]) => { update(...args) storedHandler?.(...args) } } const updateProp = (propertyName: string, value: unknown) => onNodePropChange?.(node.id, propertyName, value) const eventValue = (value: unknown) => value && typeof value === 'object' && 'target' in value ? (value as { target?: { value?: unknown } }).target?.value : value const selectedOptionValue = (selected: unknown) => Array.isArray(selected) ? selected.map((option) => option && typeof option === 'object' && 'value' in option ? (option as { value: unknown }).value : option, ) : selected && typeof selected === 'object' && 'value' in selected ? (selected as { value: unknown }).value : null const checkedValue = (value: unknown, originalEvent: unknown) => originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent ? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked) : value && typeof value === 'object' && 'target' in value ? Boolean((value as { target?: { checked?: unknown } }).target?.checked) : Boolean(value) // Inside a SqlDataSource the edited value belongs to the record, not to the // node's static prop — otherwise typing into a bound Input would be discarded // on the next render because the binding always wins. const formScopeProperty = getFormScopeProperty(node, formScope) if (formScope && formScopeProperty) { const bindingPath = node.bindings[formScopeProperty].path // An empty record (New mode) yields `undefined`, which React reads as // "uncontrolled" and leaves the field stuck on its previous DOM value. if (props[formScopeProperty] === undefined && !isDesignerDateComponent(node.type)) { props[formScopeProperty] = formScopeProperty === 'checked' ? false : '' } const writeField = (value: unknown) => formScope.onFieldChange(bindingPath, value) if (node.type === 'Select') { chainHandler('onChange', (selected) => writeField(selectedOptionValue(selected))) } else if (formScopeProperty === 'checked') { chainHandler('onChange', (value, originalEvent) => writeField(checkedValue(value, originalEvent)), ) } else if (isDesignerDateComponent(node.type)) { chainHandler('onChange', (value) => writeField(fromDesignerDate(value, node.type))) } else if (node.type === 'AutoComplete') { chainHandler('onInputChange', (value) => writeField(value ?? '')) } else { chainHandler('onChange', (value) => writeField(eventValue(value))) } } else if (node.type === 'Select') { chainHandler('onChange', (selected) => updateProp('value', selectedOptionValue(selected))) } else if (node.type === 'AutoComplete') { chainHandler('onInputChange', (value) => updateProp('value', value ?? '')) } else if (node.type === 'Menu') { chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')])) } else if (node.type === 'Dropdown') { chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? ''))) } else if (node.type === 'Tabs') { chainHandler('onChange', (value) => updateProp('value', String(value ?? ''))) } else if (node.type === 'Radio.Group') { chainHandler('onChange', (value) => updateProp('value', value)) } else if (node.type === 'Pagination') { chainHandler('onChange', (value) => updateProp('currentPage', Number(value) || 1)) } else if (node.type === 'Steps') { chainHandler('onChange', (value) => updateProp('current', Number(value) || 0)) } else if (node.type === 'ImageViewer') { chainHandler('onIndexChange', (value) => updateProp('activeIndex', Number(value) || 0)) } else if (node.type === 'Upload') { chainHandler('onChange', (_files, fileList) => updateProp('fileList', Array.isArray(fileList) ? fileList : []), ) } else if (node.type === 'MenuItem') { chainHandler('onSelect', () => updateProp('isActive', true)) } else if (isDesignerDateComponent(node.type)) { // A Date is not JSON serialisable, so the picked value is kept as an ISO // string — otherwise it is lost on the next render/save. chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value, node.type))) } else if ('checked' in props) { chainHandler('onChange', (value, originalEvent) => updateProp('checked', checkedValue(value, originalEvent)), ) } else if ('value' in props) { chainHandler('onChange', (value) => updateProp('value', eventValue(value))) } // Applied last: a script that disabled or refilled this component has to win // over both the toolbox defaults and the values set in the property panel. Object.assign(props, refOverrides) props.key = node.id return props } const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => { const { translate } = useLocalization() return (
S
{node.type.replace(/View$/, '')}
{String( node.props.listFormCode || translate('::App.VisualDesignerCanvas.SelectListFormCode'), )}
{translate('::App.VisualDesignerCanvas.PlatformView')}
) } const PLATFORM_VIEW_NAMES: Record = { ListView: 'List', DataGridView: 'Grid', TreeView: 'Tree', GanttView: 'GanttView', TodoBoard: 'TodoBoard', CardView: 'CardView', SchedulerView: 'SchedulerView', PivotView: 'Pivot', ChartView: 'Chart', } const GridColumnHeaders = ({ columns }: { columns: string[] }) => { const { translate } = useLocalization() return (
{columns.length ? (
{columns.map((column) => (
{column}
))}
) : (
{translate('::App.VisualDesignerCanvas.SelectColumnsFromData')}
)}
) } // Dates and decimals follow the selected language, like the DevExtreme grids do. const getGridCellText = (value: unknown) => typeof value === 'object' && value !== null && !(value instanceof Date) ? JSON.stringify(value) : formatLocaleValue(value) || '—' const GridDataTablePreview = ({ borderlessRow = false, captions, compact = false, hoverable = true, items, lookupIndexes, overflow = true, selectedColumns, }: { borderlessRow?: boolean /** Header override per column; a `::` value is a localization key. */ captions?: Record compact?: boolean hoverable?: boolean items: unknown[] /** Value → text map per column, for the columns configured as a lookup. */ lookupIndexes?: Record> overflow?: boolean selectedColumns?: string[] }) => { const { translate } = useLocalization() const firstObject = items.find( (item): item is Record => Boolean(item) && typeof item === 'object' && !Array.isArray(item), ) const columns = selectedColumns ?? (firstObject ? Object.keys(firstObject) : ['value']) if (!columns.length) { return (
{translate('::App.VisualDesignerCanvas.SelectAtLeastOneColumn')}
) } return (
{columns.map((column) => ( ))} {items.slice(0, 100).map((item, rowIndex) => ( {columns.map((column) => { const value = column === 'value' ? item : getDesignerValueByPath(item, column) // A column with a lookup paints the matched option text; a value // with no matching row keeps showing the raw id rather than an // empty cell, so a broken mapping stays visible. const lookupIndex = lookupIndexes?.[column] const lookupText = lookupIndex ? resolveDesignerLookupText(lookupIndex, value) : undefined const text = getGridCellText(lookupText === undefined ? value : lookupText) return ( ) })} ))}
{captions?.[column] ? String(resolveStaticLanguageKeys(captions[column], translate)) : column === 'value' ? 'Value' : column}
{text}
) } /** * A new record starts at the top of the form: the first enabled editor takes the * cursor once React has painted the emptied fields. */ const focusFirstFormField = (host: React.RefObject) => { window.requestAnimationFrame(() => { const field = host.current?.querySelector( "input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])", ) if (!field) return field.focus() if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) field.select() }) } const SqlDataSourceView = ({ node, dataValues, interactive, renderChildren, }: { node: DesignerNode dataValues: Record interactive: boolean renderChildren: ( childDataValues: Record, formScope: DesignerFormScope, ) => React.ReactNode }) => { const { translate } = useLocalization() const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') const keyField = getSqlDataSourceKeyField(node) const collectionPath = String(node.props.collectionPath ?? '') const columnCount = getSqlDataSourceColumnCount(node) const rows = React.useMemo( () => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []), [collectionPath, dataValues, selectId], ) // Container element, so New can hand the cursor to the first field of the form. const hostRef = React.useRef(null) const [rowIndex, setRowIndex] = React.useState(0) const [mode, setMode] = React.useState('edit') // Holds the whole record while editing, so New mode can show an empty form // instead of falling back to the loaded row. const [draft, setDraft] = React.useState | null>(null) const activeRow = React.useMemo(() => rows[rowIndex] ?? {}, [rowIndex, rows]) // A fresh Select result invalidates the local edits, otherwise the canvas would // keep showing values that no longer exist in the response. const rowsFingerprint = React.useMemo(() => JSON.stringify(rows), [rows]) React.useEffect(() => { setRowIndex(0) setDraft(null) setMode('edit') }, [rowsFingerprint]) const record = draft ?? activeRow const formScope = React.useMemo( () => ({ sourceId: node.id, onFieldChange: (path, value) => setDraft((current) => setDesignerRecordField(current ?? activeRow, path, value)), }), [activeRow, node.id], ) const childDataValues = React.useMemo( () => ({ ...dataValues, [node.id]: record }), [dataValues, node.id, record], ) const goToRow = (index: number) => { setRowIndex(index) setDraft(null) setMode('edit') } const keyValue = readSqlDataSourceField(record, keyField) const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== '' const canInsert = Boolean(getSqlDataSourceEndpointId(node, 'insertEndpoint')) const canUpdate = Boolean(getSqlDataSourceEndpointId(node, 'updateEndpoint')) const canDelete = Boolean(getSqlDataSourceEndpointId(node, 'deleteEndpoint')) // Save follows the explicit mode, exactly like the generated runtime does. const canSave = mode === 'new' ? canInsert : canUpdate const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall') // A filled Title turns the container into a Card; the header extra carries the // key of the record on screen, which is the only identity the user can check. const cardTitle = String( resolveStaticLanguageKeys(node.props.title ?? '', translate) ?? '', ).trim() const headerExtra = hasKey ? `${keyField}: ${String(keyValue)}` : translate('::App.VisualDesignerCanvas.NewRecord') const toolbarButton = ( label: string, enabled: boolean, tone: 'primary' | 'danger' | 'plain', disabledTitle: string, onClick?: () => void, ) => ( ) const body = (
{interactive && (
{SQL_DATA_SOURCE_TYPE} key: {keyField} {selectId ? translate('::App.Platform.RecordCount', { count: rows.length }) : translate('::App.VisualDesignerCanvas.NoSelectEndpoint')} {translate( mode === 'new' ? '::App.VisualDesignerCanvas.NewRecord' : '::App.Platform.Editing', )} {rows.length > 1 && ( )}
)} {columnCount > 1 ? ( // Grid only once more than one column is asked for, so a single column // form keeps the exact flex layout it had before the setting existed.
{renderChildren(childDataValues, formScope)}
) : ( renderChildren(childDataValues, formScope) )} {/* The drop zone belongs with the content, above the command toolbar. */} {interactive && !node.children.length && (
{translate('::App.VisualDesignerCanvas.DropAndBind')}
)} {node.props.showToolbar !== false && (
{/* Navigation appears on its own once there is more than one record. */} {rows.length > 1 && ( <> {toolbarButton( translate('::App.Platform.Previous'), rowIndex > 0, 'plain', translate('::App.VisualDesignerCanvas.AtFirstRecord'), () => goToRow(Math.max(0, rowIndex - 1)), )} {rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'} {toolbarButton( translate('::App.Platform.Next2'), rowIndex < rows.length - 1, 'plain', translate('::App.VisualDesignerCanvas.AtLastRecord'), () => goToRow(Math.min(rows.length - 1, rowIndex + 1)), )} )} {/* New and Reload only touch local state, so they work at design time. */} {toolbarButton( translate('::App.Platform.New'), canInsert, 'plain', translate('::App.VisualDesignerCanvas.SelectInsertEndpoint'), () => { // New mode starts from the defaults declared on the bound children, // exactly like the generated runtime does. setDraft(buildSqlDefaultRecord(node)) setMode('new') focusFirstFormField(hostRef) }, )} {toolbarButton( translate('::App.Platform.Save'), canSave, 'primary', translate( mode === 'new' ? '::App.VisualDesignerCanvas.SelectInsertEndpoint' : '::App.VisualDesignerCanvas.SelectUpdateEndpoint', ), )} {toolbarButton( translate('::App.Platform.Delete'), canDelete && hasKey && mode === 'edit', 'danger', canDelete ? translate('::App.VisualDesignerCanvas.KeyFieldRequired', { field: keyField }) : translate('::App.VisualDesignerCanvas.SelectDeleteEndpoint'), )} {toolbarButton( translate('::App.Platform.Refresh'), Boolean(selectId), 'plain', translate('::App.VisualDesignerCanvas.SelectSelectEndpoint'), () => goToRow(rowIndex), )}
)}
) return ( {body} ) } /** * A node's own content, resolved in the order the runtime uses: a `setText` * override beats a data binding, which beats the static `children` prop. Child * nodes are not considered here — the caller decides whether they take over. */ const resolveNodeContent = ( node: DesignerNode, dataValues: Record, currentItem: unknown, translate: (key: string) => string, refOverrides: Record = {}, ): React.ReactNode => { if (refOverrides.children !== undefined) return refOverrides.children as React.ReactNode const childrenBinding = node.bindings?.children const boundChildren = childrenBinding?.sourceId ? getBindingValue(childrenBinding, dataValues, currentItem) : undefined if (boundChildren === null || boundChildren === undefined) { return ( (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) || undefined ) } if (React.isValidElement(boundChildren)) return boundChildren return typeof boundChildren === 'object' ? JSON.stringify(boundChildren) : String(boundChildren) } /** * Platform view in preview mode. Its filters are resolved the same way the data * source preview resolves them — a static value as typed, anything else from the * sampling value — and handed to the view as the `filter` expression it already * understands. A required filter without a sampling value renders nothing, which * is what the generated page does too. */ const PlatformViewPreview = ({ node }: { node: DesignerNode }) => { const filterKey = JSON.stringify(getDesignerNodeFilters(node)) const params = React.useMemo(() => { const filters = JSON.parse(filterKey) as DesignerDataSourceFilter[] if (!filters.length) return undefined return buildDesignerFilterParams(filters, resolveDesignerPreviewFilterValue) }, [filterKey]) if (params === null) return null return ( ) } const renderElement = ( node: DesignerNode, children: React.ReactNode, dataValues: Record, currentItem: unknown, interactive: boolean, translate: (key: string) => string, onNodePropChange?: (id: string, propertyName: string, value: unknown) => void, renderCustomComponent?: (name: string, props?: Record) => React.ReactNode, formScope?: DesignerFormScope, refs: Record = {}, refOverrides: Record = {}, ) => { // Built in NodeView so the container can own the record state and expose it to // its children through an augmented `dataValues` map. if (isSqlDataSourceNode(node.type)) return <>{children} if (node.type === 'Spacer') { return (