From 300bc5ae886935ba59fbb4c37dc9b646c276c4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Sat, 30 May 2026 01:22:39 +0300 Subject: [PATCH] =?UTF-8?q?EditForm=20i=C3=A7erisine=20Script=20ve=20Optio?= =?UTF-8?q?ns=20komponentleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/assets/styles/components/_tabs.css | 2 +- .../EditorOptionsBuilderDialog.tsx | 1008 +++++++++++ .../EditorScriptBuilderDialog.tsx | 1549 +++++++++++++++++ .../JsonRowOpDialogEditForm.tsx | 115 +- 4 files changed, 2656 insertions(+), 18 deletions(-) create mode 100644 ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx create mode 100644 ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx diff --git a/ui/src/assets/styles/components/_tabs.css b/ui/src/assets/styles/components/_tabs.css index 3107665..214ad67 100644 --- a/ui/src/assets/styles/components/_tabs.css +++ b/ui/src/assets/styles/components/_tabs.css @@ -15,7 +15,7 @@ } .tab-nav-underline { - @apply py-3 px-5 border-b-2 border-transparent; + @apply py-2 px-4 border-b-2 border-transparent; } .tab-nav-pill { diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx b/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx new file mode 100644 index 0000000..af36552 --- /dev/null +++ b/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx @@ -0,0 +1,1008 @@ +import { Button, Dialog } from '@/components/ui' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { useEffect, useMemo, useState } from 'react' +import { FaCheck, FaCode, FaPlus, FaSlidersH, FaTimes, FaTrash } from 'react-icons/fa' + +type CustomOption = { + id: string + path: string + value: string + type: 'string' | 'number' | 'boolean' | 'json' +} + +type EditorOptionsBuilderDialogProps = { + isOpen: boolean + value?: string + editorType?: string + onClose: () => void + onApply: (value: string) => void +} + +const baseInputClass = + 'w-full h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400' + +const boolOptions = [ + { key: 'showClearButton', label: 'showClearButton' }, + { key: 'readOnly', label: 'readOnly' }, + { key: 'disabled', label: 'disabled' }, + { key: 'searchEnabled', label: 'searchEnabled' }, + { key: 'showDataBeforeSearch', label: 'showDataBeforeSearch' }, + { key: 'acceptCustomValue', label: 'acceptCustomValue' }, + { key: 'showSpinButtons', label: 'showSpinButtons' }, + { key: 'useMaskBehavior', label: 'useMaskBehavior' }, + { key: 'useMaskedValue', label: 'useMaskedValue' }, + { key: 'spellcheck', label: 'spellcheck' }, + { key: 'openOnFieldClick', label: 'openOnFieldClick' }, + { key: 'showDropDownButton', label: 'showDropDownButton' }, +] + +const htmlToolbarItems = [ + 'undo', + 'redo', + 'separator', + 'size', + 'font', + 'separator', + 'bold', + 'italic', + 'strike', + 'underline', + 'separator', + 'alignLeft', + 'alignCenter', + 'alignRight', + 'alignJustify', + 'separator', + 'orderedList', + 'bulletList', + 'separator', + 'header', + 'separator', + 'color', + 'background', + 'separator', + 'link', + 'image', + 'separator', + 'clear', + 'codeBlock', + 'blockquote', + 'separator', + 'insertTable', + 'deleteTable', + 'insertRowAbove', + 'insertRowBelow', + 'deleteRow', + 'insertColumnLeft', + 'insertColumnRight', + 'deleteColumn', + 'cellProperties', + 'tableProperties', +] + +function parseJsonObject(value?: string) { + if (!value?.trim() || value.trim().toLowerCase() === 'null') return {} + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +function isEmptyObject(value: Record) { + return Object.keys(value).length === 0 +} + +function setByPath(target: Record, path: string, value: unknown) { + const keys = path + .split('.') + .map((key) => key.trim()) + .filter(Boolean) + if (!keys.length) return + + let cursor: Record = target + keys.slice(0, -1).forEach((key) => { + if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) { + cursor[key] = {} + } + cursor = cursor[key] as Record + }) + cursor[keys[keys.length - 1]] = value +} + +function unsetByPath(target: Record, path: string) { + const keys = path + .split('.') + .map((key) => key.trim()) + .filter(Boolean) + if (!keys.length) return + + let cursor: Record | undefined = target + for (const key of keys.slice(0, -1)) { + const next = cursor?.[key] + if (!next || typeof next !== 'object' || Array.isArray(next)) return + cursor = next as Record + } + + if (cursor && typeof cursor === 'object' && !Array.isArray(cursor)) { + delete cursor[keys[keys.length - 1]] + } +} + +function getByPath(target: Record, path: string) { + return path + .split('.') + .map((key) => key.trim()) + .filter(Boolean) + .reduce((cursor, key) => { + if (!cursor || typeof cursor !== 'object' || Array.isArray(cursor)) return undefined + return (cursor as Record)[key] + }, target) +} + +function toInputValue(value: unknown): string | number { + return typeof value === 'string' || typeof value === 'number' ? value : '' +} + +function toSelectValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function readCustomValue(option: CustomOption) { + if (option.type === 'boolean') return option.value === 'true' + if (option.type === 'number') return Number(option.value || 0) + if (option.type === 'json') { + try { + return JSON.parse(option.value) + } catch { + return option.value + } + } + return option.value +} + +function buildHtmlEditorOptions() { + return { + mediaResizing: { enabled: true }, + imageUpload: { + tabs: ['file', 'url'], + fileUploadMode: 'base64', + }, + toolbar: { + multiline: true, + items: htmlToolbarItems.map((name) => { + if (name === 'size') { + return { + name, + acceptedValues: ['8pt', '10pt', '12pt', '14pt', '18pt', '24pt', '36pt'], + options: { inputAttr: { 'aria-label': 'Font size' } }, + } + } + if (name === 'font') { + return { + name, + acceptedValues: [ + 'Arial', + 'Courier New', + 'Georgia', + 'Impact', + 'Lucida Console', + 'Tahoma', + 'Times New Roman', + 'Verdana', + ], + options: { inputAttr: { 'aria-label': 'Font family' } }, + } + } + if (name === 'header') return { name, acceptedValues: [false, 1, 2, 3, 4, 5] } + return { name } + }), + }, + } +} + +function EditorOptionsBuilderDialog({ + isOpen, + value, + editorType, + onClose, + onApply, +}: EditorOptionsBuilderDialogProps) { + const parsedValue = useMemo(() => parseJsonObject(value), [value]) + const [options, setOptions] = useState>({}) + const [customOptions, setCustomOptions] = useState([]) + const [parseError, setParseError] = useState('') + const { translate } = useLocalization() + + useEffect(() => { + if (!isOpen) return + setOptions(parsedValue) + setCustomOptions([]) + setParseError( + value?.trim() && value.trim().toLowerCase() !== 'null' && isEmptyObject(parsedValue) + ? 'Current value is not valid JSON.' + : '', + ) + }, [isOpen, parsedValue, value]) + + const toggleOption = (key: string, checked: boolean) => { + setOptions((current) => { + const next = { ...current } + if (checked) next[key] = true + else delete next[key] + return next + }) + } + + const setOptionValue = (key: string, optionValue: unknown) => { + setOptions((current) => { + const next = { ...current } + if (optionValue === '' || optionValue === undefined || optionValue === null) delete next[key] + else next[key] = optionValue + return next + }) + } + + const setOptionPathValue = (path: string, optionValue: unknown) => { + setOptions((current) => { + const next = { ...current } + if (optionValue === '' || optionValue === undefined || optionValue === null) { + unsetByPath(next, path) + } else { + setByPath(next, path, optionValue) + } + return next + }) + } + + const addPreset = (preset: Record) => { + setOptions((current) => ({ ...current, ...preset })) + } + + const applyOptions = () => { + const next = { ...options } + customOptions.forEach((option) => { + if (option.path.trim()) setByPath(next, option.path, readCustomValue(option)) + }) + onApply(isEmptyObject(next) ? '' : JSON.stringify(next)) + onClose() + } + + const clearOptions = () => { + setOptions({}) + setCustomOptions([]) + } + + const preview = useMemo(() => { + const next = { ...options } + customOptions.forEach((option) => { + if (option.path.trim()) setByPath(next, option.path, readCustomValue(option)) + }) + return isEmptyObject(next) ? '' : JSON.stringify(next, null, 2) + }, [customOptions, options]) + + return ( + + +
+
+ Editor Options Builder +
+ +
+ + {parseError && ( +
+ Geçerli JSON okunamadı. +
+ )} + +
+
+
+
+ 1. Hızlı Anahtarlar +
+
+ {boolOptions.map((option) => ( + + ))} +
+
+ +
+
+ 2. Boyut ve Görünüm +
+
+ + + +
+
+ +
+
+ 3. Genel Editor Davranışı +
+
+ + + + + + +
+
+ +
+
+ 4. TextBox / Mask +
+
+ + + + + + +
+
+ +
+
+ 5. NumberBox +
+
+ {['min', 'max', 'step'].map((key) => ( + + ))} + + +
+
+ +
+
+ 6. DateBox +
+
+ + + + + + +
+
+ +
+
+ 7. Hazır Ayarlar +
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ 8. Özel Ayarlar +
+
+ +
+
+ {customOptions.map((option) => ( +
+ + setCustomOptions((current) => + current.map((item) => + item.id === option.id ? { ...item, path: event.target.value } : item, + ), + ) + } + placeholder="path.to.option" + title="Nokta ile nested path yaz. Örnek: toolbar.multiline" + /> + + + setCustomOptions((current) => + current.map((item) => + item.id === option.id ? { ...item, value: event.target.value } : item, + ), + ) + } + placeholder={option.type === 'boolean' ? 'true / false' : 'value'} + title="Tip JSON ise object/array yazabilirsin. Tip boolean ise true veya false yaz." + /> +
+ ))} +
+
+
+ +
+
+ + JSON Önizleme +
+
+              {preview || '{}'}
+            
+
+
+
+ + + + +
+ ) +} + +export default EditorOptionsBuilderDialog diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx b/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx new file mode 100644 index 0000000..87c403f --- /dev/null +++ b/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx @@ -0,0 +1,1549 @@ +import { Button, Dialog } from '@/components/ui' +import { SelectBoxOption } from '@/types/shared' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { useEffect, useMemo, useState } from 'react' +import { FaBolt, FaCheck, FaCode, FaPlus, FaTimes, FaTrash } from 'react-icons/fa' + +type CopyMapping = { + id: string + source: string + target: string +} + +type ToggleRule = { + id: string + target: string + property: 'visible' | 'disabled' | 'readOnly' + source: string + operator: 'equals' | 'notEquals' | 'empty' | 'notEmpty' + value: string + whenTrue: boolean +} + +type ConditionalAction = { + id: string + source: string + operator: + | 'always' + | 'equals' + | 'notEquals' + | 'empty' + | 'notEmpty' + | 'contains' + | 'greaterThan' + | 'lessThan' + value: string + actionType: + | 'setField' + | 'copySelected' + | 'setFieldState' + | 'setFieldStyle' + | 'apiToField' + | 'openUrl' + | 'alert' + | 'confirm' + | 'calculate' + targetField: string + targetProperty: 'visible' | 'disabled' | 'readOnly' | 'color' | 'backgroundColor' | 'borderColor' + textValue: string + selectedColumn: string + apiUrl: string + apiMethod: 'GET' | 'POST' + responsePath: string + urlTarget: '_blank' | '_self' + formula: string + message: string +} + +type EditorScriptBuilderDialogProps = { + isOpen: boolean + value?: string + currentField?: string + fields: SelectBoxOption[] + onClose: () => void + onApply: (value: string) => void +} + +const baseInputClass = + 'w-full h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400' + +const makeId = () => `${Date.now()}_${Math.random().toString(36).slice(2)}` + +const createConditionalAction = (): ConditionalAction => ({ + id: makeId(), + source: '', + operator: 'always', + value: '', + actionType: 'setField', + targetField: '', + targetProperty: 'disabled', + textValue: '', + selectedColumn: '', + apiUrl: '', + apiMethod: 'GET', + responsePath: '', + urlTarget: '_blank', + formula: '', + message: '', +}) + +function fieldOptions(fields: SelectBoxOption[]) { + return fields + .map((field) => String(field.value || field.label || '')) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)) +} + +function quote(value: string) { + return JSON.stringify(value ?? '') +} + +function buildCondition(rule: ToggleRule) { + const source = `next[${quote(rule.source)}]` + if (rule.operator === 'empty') return `!${source}` + if (rule.operator === 'notEmpty') return `!!${source}` + if (rule.operator === 'notEquals') return `${source} !== ${quote(rule.value)}` + return `${source} === ${quote(rule.value)}` +} + +function buildGenericCondition(rule: ConditionalAction) { + if (rule.operator === 'always') return 'true' + const source = `next[${quote(rule.source)}]` + if (rule.operator === 'empty') return `!${source}` + if (rule.operator === 'notEmpty') return `!!${source}` + if (rule.operator === 'contains') return `String(${source} ?? '').includes(${quote(rule.value)})` + if (rule.operator === 'greaterThan') + return `Number(${source} ?? 0) > Number(${quote(rule.value)})` + if (rule.operator === 'lessThan') return `Number(${source} ?? 0) < Number(${quote(rule.value)})` + if (rule.operator === 'notEquals') return `${source} !== ${quote(rule.value)}` + return `${source} === ${quote(rule.value)}` +} + +function conditionNeedsSource(operator: ConditionalAction['operator'] | ToggleRule['operator']) { + return operator !== 'always' +} + +function conditionNeedsValue(operator: ConditionalAction['operator'] | ToggleRule['operator']) { + return ( + operator === 'equals' || + operator === 'notEquals' || + operator === 'contains' || + operator === 'greaterThan' || + operator === 'lessThan' + ) +} + +function isConditionalActionReady(action: ConditionalAction) { + if (conditionNeedsSource(action.operator) && !action.source) return false + if (conditionNeedsValue(action.operator) && !action.value.trim()) return false + if (action.actionType === 'setField') + return Boolean(action.targetField && action.textValue.trim()) + if (action.actionType === 'copySelected') + return Boolean(action.targetField && action.selectedColumn) + if (action.actionType === 'setFieldState') return Boolean(action.targetField) + if (action.actionType === 'setFieldStyle') + return Boolean(action.targetField && action.textValue.trim()) + if (action.actionType === 'apiToField') return Boolean(action.targetField && action.apiUrl.trim()) + if (action.actionType === 'openUrl') return Boolean(action.textValue.trim()) + if (action.actionType === 'alert' || action.actionType === 'confirm') + return Boolean(action.message.trim()) + if (action.actionType === 'calculate') return Boolean(action.targetField && action.formula.trim()) + return false +} + +function isToggleRuleReady(rule: ToggleRule) { + if (!rule.target || !rule.source) return false + if (conditionNeedsValue(rule.operator) && !rule.value.trim()) return false + return true +} + +function buildValueExpression(value: string) { + const trimmed = value.trim() + if (!trimmed) return "''" + if (trimmed === 'true' || trimmed === 'false') return trimmed + if (trimmed.startsWith('=') && trimmed.length > 1) return trimmed.slice(1) + return quote(value) +} + +function fieldLabel(value: string, fallback: string) { + return value || fallback +} + +function buildScript({ + currentField, + copyMappings, + toggleRules, + daysStartField, + daysEndField, + daysTargetField, + selectedItemAmountEnabled, + rowAmountEnabled, + amountQuantityField, + amountUnitPriceField, + amountUomField, + amountTotalField, + timeDiffEnabled, + timeStartField, + timeEndField, + timeTargetField, + conditionalActions, + serviceCall, + customScript, +}: { + currentField?: string + copyMappings: CopyMapping[] + toggleRules: ToggleRule[] + daysStartField: string + daysEndField: string + daysTargetField: string + selectedItemAmountEnabled: boolean + rowAmountEnabled: boolean + amountQuantityField: string + amountUnitPriceField: string + amountUomField: string + amountTotalField: string + timeDiffEnabled: boolean + timeStartField: string + timeEndField: string + timeTargetField: string + conditionalActions: ConditionalAction[] + serviceCall: string + customScript: string +}) { + const activeCopyMappings = copyMappings.filter((mapping) => mapping.source && mapping.target) + const activeToggleRules = toggleRules.filter(isToggleRuleReady) + const hasCopyMappings = activeCopyMappings.length > 0 + const hasToggleRules = activeToggleRules.length > 0 + const hasDateDifference = Boolean(daysStartField && daysEndField && daysTargetField) + const hasSelectedItemAmount = Boolean( + selectedItemAmountEnabled && amountQuantityField && amountUnitPriceField && amountTotalField, + ) + const hasRowAmount = Boolean( + rowAmountEnabled && amountQuantityField && amountUnitPriceField && amountTotalField, + ) + const hasTimeDifference = Boolean( + timeDiffEnabled && timeStartField && timeEndField && timeTargetField, + ) + const activeConditionalActions = conditionalActions.filter(isConditionalActionReady) + const hasConditionalActions = activeConditionalActions.length > 0 + const hasServiceCall = Boolean(serviceCall.trim()) + const hasBuilderSelection = + hasCopyMappings || + hasToggleRules || + hasDateDifference || + hasSelectedItemAmount || + hasRowAmount || + hasTimeDifference || + hasConditionalActions || + hasServiceCall + + if (!hasBuilderSelection) { + return customScript.trim() + } + + const needsSetFormData = + hasCopyMappings || + hasDateDifference || + hasSelectedItemAmount || + hasRowAmount || + hasTimeDifference || + activeConditionalActions.some((action) => + ['setField', 'copySelected', 'apiToField', 'calculate'].includes(action.actionType), + ) + const needsSelectedItem = + hasCopyMappings || + hasSelectedItemAmount || + activeConditionalActions.some((action) => + [ + 'copySelected', + 'calculate', + 'setField', + 'setFieldStyle', + 'apiToField', + 'openUrl', + 'alert', + 'confirm', + ].includes(action.actionType), + ) + const needsGetByPath = + activeCopyMappings.some((mapping) => mapping.source.includes('.')) || + activeConditionalActions.some( + (action) => + action.actionType === 'copySelected' || + action.actionType === 'apiToField' || + ['setField', 'setFieldStyle', 'openUrl', 'alert', 'confirm'].includes(action.actionType), + ) + const needsRenderTemplate = activeConditionalActions.some((action) => + ['setField', 'setFieldStyle', 'apiToField', 'openUrl', 'alert', 'confirm'].includes( + action.actionType, + ), + ) + const needsForm = + hasToggleRules || + activeConditionalActions.some( + (action) => action.actionType === 'setFieldState' || action.actionType === 'setFieldStyle', + ) + const needsFieldState = + hasToggleRules || + activeConditionalActions.some((action) => action.actionType === 'setFieldState') + const needsFieldStyle = activeConditionalActions.some( + (action) => action.actionType === 'setFieldStyle', + ) + + const lines: string[] = ['(async () => {'] + + if (needsSetFormData || hasToggleRules || hasConditionalActions) { + lines.push( + " const currentField = (typeof editor !== 'undefined' && editor?.dataField) || e?.dataField || " + + quote(currentField || '') + + ';', + ) + lines.push(' const next = { ...formData, [currentField]: e?.value };') + } + + if (needsSelectedItem) { + lines.push( + ' const selectedItem = e?.component?.option ? e.component.option("selectedItem") : null;', + ) + } + + if (needsGetByPath) { + lines.push( + ' const getByPath = (obj, path) => String(path || "").split(".").filter(Boolean).reduce((acc, key) => acc == null ? acc : acc[key], obj);', + ) + } + + if (needsRenderTemplate) { + lines.push( + ' const renderTemplate = text => String(text || "").replace(/\\{value\\}/g, e?.value ?? "").replace(/\\{selected\\.([^}]+)\\}/g, (_, key) => getByPath(selectedItem, key) ?? "").replace(/\\{([^}]+)\\}/g, (_, key) => next[key] ?? "");', + ) + } + + if (needsForm) { + lines.push( + ' const form = e?.component?.itemOption ? e.component : ((typeof editor !== "undefined" && editor?.component?.option) ? editor.component.option("editing.form") : null);', + ) + } + + if (needsFieldState) { + lines.push( + ' const setFieldState = (field, prop, flag) => { if (!field) return; if (prop === "visible") { form?.itemOption?.(field, "visible", flag); return; } form?.getEditor?.(field)?.option(prop, flag); const item = form?.itemOption?.(field) || {}; form?.itemOption?.(field, "editorOptions", { ...(item.editorOptions || {}), [prop]: flag }); };', + ) + } + + if (needsFieldStyle) { + lines.push( + ' const setFieldStyle = (field, prop, value) => { const editorInstance = form?.getEditor?.(field); const element = editorInstance?.element?.(); const node = element?.get ? element.get(0) : element; if (node?.style) node.style[prop] = value || ""; const input = node?.querySelector?.("input, textarea, .dx-texteditor-input"); if (input?.style) input.style[prop] = value || ""; };', + ) + } + + activeCopyMappings.forEach((mapping) => { + const source = mapping.source.includes('.') + ? `getByPath(selectedItem, ${quote(mapping.source)})` + : `selectedItem[${quote(mapping.source)}]` + lines.push( + ` next[${quote(mapping.target)}] = selectedItem ? ${source} : next[${quote(mapping.target)}];`, + ) + }) + + if ( + selectedItemAmountEnabled && + amountQuantityField && + amountUnitPriceField && + amountTotalField + ) { + lines.push(' {') + lines.push(' const p = selectedItem || {};') + lines.push( + ` const q = Math.round((parseFloat(p[${quote(amountQuantityField)}]) || 0) * 100);`, + ) + lines.push( + ` const u = Math.round((parseFloat(p[${quote(amountUnitPriceField)}]) || 0) * 100);`, + ) + lines.push(` next[${quote(amountQuantityField)}] = q / 100;`) + lines.push(` next[${quote(amountUnitPriceField)}] = u / 100;`) + if (amountUomField) { + lines.push(` next[${quote(amountUomField)}] = p[${quote(amountUomField)}];`) + } + lines.push(` next[${quote(amountTotalField)}] = Math.round((q * u) / 100) / 100;`) + lines.push(' }') + } + + if (rowAmountEnabled && amountQuantityField && amountUnitPriceField && amountTotalField) { + lines.push(' {') + lines.push( + ` const q = Math.round((parseFloat(next[${quote(amountQuantityField)}]) || 0) * 100);`, + ) + lines.push( + ` const u = Math.round((parseFloat(next[${quote(amountUnitPriceField)}]) || 0) * 100);`, + ) + lines.push(` next[${quote(amountTotalField)}] = Math.round((q * u) / 100) / 100;`) + lines.push(' }') + } + + if (daysStartField && daysEndField && daysTargetField) { + lines.push( + ' const parseDate = value => !value ? null : (value instanceof Date ? value : new Date(value));', + ) + lines.push(` const startDate = parseDate(next[${quote(daysStartField)}]);`) + lines.push(` const endDate = parseDate(next[${quote(daysEndField)}]);`) + lines.push( + ` next[${quote( + daysTargetField, + )}] = startDate && endDate ? Math.max(0, Math.floor((Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()) - Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())) / (24 * 60 * 60 * 1000)) + 1) : null;`, + ) + } + + if (timeDiffEnabled && timeStartField && timeEndField && timeTargetField) { + lines.push(' {') + lines.push( + ' const toDate = value => !value ? null : (value instanceof Date ? value : new Date(value));', + ) + lines.push(` const startTime = toDate(next[${quote(timeStartField)}]);`) + lines.push(` const endTime = toDate(next[${quote(timeEndField)}]);`) + lines.push(' let hours = null;') + lines.push(' if (startTime && endTime) {') + lines.push(' hours = (endTime - startTime) / 36e5;') + lines.push(' if (hours < 0) hours += 24;') + lines.push(' hours = Math.round(hours * 10) / 10;') + lines.push(' }') + lines.push(` next[${quote(timeTargetField)}] = hours;`) + lines.push(' }') + } + + activeConditionalActions.forEach((action) => { + const condition = buildGenericCondition(action) + lines.push(` if (${condition}) {`) + + if (action.actionType === 'setField' && action.targetField) { + lines.push( + ` next[${quote(action.targetField)}] = renderTemplate(${quote(action.textValue)});`, + ) + } + + if (action.actionType === 'copySelected' && action.targetField && action.selectedColumn) { + lines.push( + ` next[${quote(action.targetField)}] = selectedItem ? getByPath(selectedItem, ${quote( + action.selectedColumn, + )}) : next[${quote(action.targetField)}];`, + ) + } + + if (action.actionType === 'setFieldState' && action.targetField) { + lines.push( + ` setFieldState(${quote(action.targetField)}, ${quote(action.targetProperty)}, ${buildValueExpression( + action.textValue || 'true', + )});`, + ) + } + + if (action.actionType === 'setFieldStyle' && action.targetField && action.textValue) { + lines.push( + ` setFieldStyle(${quote(action.targetField)}, ${quote( + action.targetProperty, + )}, renderTemplate(${quote(action.textValue)}));`, + ) + } + + if (action.actionType === 'apiToField' && action.apiUrl && action.targetField) { + const method = action.apiMethod || 'GET' + lines.push(` const apiUrl = renderTemplate(${quote(action.apiUrl)});`) + if (method === 'POST') { + lines.push( + ' const apiResp = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) });', + ) + } else { + lines.push(' const apiResp = await fetch(apiUrl);') + } + lines.push(' const apiJson = await apiResp.json();') + lines.push( + ` next[${quote(action.targetField)}] = getByPath(apiJson, ${quote( + action.responsePath || '', + )}) ?? apiJson;`, + ) + } + + if (action.actionType === 'openUrl' && action.textValue) { + lines.push( + ` window.open(renderTemplate(${quote(action.textValue)}), ${quote( + action.urlTarget || '_blank', + )});`, + ) + } + + if (action.actionType === 'alert' && action.message) { + lines.push(` alert(renderTemplate(${quote(action.message)}));`) + } + + if (action.actionType === 'confirm' && action.message) { + lines.push(` if (!confirm(renderTemplate(${quote(action.message)}))) return;`) + } + + if (action.actionType === 'calculate' && action.targetField && action.formula) { + lines.push( + ` next[${quote( + action.targetField, + )}] = ((data, selected, value) => (${action.formula}))(next, selectedItem, e?.value);`, + ) + } + + lines.push(' }') + }) + + if (needsSetFormData) { + lines.push(' setFormData(next);') + } + + if (hasToggleRules) { + activeToggleRules.forEach((rule) => { + const condition = buildCondition(rule) + lines.push(` {`) + lines.push(` const flag = (${condition}) ? ${rule.whenTrue} : ${!rule.whenTrue};`) + if (rule.property === 'visible') { + lines.push(` setFieldState(${quote(rule.target)}, 'visible', flag);`) + } else { + lines.push(` setFieldState(${quote(rule.target)}, ${quote(rule.property)}, flag);`) + } + lines.push(` }`) + }) + } + + if (serviceCall.trim()) { + lines.push(` ${serviceCall.trim().replace(/;?$/, ';')}`) + } + + if (customScript.trim()) { + lines.push(' // Custom script') + customScript + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean) + .forEach((line) => lines.push(` ${line}`)) + } + + lines.push('})();') + return lines.join('\n') +} + +function EditorScriptBuilderDialog({ + isOpen, + value, + currentField, + fields, + onClose, + onApply, +}: EditorScriptBuilderDialogProps) { + const availableFields = useMemo(() => fieldOptions(fields), [fields]) + const [copyMappings, setCopyMappings] = useState([]) + const [toggleRules, setToggleRules] = useState([]) + const [daysStartField, setDaysStartField] = useState('') + const [daysEndField, setDaysEndField] = useState('') + const [daysTargetField, setDaysTargetField] = useState('') + const [selectedItemAmountEnabled, setSelectedItemAmountEnabled] = useState(false) + const [rowAmountEnabled, setRowAmountEnabled] = useState(false) + const [amountQuantityField, setAmountQuantityField] = useState('') + const [amountUnitPriceField, setAmountUnitPriceField] = useState('') + const [amountUomField, setAmountUomField] = useState('') + const [amountTotalField, setAmountTotalField] = useState('') + const [timeDiffEnabled, setTimeDiffEnabled] = useState(false) + const [timeStartField, setTimeStartField] = useState('') + const [timeEndField, setTimeEndField] = useState('') + const [timeTargetField, setTimeTargetField] = useState('') + const [conditionalActions, setConditionalActions] = useState([]) + const [serviceCall, setServiceCall] = useState('') + const [customScript, setCustomScript] = useState('') + const { translate } = useLocalization() + + useEffect(() => { + if (!isOpen) return + setCopyMappings([]) + setToggleRules([]) + setDaysStartField('') + setDaysEndField('') + setDaysTargetField('') + setSelectedItemAmountEnabled(false) + setRowAmountEnabled(false) + setAmountQuantityField('') + setAmountUnitPriceField('') + setAmountUomField('') + setAmountTotalField('') + setTimeDiffEnabled(false) + setTimeStartField('') + setTimeEndField('') + setTimeTargetField('') + setConditionalActions([]) + setServiceCall('') + setCustomScript('') + }, [isOpen, value]) + + const findField = (fieldName: string) => + availableFields.find((field) => field.toLowerCase() === fieldName.toLowerCase()) || '' + + const fillAmountDefaults = () => { + setAmountQuantityField((current) => current || findField('Quantity')) + setAmountUnitPriceField((current) => current || findField('UnitPrice')) + setAmountUomField((current) => current || findField('UomId')) + setAmountTotalField((current) => current || findField('TotalAmount')) + } + + const fillTimeDefaults = () => { + setTimeStartField((current) => current || findField('StartTime')) + setTimeEndField((current) => current || findField('EndTime')) + setTimeTargetField((current) => current || findField('TotalHours')) + } + + const fillDateDefaults = () => { + setDaysStartField((current) => current || findField('StartDate')) + setDaysEndField((current) => current || findField('EndDate')) + setDaysTargetField((current) => current || findField('TotalDays')) + } + + const hydrateKnownScript = (script?: string) => { + const source = script?.trim() + if (!source) return false + + let hydrated = false + + if (source.includes('TotalDays')) { + setDaysStartField(findField('StartDate') || 'StartDate') + setDaysEndField(findField('EndDate') || 'EndDate') + setDaysTargetField(findField('TotalDays') || 'TotalDays') + hydrated = true + } + + if (source.includes('TotalHours')) { + setTimeDiffEnabled(true) + setTimeStartField(findField('StartTime') || 'StartTime') + setTimeEndField(findField('EndTime') || 'EndTime') + setTimeTargetField(findField('TotalHours') || 'TotalHours') + hydrated = true + } + + if ( + source.includes('TotalAmount') && + source.includes('Quantity') && + source.includes('UnitPrice') + ) { + setAmountQuantityField(findField('Quantity') || 'Quantity') + setAmountUnitPriceField(findField('UnitPrice') || 'UnitPrice') + setAmountUomField(findField('UomId') || 'UomId') + setAmountTotalField(findField('TotalAmount') || 'TotalAmount') + if (source.includes('selectedItem') || source.includes("option('selectedItem')")) { + setSelectedItemAmountEnabled(true) + } else { + setRowAmountEnabled(true) + } + hydrated = true + } + + const serviceMatch = source.match(/UiEvalService\.[A-Za-z0-9_]+\(.*?\);?/) + if (serviceMatch?.[0]) { + setServiceCall(serviceMatch[0]) + hydrated = true + } + + return hydrated + } + + useEffect(() => { + if (!isOpen) return + const existingScript = value?.trim() + if (!existingScript) { + setCustomScript('') + return + } + + const hydrated = hydrateKnownScript(existingScript) + setCustomScript(hydrated ? '' : existingScript) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableFields, isOpen, value]) + + const preview = useMemo( + () => + buildScript({ + currentField, + copyMappings, + toggleRules, + daysStartField, + daysEndField, + daysTargetField, + selectedItemAmountEnabled, + rowAmountEnabled, + amountQuantityField, + amountUnitPriceField, + amountUomField, + amountTotalField, + timeDiffEnabled, + timeStartField, + timeEndField, + timeTargetField, + conditionalActions, + serviceCall, + customScript, + }), + [ + amountQuantityField, + amountTotalField, + amountUnitPriceField, + amountUomField, + conditionalActions, + copyMappings, + currentField, + customScript, + daysEndField, + daysStartField, + daysTargetField, + rowAmountEnabled, + selectedItemAmountEnabled, + serviceCall, + timeDiffEnabled, + timeEndField, + timeStartField, + timeTargetField, + toggleRules, + ], + ) + + const renderFieldSelect = (value: string, onChange: (value: string) => void) => ( + + ) + + const updateConditionalAction = (id: string, patch: Partial) => { + setConditionalActions((current) => + current.map((action) => (action.id === id ? { ...action, ...patch } : action)), + ) + } + + const renderLabeledFieldSelect = ( + label: string, + value: string, + onChange: (value: string) => void, + ) => ( + + ) + + const needsCompareValue = (operator: ConditionalAction['operator'] | ToggleRule['operator']) => + operator === 'equals' || + operator === 'notEquals' || + operator === 'contains' || + operator === 'greaterThan' || + operator === 'lessThan' + + const describeCopyMapping = (mapping: CopyMapping) => + `Seçili kayıttaki ${fieldLabel(mapping.source, 'kolon/path')} değerini ${fieldLabel( + mapping.target, + 'hedef field', + )} alanına yaz.` + + const describeConditionalAction = (action: ConditionalAction) => { + const condition = + action.operator === 'always' + ? 'Her zaman' + : `Eğer ${fieldLabel(action.source, 'kaynak field')} ${action.operator}${ + needsCompareValue(action.operator) ? ` ${action.value || 'değer'}` : '' + } ise` + const target = fieldLabel(action.targetField, 'hedef field') + + if (action.actionType === 'setField') { + return `${condition}, ${target} alanına ${action.textValue || 'değer'} yaz.` + } + if (action.actionType === 'copySelected') { + return `${condition}, seçili kayıttaki ${action.selectedColumn || 'path'} değerini ${target} alanına yaz.` + } + if (action.actionType === 'setFieldState') { + return `${condition}, ${target} alanında ${action.targetProperty} = ${ + action.textValue || 'true' + } yap.` + } + if (action.actionType === 'setFieldStyle') { + return `${condition}, ${target} alanının ${action.targetProperty} stilini ${ + action.textValue || 'değer' + } yap.` + } + if (action.actionType === 'apiToField') { + return `${condition}, ${action.apiMethod} ${action.apiUrl || 'api url'} çağır ve sonucu ${target} alanına yaz.` + } + if (action.actionType === 'openUrl') { + return `${condition}, ${action.textValue || 'url'} adresini aç.` + } + if (action.actionType === 'alert') { + return `${condition}, ${action.message || 'mesaj'} uyarısını göster.` + } + if (action.actionType === 'confirm') { + return `${condition}, ${action.message || 'mesaj'} onayını iste.` + } + if (action.actionType === 'calculate') { + return `${condition}, formül sonucunu ${target} alanına yaz.` + } + return condition + } + + return ( + + +
+
+ Editor Script Builder +
+ +
+ +
+
+
+
+
+
+ 1. Seçili Kaydın Değerlerini Kopyala +
+
+ +
+
+ {copyMappings.map((mapping) => ( +
+
+ {describeCopyMapping(mapping)} +
+
+ +
+ {renderLabeledFieldSelect( + 'Formdaki hedef field', + mapping.target, + (nextValue) => + setCopyMappings((current) => + current.map((item) => + item.id === mapping.id ? { ...item, target: nextValue } : item, + ), + ), + )} +
+
+
+ ))} +
+
+ +
+
+
+
+ 2. Koşullu Aksiyon Builder +
+
+ +
+
+ {conditionalActions.map((action) => ( +
+
+ {describeConditionalAction(action)} +
+
+
+ {renderLabeledFieldSelect('Eğer: kaynak field', action.source, (value) => + updateConditionalAction(action.id, { source: value }), + )} +
+ + +
+ +
+ + + {[ + 'setField', + 'copySelected', + 'apiToField', + 'calculate', + 'setFieldState', + 'setFieldStyle', + ].includes(action.actionType) && ( +
+ {renderLabeledFieldSelect('Hedef field', action.targetField, (value) => + updateConditionalAction(action.id, { targetField: value }), + )} +
+ )} + + {action.actionType === 'setField' && ( + + updateConditionalAction(action.id, { textValue: event.target.value }) + } + placeholder="text, {Field} or {selected.Name}" + title="Sabit metin veya token yaz. Örnek: {Name}, {value}, {selected.DisplayName}" + /> + )} + + {action.actionType === 'copySelected' && ( + + updateConditionalAction(action.id, { + selectedColumn: event.target.value, + }) + } + placeholder="selected item path" + title="Seçilen lookup kaydından okunacak path. Örnek: UomId veya Customer.Name" + /> + )} + + {action.actionType === 'setFieldState' && ( + <> + + + + )} + + {action.actionType === 'setFieldStyle' && ( + <> + + + updateConditionalAction(action.id, { textValue: event.target.value }) + } + placeholder="#ef4444" + title="CSS renk değeri yaz. Örnek: red, #ef4444, rgb(239,68,68). Token da kullanabilirsin: {ColorCode}" + /> + + )} + + {action.actionType === 'apiToField' && ( + <> + + + updateConditionalAction(action.id, { apiUrl: event.target.value }) + } + placeholder="/api/path/{Id}" + title="Token kullanabilirsin: /api/orders/{OrderId}, /api/x/{selected.Id}, /api/y/{value}" + /> + + updateConditionalAction(action.id, { + responsePath: event.target.value, + }) + } + placeholder="data.value" + title="API JSON cevabından okunacak path. Boş bırakırsan tüm JSON hedef field'a yazılır." + /> + + )} + + {action.actionType === 'openUrl' && ( + <> + + updateConditionalAction(action.id, { textValue: event.target.value }) + } + placeholder="/report?id={Id}&type={selected.Type}" + title="Açılacak URL. Token destekler: {Id}, {value}, {selected.Type}" + /> + + + )} + + {(action.actionType === 'alert' || action.actionType === 'confirm') && ( + + updateConditionalAction(action.id, { message: event.target.value }) + } + placeholder="{Field} tokenları ile mesaj" + title="Alert/confirm mesajı. Token destekler: {Name}, {value}, {selected.Name}" + /> + )} + + {action.actionType === 'calculate' && ( + + updateConditionalAction(action.id, { formula: event.target.value }) + } + placeholder="Number(data.Quantity || 0) * Number(data.UnitPrice || 0)" + title="JavaScript expression yaz. data=formData, selected=selectedItem, value=e.value olarak kullanılır." + /> + )} +
+
+ ))} +
+
+ +
+
+
+
+ 3. Field Durum Kuralları +
+
+ +
+
+ {toggleRules.map((rule) => ( +
+
+ Eğer{' '} + + {fieldLabel(rule.source, 'kaynak field')} + {' '} + {rule.operator}{' '} + {rule.operator === 'equals' || rule.operator === 'notEquals' ? ( + {rule.value || 'değer'} + ) : null}{' '} + ise{' '} + + {fieldLabel(rule.target, 'hedef field')} + {' '} + {rule.property} ={' '} + {String(rule.whenTrue)} olur. +
+
+
+ {renderLabeledFieldSelect( + 'O zaman: hedef field', + rule.target, + (nextValue) => + setToggleRules((current) => + current.map((item) => + item.id === rule.id ? { ...item, target: nextValue } : item, + ), + ), + )} +
+ +
+ {renderLabeledFieldSelect('Eğer: kaynak field', rule.source, (nextValue) => + setToggleRules((current) => + current.map((item) => + item.id === rule.id ? { ...item, source: nextValue } : item, + ), + ), + )} +
+
+
+ + + setToggleRules((current) => + current.map((item) => + item.id === rule.id ? { ...item, value: event.target.value } : item, + ), + ) + } + placeholder="Koşul değeri" + title="Eşitse/Eşit değilse durumunda karşılaştırılacak değer." + /> + +
+
+ ))} +
+
+ +
+
+ 4. Tarih Farkı +
+
+ +
+
+ {renderLabeledFieldSelect('Başlangıç tarihi', daysStartField, setDaysStartField)} + {renderLabeledFieldSelect('Bitiş tarihi', daysEndField, setDaysEndField)} + {renderLabeledFieldSelect('Sonuç alanı', daysTargetField, setDaysTargetField)} +
+
+ +
+
+
+
+ 5. Saat Farkı +
+
+ +
+
+ {renderLabeledFieldSelect('Başlangıç saati', timeStartField, setTimeStartField)} + {renderLabeledFieldSelect('Bitiş saati', timeEndField, setTimeEndField)} + {renderLabeledFieldSelect('Sonuç alanı', timeTargetField, setTimeTargetField)} +
+
+ +
+
+ 6. Tutar Hesaplamaları +
+
+ + +
+
+ {renderLabeledFieldSelect( + 'Miktar alanı', + amountQuantityField, + setAmountQuantityField, + )} + {renderLabeledFieldSelect( + 'Birim fiyat alanı', + amountUnitPriceField, + setAmountUnitPriceField, + )} + {renderLabeledFieldSelect('Birim alanı', amountUomField, setAmountUomField)} + {renderLabeledFieldSelect('Toplam alanı', amountTotalField, setAmountTotalField)} +
+
+ +
+
+ 7. Servis Çağrısı ve Özel Script +
+ setServiceCall(event.target.value)} + placeholder="UiEvalService.ApiGenerateBackgroundWorkers();" + title="Global servis çağrısı. Örnek: UiEvalService.ApiGenerateBackgroundWorkers();" + /> +