From 0d27742f8c8639ba848c773576d234a622c557fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:06:14 +0300 Subject: [PATCH] =?UTF-8?q?Claude=20g=C3=BCncellemesi=20Editor=20Script=20?= =?UTF-8?q?Builder=20g=C3=BCncellemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/utils/editorScriptRuntime.ts | 368 +++- .../EditorOptionsBuilderDialog.tsx | 14 +- .../EditorScriptBuilderDialog.tsx | 1858 ++++++----------- .../editor-script/scriptRecipes.ts | 635 ++++++ ui/src/views/form/FormDevExpress.tsx | 59 +- ui/src/views/list/CardView.tsx | 36 +- ui/src/views/list/SchedulerView.tsx | 30 +- ui/src/views/list/shared/editingForm.ts | 19 +- .../views/list/shared/editingFormScripts.ts | 97 +- 9 files changed, 1786 insertions(+), 1330 deletions(-) create mode 100644 ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts diff --git a/ui/src/utils/editorScriptRuntime.ts b/ui/src/utils/editorScriptRuntime.ts index 9c9af480..0cdf1243 100644 --- a/ui/src/utils/editorScriptRuntime.ts +++ b/ui/src/utils/editorScriptRuntime.ts @@ -1,3 +1,18 @@ +/** + * editorScript çalıştırma altyapısı. + * + * Script'ler `new AsyncFunction(...)` ile derlenir ve aşağıdaki yardımcılar + * parametre olarak enjekte edilir. Böylece yapılandırmadaki script'ler + * `set('Total', num('Qty') * num('Price'))` gibi tek satır olabilir; eskiden + * her script kendi `getByPath` / `renderTemplate` / `next` boilerplate'ini + * baştan tanımlamak zorundaydı. + * + * Eski (uzun) script'ler bozulmasın diye geriye dönük uyumluluk korunur: + * yeni parametre adlarıyla derleme bir `SyntaxError` üretirse (örn. script + * içinde `const data = ...` varsa) script yalnızca eski beş parametreyle + * yeniden derlenir. + */ + export type EditorScriptRuntimeContext = { formData: Record e: any @@ -6,24 +21,339 @@ export type EditorScriptRuntimeContext = { setFormData?: (newData: any) => void } -export const executeEditorScript = ( - script: string, - { - formData, - e, - editor, - runtimeSetEditorReadOnly, - setFormData, - }: EditorScriptRuntimeContext, -) => { - const executor = new Function( - 'formData', - 'e', - 'editor', - 'runtimeSetEditorReadOnly', - 'setFormData', - script, - ) +const AsyncFunction = Object.getPrototypeOf(async function noop() {}).constructor as new ( + ...args: string[] +) => (...args: any[]) => Promise - return executor(formData, e, editor, runtimeSetEditorReadOnly, setFormData) +const LEGACY_PARAMS = ['formData', 'e', 'editor', 'runtimeSetEditorReadOnly', 'setFormData'] + +/** Enjekte edilen yardımcıların adları. Sıra `buildHelpers` ile birebir aynı olmalı. */ +const HELPER_PARAMS = [ + '$', + 'value', + 'field', + 'data', + 'isReady', + 'get', + 'num', + 'str', + 'bool', + 'dateOf', + 'pick', + 'selected', + 'snum', + 'sstr', + 'set', + 'clear', + 'readOnly', + 'copy', + 'round', + 'sum', + 'days', + 'hours', + 'tpl', + 'notify', + 'ask', + 'openUrl', + 'api', + 'revert', +] + +/* ------------------------------------------------------------------ * + * Ortak yardımcılar + * ------------------------------------------------------------------ */ + +/** Alan adını büyük/küçük harf duyarsız arar; SQL kolonları tutarsız gelebiliyor. */ +const resolveKey = (source: any, name: string): string | undefined => { + if (!source || typeof source !== 'object') return undefined + if (Object.prototype.hasOwnProperty.call(source, name)) return name + const lower = String(name).toLowerCase() + return Object.keys(source).find((key) => key.toLowerCase() === lower) +} + +const pickPath = (source: any, path?: string): any => + String(path ?? '') + .split('.') + .filter(Boolean) + .reduce((cursor: any, key: string) => { + if (cursor == null) return undefined + const resolved = resolveKey(cursor, key) + return resolved === undefined ? undefined : cursor[resolved] + }, source) + +const toDate = (input: any): Date | null => { + if (!input) return null + const parsed = input instanceof Date ? input : new Date(input) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +const startOfDayUtc = (date: Date) => + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + +/* ------------------------------------------------------------------ * + * Yardımcı API üretimi + * ------------------------------------------------------------------ */ + +const buildHelpers = ({ + formData, + e, + editor, + runtimeSetEditorReadOnly, + setFormData, +}: EditorScriptRuntimeContext) => { + const field: string = editor?.dataField ?? e?.dataField ?? '' + const isReady = e?.scriptEvent === 'contentReady' + + // Script'in okuyup yazdığı canlı form verisi. + const data: Record = { ...formData } + if (field && e && 'value' in e) { + data[resolveKey(data, field) ?? field] = e.value + } + + let selectedItem: any = null + try { + selectedItem = e?.component?.option ? e.component.option('selectedItem') : null + } catch { + selectedItem = null + } + + let dirty = false + let flushQueued = false + + /** + * contentReady sırasında `setFormData` verilmez; o anda yapılan yazmalar + * yalnızca yerel kalır. Eski üretilen script'lerdeki + * `if (!isEditorScriptContentReady)` koruması artık burada. + */ + const flush = () => { + flushQueued = false + if (!dirty || typeof setFormData !== 'function') return + dirty = false + setFormData({ ...data }) + } + + const scheduleFlush = () => { + if (flushQueued) return + flushQueued = true + queueMicrotask(flush) + } + + const writeOne = (name: string, next: any) => { + if (!name) return + const key = resolveKey(data, name) ?? name + // Değer değişmediyse yazmıyoruz. Form açılışında (contentReady) çalışan + // script'ler setFormData'yı tetikleyip yeniden contentReady doğurabildiği + // için bu koruma olmadan sonsuz döngü oluşur. + if (Object.is(data[key], next)) return + data[key] = next + dirty = true + scheduleFlush() + } + + const get = (name: string) => pickPath(data, name) + const num = (name: string) => Number(get(name) ?? 0) || 0 + const str = (name: string) => String(get(name) ?? '') + const bool = (name: string) => { + const raw = get(name) + return raw === true || raw === 'true' || raw === 1 || raw === '1' + } + + const selected = (path?: string) => (path ? pickPath(selectedItem, path) : selectedItem) + const snum = (path: string) => Number(selected(path) ?? 0) || 0 + const sstr = (path: string) => String(selected(path) ?? '') + + const set = (nameOrPatch: string | Record, next?: any) => { + if (nameOrPatch && typeof nameOrPatch === 'object') { + Object.entries(nameOrPatch).forEach(([key, patchValue]) => writeOne(key, patchValue)) + } else { + writeOne(String(nameOrPatch), next) + } + return data + } + + const clear = (...names: string[]) => set(Object.fromEntries(names.map((name) => [name, null]))) + + const readOnly = (name: string, flag: boolean = true) => + runtimeSetEditorReadOnly?.(name, !!flag) + + /** Lookup/GridBox ile seçilen kaydın bir kolonunu form alanına taşır. */ + const copy = (sourcePath: string, targetName?: string) => { + if (!selectedItem) return + const target = targetName || String(sourcePath).split('.').pop() || '' + writeOne(target, pickPath(selectedItem, sourcePath)) + } + + const round = (input: any, digits: number = 2) => { + const factor = 10 ** digits + return Math.round((Number(input) || 0) * factor) / factor + } + + const sum = (...names: string[]) => names.reduce((total, name) => total + num(name), 0) + + /** Bitiş günü dahil gün farkı. */ + const days = (startName: string, endName: string) => { + const start = toDate(get(startName)) + const end = toDate(get(endName)) + if (!start || !end) return null + return Math.max(0, Math.floor((startOfDayUtc(end) - startOfDayUtc(start)) / 864e5) + 1) + } + + /** Saat farkı; bitiş başlangıçtan küçükse ertesi güne taşar. */ + const hours = (startName: string, endName: string) => { + const start = toDate(get(startName)) + const end = toDate(get(endName)) + if (!start || !end) return null + let diff = (end.getTime() - start.getTime()) / 36e5 + if (diff < 0) diff += 24 + return Math.round(diff * 10) / 10 + } + + /** `{Alan}`, `{value}` ve `{selected.Yol}` token'larını doldurur. */ + const tpl = (text: string) => + String(text ?? '') + .replace(/\{value\}/g, () => String(e?.value ?? '')) + .replace(/\{selected\.([^}]+)\}/g, (_, key: string) => String(pickPath(selectedItem, key) ?? '')) + .replace(/\{([^}]+)\}/g, (_, key: string) => String(get(key) ?? '')) + + /** Geçerli alanı değişiklik öncesindeki değerine döndürür. */ + const revert = () => { + const previous = + e?.previousValue ?? pickPath(formData, field) ?? null + writeOne(field, previous) + const activeEditor = e?.component?.getEditor?.(field) + if (activeEditor?.option) activeEditor.option('value', previous) + else e?.component?.option?.('value', previous) + flush() + } + + const notify = (message: string) => window.alert(tpl(message)) + + /** Onay ister; kullanıcı vazgeçerse alanı eski değerine döndürür. */ + const ask = (message: string) => { + if (window.confirm(tpl(message))) return true + revert() + return false + } + + const openUrl = (url: string, target: string = '_blank') => window.open(tpl(url), target) + + const api = async (url: string, responsePath?: string, options?: Parameters[1]) => { + const response = await fetch(tpl(url), options) + const json = await response.json() + return responsePath ? pickPath(json, responsePath) : json + } + + const helpers = { + value: e?.value, + field, + data, + isReady, + get, + num, + str, + bool, + dateOf: (name: string) => toDate(get(name)), + pick: pickPath, + selected, + snum, + sstr, + set, + clear, + readOnly, + copy, + round, + sum, + days, + hours, + tpl, + notify, + ask, + openUrl, + api, + revert, + } + + return { + flush, + // Sıra HELPER_PARAMS ile birebir aynı olmalı. + args: [ + helpers, + helpers.value, + helpers.field, + helpers.data, + helpers.isReady, + helpers.get, + helpers.num, + helpers.str, + helpers.bool, + helpers.dateOf, + helpers.pick, + helpers.selected, + helpers.snum, + helpers.sstr, + helpers.set, + helpers.clear, + helpers.readOnly, + helpers.copy, + helpers.round, + helpers.sum, + helpers.days, + helpers.hours, + helpers.tpl, + helpers.notify, + helpers.ask, + helpers.openUrl, + helpers.api, + helpers.revert, + ], + } +} + +/* ------------------------------------------------------------------ * + * Derleme (önbellekli) + * ------------------------------------------------------------------ */ + +type CompiledScript = { + run: (...args: any[]) => Promise + /** Yardımcılar enjekte edilebildi mi? Eski script'lerde false olabilir. */ + withHelpers: boolean +} + +const compileCache = new Map() + +const compileScript = (script: string): CompiledScript => { + const cached = compileCache.get(script) + if (cached) return cached + + let compiled: CompiledScript + try { + compiled = { + run: new AsyncFunction(...LEGACY_PARAMS, ...HELPER_PARAMS, script), + withHelpers: true, + } + } catch { + // Script içinde yardımcı adlarıyla çakışan bir tanım var; eski imzayla derle. + compiled = { run: new AsyncFunction(...LEGACY_PARAMS, script), withHelpers: false } + } + + compileCache.set(script, compiled) + return compiled +} + +export const executeEditorScript = (script: string, context: EditorScriptRuntimeContext) => { + const { formData, e, editor, runtimeSetEditorReadOnly, setFormData } = context + const compiled = compileScript(script) + const { args, flush } = buildHelpers(context) + + const baseArgs = [formData, e, editor, runtimeSetEditorReadOnly, setFormData] + const result = compiled.run(...baseArgs, ...(compiled.withHelpers ? args : [])) + + // Senkron script'lerde tüm `set` çağrıları bu noktada tamamlanmıştır. + flush() + + return result + .catch((error: unknown) => { + console.error('Editor script error:', error) + }) + .finally(flush) } 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 index 6c6d5020..dc542e4d 100644 --- a/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx +++ b/ui/src/views/admin/listForm/edit/json-row-operations/EditorOptionsBuilderDialog.tsx @@ -307,13 +307,19 @@ function EditorOptionsBuilderDialog({ preventScroll isOpen={isOpen} width={1360} - height="90vh" contentClassName="flex flex-col" style={{ overlay: { zIndex: 1300 } }} onClose={onClose} onRequestClose={onClose} > - + {/* + Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca + react-modal içeriğine uygulanıyor (iç panel `height: auto`), ve + `.dialog-body` global olarak `flex-1` alıyor — flex-basis 0% olduğu için + `height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp + yüksekliği doğrudan veriyoruz. + */} + {/* ── Başlık ─────────────────────────────────────────────── */}
@@ -436,7 +442,9 @@ function EditorOptionsBuilderDialog({
-
+ {/* flex-1 olmadan bu ızgara içeriğe göre büzülür ve sağdaki JSON + önizlemesi yüksekliğini kaybeder. */} +
{/* ── Ayar bölümleri ─────────────────────────────────── */}
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 index de371e9e..ff69407c 100644 --- a/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx +++ b/ui/src/views/admin/listForm/edit/json-row-operations/EditorScriptBuilderDialog.tsx @@ -3,45 +3,47 @@ import { SelectBoxOption } from '@/types/shared' import { useLocalization } from '@/utils/hooks/useLocalization' import Editor from '@monaco-editor/react' import { useEffect, useMemo, useState } from 'react' -import { FaCheck, FaCode, FaMagic, FaPlus, FaTimes, FaTrash } from 'react-icons/fa' - -type CopyMapping = { - id: string - source: string - target: string -} - -type ConditionalAction = { - id: string - source: string - operator: - | 'always' - | 'equals' - | 'notEquals' - | 'empty' - | 'notEmpty' - | 'contains' - | 'greaterThan' - | 'lessThan' - value: string - actionType: - | 'setField' - | 'apiToField' - | 'openUrl' - | 'alert' - | 'confirm' - | 'calculate' - | 'setReadOnly' - targetField: string - textValue: string - booleanValue: 'true' | 'false' - apiUrl: string - apiMethod: 'GET' | 'POST' - responsePath: string - urlTarget: '_blank' | '_self' - formula: string - message: string -} +import type { IconType } from 'react-icons' +import { + FaArrowDown, + FaArrowUp, + FaBell, + FaBook, + FaCalculator, + FaCheck, + FaChevronDown, + FaChevronRight, + FaCode, + FaCopy, + FaDatabase, + FaExclamationTriangle, + FaEye, + FaMagic, + FaPlug, + FaPlus, + FaTimes, + FaTrash, +} from 'react-icons/fa' +import { + buildRuleLine, + buildScriptFromRules, + createRule, + describeCondition, + effectiveTrigger, + isRuleComplete, + parseScript, + recipeGroups, + recipes, + recipesById, + runtimeApiReference, + scriptRunsOnOpen, + triggerLabels, + type ConditionOperator, + type RecipeGroupKey, + type RecipeParam, + type RuleTrigger, + type ScriptRule, +} from './editor-script/scriptRecipes' type EditorScriptBuilderDialogProps = { isOpen: boolean @@ -52,485 +54,35 @@ type EditorScriptBuilderDialogProps = { 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 controlClass = + 'w-full min-w-0 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 fieldLabelClass = - 'flex flex-col gap-1 text-xs font-medium text-gray-500 dark:text-gray-400' +const cardClass = + 'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900' -const actionIconCellClass = - 'col-span-12 flex h-9 items-center justify-end md:col-span-1 md:justify-center' - -const makeId = () => `${Date.now()}_${Math.random().toString(36).slice(2)}` - -const formatScriptFallback = (source: string) => { - const trimmed = source.trim() - if (!trimmed) return '' - - let indent = 0 - return trimmed - .replace(/\s*;\s*/g, ';\n') - .replace(/\s*\{\s*/g, ' {\n') - .replace(/\s*\}\s*/g, '\n}\n') - .replace(/\n\s*\n/g, '\n') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - if (line.startsWith('}')) { - indent = Math.max(0, indent - 1) - } - - const formattedLine = `${' '.repeat(indent)}${line}` - - if (line.endsWith('{')) { - indent += 1 - } - - return formattedLine - }) - .join('\n') +const groupIcons: Record = { + calc: FaCalculator, + data: FaDatabase, + view: FaEye, + interaction: FaBell, + integration: FaPlug, } -const createConditionalAction = (): ConditionalAction => ({ - id: makeId(), - source: '', - operator: 'always', - value: '', - actionType: 'setField', - targetField: '', - textValue: '', - booleanValue: 'true', - apiUrl: '', - apiMethod: 'GET', - responsePath: '', - urlTarget: '_blank', - formula: '', - message: '', -}) +const operatorLabels: { value: ConditionOperator; label: string }[] = [ + { value: 'always', label: 'Her zaman' }, + { value: 'equals', label: 'Eşitse' }, + { value: 'notEquals', label: 'Eşit değilse' }, + { value: 'contains', label: 'İçeriyorsa' }, + { value: 'empty', label: 'Boşsa' }, + { value: 'notEmpty', label: 'Doluysa' }, + { value: 'greaterThan', label: 'Büyükse' }, + { value: 'lessThan', label: 'Küçükse' }, + { value: 'isTrue', label: 'İşaretliyse' }, + { value: 'isFalse', label: 'İşaretli değilse' }, +] -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 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']) { - return operator !== 'always' -} - -function conditionNeedsValue(operator: ConditionalAction['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) - 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()) - if (action.actionType === 'setReadOnly') return Boolean(action.targetField) - return false -} - -function fieldLabel(value: string, fallback: string) { - return value || fallback -} - -type BuildScriptArgs = { - currentField?: string - copyMappings: CopyMapping[] - 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 -} - -function buildScript(args: BuildScriptArgs) { - const { - currentField, - copyMappings, - daysStartField, - daysEndField, - daysTargetField, - selectedItemAmountEnabled, - rowAmountEnabled, - amountQuantityField, - amountUnitPriceField, - amountUomField, - amountTotalField, - timeDiffEnabled, - timeStartField, - timeEndField, - timeTargetField, - conditionalActions, - serviceCall, - } = args - const activeCopyMappings = copyMappings.filter((mapping) => mapping.source && mapping.target) - const hasCopyMappings = activeCopyMappings.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 || - hasDateDifference || - hasSelectedItemAmount || - hasRowAmount || - hasTimeDifference || - hasConditionalActions || - hasServiceCall - - if (!hasBuilderSelection) { - return '' - } - - const hasNonConditionalSelection = - hasCopyMappings || - hasDateDifference || - hasSelectedItemAmount || - hasRowAmount || - hasTimeDifference || - hasServiceCall - - if ( - hasConditionalActions && - (activeConditionalActions.length > 1 || hasNonConditionalSelection) - ) { - const scriptBlocks: string[] = [] - - if (hasNonConditionalSelection) { - scriptBlocks.push( - buildScript({ - ...args, - conditionalActions: [], - }), - ) - } - - activeConditionalActions.forEach((action) => { - scriptBlocks.push( - buildScript({ - ...args, - copyMappings: [], - daysStartField: '', - daysEndField: '', - daysTargetField: '', - selectedItemAmountEnabled: false, - rowAmountEnabled: false, - amountQuantityField: '', - amountUnitPriceField: '', - amountUomField: '', - amountTotalField: '', - timeDiffEnabled: false, - timeStartField: '', - timeEndField: '', - timeTargetField: '', - conditionalActions: [action], - serviceCall: '', - }), - ) - }) - - return scriptBlocks.filter(Boolean).join('\n\n') - } - - const needsSetFormData = - hasCopyMappings || - hasDateDifference || - hasSelectedItemAmount || - hasRowAmount || - hasTimeDifference || - activeConditionalActions.some((action) => - ['setField', 'apiToField', 'calculate'].includes(action.actionType), - ) - const needsSelectedItem = - hasCopyMappings || - hasSelectedItemAmount || - activeConditionalActions.some((action) => - [ - 'calculate', - 'setField', - 'apiToField', - 'openUrl', - 'alert', - 'confirm', - ].includes(action.actionType), - ) - const needsGetByPath = - activeCopyMappings.some((mapping) => mapping.source.includes('.')) || - activeConditionalActions.some( - (action) => - action.actionType === 'apiToField' || - ['setField', 'openUrl', 'alert', 'confirm'].includes(action.actionType), - ) - const needsRenderTemplate = activeConditionalActions.some((action) => - ['setField', 'apiToField', 'openUrl', 'alert', 'confirm'].includes( - action.actionType, - ), - ) - const needsRollbackCurrentValue = activeConditionalActions.some( - (action) => action.actionType === 'confirm', - ) - const needsSetEditorReadOnly = activeConditionalActions.some( - (action) => action.actionType === 'setReadOnly', - ) - - const lines: string[] = ['(async () => {'] - - if (needsSetFormData || hasConditionalActions || hasServiceCall) { - lines.push( - " const currentField = (typeof editor !== 'undefined' && editor?.dataField) || e?.dataField || " + - quote(currentField || '') + - ';', - ) - lines.push(' const next = { ...formData, [currentField]: e?.value };') - lines.push(' const isEditorScriptContentReady = e?.scriptEvent === "contentReady";') - } - - if (needsRollbackCurrentValue) { - lines.push( - ' const rollbackCurrentValue = () => { const previousFieldValue = typeof previousValue !== "undefined" ? previousValue : (e?.previousValue ?? formData?.[currentField] ?? null); next[currentField] = previousFieldValue; const currentEditor = e?.component?.getEditor?.(currentField); currentEditor?.option?.("value", previousFieldValue); if (!currentEditor) e?.component?.option?.("value", previousFieldValue); if (typeof setFormData === "function") setFormData({ ...next, [currentField]: previousFieldValue }); };', - ) - } - - if (needsSelectedItem) { - lines.push( - ' const selectedItem = (() => { try { return e?.component?.option ? e.component.option("selectedItem") : null; } catch { return 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 templateOpen = String.fromCharCode(123);', - ' const templateClose = String.fromCharCode(125);', - ' const renderTemplate = text => String(text || "").replaceAll(templateOpen + "value" + templateClose, e?.value ?? "").replace(new RegExp("\\\\" + templateOpen + "selected\\\\.([^" + templateClose + "]+)\\\\" + templateClose, "g"), (_, key) => getByPath(selectedItem, key) ?? "").replace(new RegExp("\\\\" + templateOpen + "([^" + templateClose + "]+)\\\\" + templateClose, "g"), (_, key) => next[key] ?? "");', - ) - } - - if (needsSetEditorReadOnly) { - lines.push( - ' const setEditorReadOnly = (field, readOnly) => { if (typeof runtimeSetEditorReadOnly === "function") runtimeSetEditorReadOnly(field, readOnly); };', - ) - } - - activeCopyMappings.forEach((mapping) => { - const source = mapping.source.includes('.') - ? `getByPath(selectedItem, ${quote(mapping.source)})` - : `selectedItem[${quote(mapping.source)}]` - lines.push( - ` if (!isEditorScriptContentReady) next[${quote( - mapping.target, - )}] = selectedItem ? ${source} : next[${quote(mapping.target)}];`, - ) - }) - - if ( - selectedItemAmountEnabled && - amountQuantityField && - amountUnitPriceField && - amountTotalField - ) { - lines.push(' {') - lines.push(' if (!isEditorScriptContentReady) {') - 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(' }') - lines.push(' }') - } - - if (rowAmountEnabled && amountQuantityField && amountUnitPriceField && amountTotalField) { - lines.push(' {') - lines.push(' if (!isEditorScriptContentReady) {') - 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(' }') - 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( - ` if (!isEditorScriptContentReady) 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(' if (!isEditorScriptContentReady) {') - 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(' }') - lines.push(' }') - } - - activeConditionalActions.forEach((action) => { - const condition = buildGenericCondition(action) - - if (action.actionType === 'setReadOnly' && action.targetField) { - const enabledValue = action.booleanValue === 'true' - const readOnlyExpression = enabledValue ? condition : `!(${condition})` - lines.push( - ` setEditorReadOnly(${quote(action.targetField)}, ${readOnlyExpression});`, - ) - return - } - - lines.push(` if (!isEditorScriptContentReady && (${condition})) {`) - - if (action.actionType === 'setField' && action.targetField) { - lines.push( - ` next[${quote(action.targetField)}] = 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, - )}))) { rollbackCurrentValue(); 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( - ' if (!isEditorScriptContentReady && typeof setFormData === "function") setFormData(next);', - ) - } - - if (serviceCall.trim()) { - lines.push(` if (!isEditorScriptContentReady) ${serviceCall.trim().replace(/;?$/, ';')}`) - } - - lines.push('})();') - return lines.join('\n') -} +const operatorNeedsValue = (operator: ConditionOperator) => + ['equals', 'notEquals', 'contains', 'greaterThan', 'lessThan'].includes(operator) function EditorScriptBuilderDialog({ isOpen, @@ -540,800 +92,490 @@ function EditorScriptBuilderDialog({ onClose, onApply, }: EditorScriptBuilderDialogProps) { - const availableFields = useMemo(() => fieldOptions(fields), [fields]) - const [copyMappings, setCopyMappings] = 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 [scriptEditorValue, setScriptEditorValue] = useState('') - const [lastGeneratedScript, setLastGeneratedScript] = useState('') const { translate } = useLocalization() - + + const availableFields = useMemo( + () => + Array.from( + new Set(fields.map((field) => String(field.value || field.label || '')).filter(Boolean)), + ).sort((left, right) => left.localeCompare(right)), + [fields], + ) + + const [rules, setRules] = useState([]) + const [manualScript, setManualScript] = useState('') + const [mode, setMode] = useState<'rules' | 'manual'>('rules') + const [openRules, setOpenRules] = useState>({}) + const [paletteOpen, setPaletteOpen] = useState(true) + const [helpOpen, setHelpOpen] = useState(false) + const [copied, setCopied] = useState(false) + useEffect(() => { if (!isOpen) return - setCopyMappings([]) - setDaysStartField('') - setDaysEndField('') - setDaysTargetField('') - setSelectedItemAmountEnabled(false) - setRowAmountEnabled(false) - setAmountQuantityField('') - setAmountUnitPriceField('') - setAmountUomField('') - setAmountTotalField('') - setTimeDiffEnabled(false) - setTimeStartField('') - setTimeEndField('') - setTimeTargetField('') - setConditionalActions([]) - setServiceCall('') - setScriptEditorValue(value?.trim() || '') - setLastGeneratedScript('') + + const parsed = parseScript(value) + const existing = value?.trim() ?? '' + + setRules(parsed.rules) + setManualScript(existing) + setCopied(false) + setHelpOpen(false) + setPaletteOpen(parsed.rules.length === 0) + setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false]))) + // Elle yazılmış ya da builder dışında değiştirilmiş script'i kurallara + // zorlamıyoruz; kullanıcı kendisi geçmedikçe olduğu gibi korunur. + setMode(!existing || parsed.inSync ? 'rules' : 'manual') }, [isOpen, value]) - const findField = (fieldName: string) => - availableFields.find((field) => field.toLowerCase() === fieldName.toLowerCase()) || '' + const generatedScript = useMemo(() => buildScriptFromRules(rules), [rules]) + const runsOnOpen = useMemo(() => scriptRunsOnOpen(rules.filter(isRuleComplete)), [rules]) + const activeScript = mode === 'rules' ? generatedScript : manualScript + const completeRuleCount = rules.filter(isRuleComplete).length + const manualDiffers = manualScript.trim() !== '' && manualScript.trim() !== generatedScript.trim() - const formatScriptPreview = () => { - setScriptEditorValue((current) => formatScriptFallback(current)) - } + const updateRule = (id: string, patch: Partial) => + setRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule))) - const fillAmountDefaults = () => { - setAmountQuantityField((current) => current || findField('Quantity')) - setAmountUnitPriceField((current) => current || findField('UnitPrice')) - setAmountUomField((current) => current || findField('UomId')) - setAmountTotalField((current) => current || findField('TotalAmount')) - } + const updateParam = (id: string, key: string, next: string) => + setRules((current) => + current.map((rule) => + rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule, + ), + ) - const resetBuilderSelections = () => { - setCopyMappings([]) - setDaysStartField('') - setDaysEndField('') - setDaysTargetField('') - setSelectedItemAmountEnabled(false) - setRowAmountEnabled(false) - setAmountQuantityField('') - setAmountUnitPriceField('') - setAmountUomField('') - setAmountTotalField('') - setTimeDiffEnabled(false) - setTimeStartField('') - setTimeEndField('') - setTimeTargetField('') - setConditionalActions([]) - setServiceCall('') - } - - 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) { - setScriptEditorValue('') - return - } - - const hydrated = hydrateKnownScript(existingScript) - setScriptEditorValue(hydrated ? '' : existingScript) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [availableFields, isOpen, value]) - - const generatedScript = useMemo( - () => - buildScript({ - currentField, - copyMappings, - daysStartField, - daysEndField, - daysTargetField, - selectedItemAmountEnabled, - rowAmountEnabled, - amountQuantityField, - amountUnitPriceField, - amountUomField, - amountTotalField, - timeDiffEnabled, - timeStartField, - timeEndField, - timeTargetField, - conditionalActions, - serviceCall, - }), - [ - amountQuantityField, - amountTotalField, - amountUnitPriceField, - amountUomField, - conditionalActions, - copyMappings, - currentField, - daysEndField, - daysStartField, - daysTargetField, - rowAmountEnabled, - selectedItemAmountEnabled, - serviceCall, - timeDiffEnabled, - timeEndField, - timeStartField, - timeTargetField, - ], - ) - - const formattedGeneratedScript = useMemo( - () => formatScriptFallback(generatedScript), - [generatedScript], - ) - - const canAppendGeneratedScript = Boolean(formattedGeneratedScript.trim()) - - const appendGeneratedScriptToEditor = () => { - const nextScriptBlock = formattedGeneratedScript.trim() - if (!nextScriptBlock) return - - setScriptEditorValue((current) => { - const existingScript = current.trim() - if (existingScript === lastGeneratedScript.trim()) return nextScriptBlock - return existingScript ? `${existingScript}\n\n${nextScriptBlock}` : nextScriptBlock + const moveRule = (index: number, delta: number) => + setRules((current) => { + const target = index + delta + if (target < 0 || target >= current.length) return current + const next = [...current] + const [moved] = next.splice(index, 1) + next.splice(target, 0, moved) + return next }) - setLastGeneratedScript('') - resetBuilderSelections() + + const addRule = (recipeId: ScriptRule['recipe']) => { + const rule = createRule(recipeId) + setRules((current) => [...current, rule]) + setOpenRules((current) => ({ ...current, [rule.id]: true })) } - useEffect(() => { - if (!isOpen) return - setScriptEditorValue((current) => { - if (current === lastGeneratedScript || !current.trim()) return formattedGeneratedScript - return current - }) - setLastGeneratedScript(formattedGeneratedScript) - }, [formattedGeneratedScript, isOpen, lastGeneratedScript]) + const copyScript = () => { + navigator.clipboard?.writeText(activeScript) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } - const renderFieldSelect = (value: string, onChange: (value: string) => void) => ( + const renderFieldSelect = ( + selectedValue: string, + onChange: (next: string) => void, + placeholder = 'Alan seç', + ) => ( ) - const updateConditionalAction = (id: string, patch: Partial) => { - setConditionalActions((current) => - current.map((action) => (action.id === id ? { ...action, ...patch } : action)), + const renderParam = (rule: ScriptRule, param: RecipeParam) => { + if (param.type === 'fieldList') { + const selectedFields = rule.fields ?? [] + return ( +
+ {param.label} +
+ {selectedFields.map((fieldName) => ( + + {fieldName} + + + ))} + +
+
+ ) + } + + const currentValue = rule.params[param.key] ?? '' + + return ( + ) } - const renderLabeledFieldSelect = ( - label: string, - value: string, - onChange: (value: string) => void, - ) => ( - - ) + const renderRule = (rule: ScriptRule, index: number) => { + const recipe = recipesById.get(rule.recipe) + if (!recipe) return null - const needsCompareValue = (operator: ConditionalAction['operator']) => - operator === 'equals' || - operator === 'notEquals' || - operator === 'contains' || - operator === 'greaterThan' || - operator === 'lessThan' + const open = openRules[rule.id] ?? true + const complete = isRuleComplete(rule) + const Icon = groupIcons[recipe.group] - const describeCopyMapping = (mapping: CopyMapping) => - `Seçili kayıttaki ${fieldLabel(mapping.source, 'kolon/path')} değerini ${fieldLabel( - mapping.target, - 'hedef field', - )} alanına yaz.` + return ( +
+
+ + +
+
+ {index + 1}. {recipe.label} +
+
+ {recipe.conditionIsArgument + ? recipe.describe(rule) + : `${describeCondition(rule.condition)} → ${recipe.describe(rule)}`} +
+
+ {effectiveTrigger(rule) !== 'change' && ( + + {effectiveTrigger(rule) === 'open' ? 'açılışta' : 'açılış + değişim'} + + )} + {!complete && ( + + eksik + + )} +
+
+
- 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') + {open && ( +
+

{recipe.summary}

- if (action.actionType === 'setField') { - return `${condition}, ${target} alanına ${action.textValue || 'değer'} yaz.` - } - 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.` - } - if (action.actionType === 'setReadOnly') { - return `${condition}, ${target} alanını readOnly ${action.booleanValue} yap.` - } - return condition +
+ + + + + {rule.condition.operator !== 'always' && ( + + )} + + {operatorNeedsValue(rule.condition.operator) && ( + + )} +
+ +
+ {recipe.params.map((param) => renderParam(rule, param))} +
+ +
+              {buildRuleLine(rule, runsOnOpen) || recipe.example}
+            
+
+ )} +
+ ) } return ( - -
-
+ {/* ── Başlık ─────────────────────────────────────────────── */} +
+ +
Editor Script
+ + {currentField || 'alan seçilmemiş'} + + - Editor Script Builder -
+ {completeRuleCount}/{rules.length} kural + + +
+ +
+ + +
+
-
-
-
-
-
-
- 1. Seçili Kaydın Değerlerini Kopyala -
+ {mode === 'rules' && manualDiffers && ( +
+ + + Kayıtlı script kurallardan farklı. Kurallar sekmesinde kaydedersen elle yazılmış hali + değişir. + + +
+ )} + + {helpOpen && ( +
+
Script içinde kullanabileceklerin
+
+ {runtimeApiReference.map((item) => ( +
+ + {item.signature} + + {item.description}
- -
-
- {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', - 'apiToField', - 'calculate', - 'setReadOnly', - ].includes(action.actionType) && ( -
- {renderLabeledFieldSelect('Hedef field', action.targetField, (value) => - updateConditionalAction(action.id, { targetField: value }), - )} -
- )} - - {action.actionType === 'setField' && ( - - )} - - {action.actionType === 'setReadOnly' && ( - - )} - - {action.actionType === 'apiToField' && ( - <> - - - - - )} - - {action.actionType === 'openUrl' && ( - <> - - - - )} - - {(action.actionType === 'alert' || action.actionType === 'confirm') && ( - - )} - - {action.actionType === 'calculate' && ( - - )} -
-
- ))} -
-
- -
-
- 3. Tarih ve Saat Farkı -
-
- {renderLabeledFieldSelect('Başlangıç tarihi', daysStartField, setDaysStartField)} - {renderLabeledFieldSelect('Bitiş tarihi', daysEndField, setDaysEndField)} - {renderLabeledFieldSelect('Sonuç alanı', daysTargetField, setDaysTargetField)} -
- -
- {renderLabeledFieldSelect('Başlangıç saati', timeStartField, setTimeStartField)} - {renderLabeledFieldSelect('Bitiş saati', timeEndField, setTimeEndField)} - {renderLabeledFieldSelect('Sonuç alanı', timeTargetField, setTimeTargetField)} -
-
- -
-
- 4. Tutar Hesaplamaları -
-
- -
-
- {renderLabeledFieldSelect( - 'Miktar alanı', - amountQuantityField, - setAmountQuantityField, - )} - {renderLabeledFieldSelect( - 'Birim fiyat alanı', - amountUnitPriceField, - setAmountUnitPriceField, - )} - {renderLabeledFieldSelect('Birim alanı', amountUomField, setAmountUomField)} - {renderLabeledFieldSelect('Toplam alanı', amountTotalField, setAmountTotalField)} -
-
- -
-
- 5. Servis Çağrısı -
- setServiceCall(event.target.value)} - /> -
-
- -
-
- - - Script Önizleme + {mode === 'manual' ? ( +
+
+ + Tek satırlık yardımcılarla yaz. Eski uzun script'ler de çalışmaya devam eder.
- - TypeScript - - +
-
+
setScriptEditorValue(nextValue || '')} + onChange={(next) => setManualScript(next || '')} />
-
-
+
+ ) : ( + // flex-1 olmadan bu ızgara içeriğe göre büzülür ve sağdaki Monaco + // (height="100%") yüksekliğini kaybeder. +
+ {/* ── Kurallar ─────────────────────────────────────────── */} +
+
+ + {paletteOpen && ( +
+ {recipeGroups.map((group) => { + const Icon = groupIcons[group.key] + return ( +
+
+ {group.title} +
+
+ {recipes + .filter((recipe) => recipe.group === group.key) + .map((recipe) => ( + + ))} +
+
+ ) + })} +
+ )} +
+ + {rules.map(renderRule)} + + {!rules.length && ( +
+ Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir. +
+ )} +
+ + {/* ── Önizleme ─────────────────────────────────────────── */} +
+
+ + + Script Önizleme + +
+ + {generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'} + +
+
+
+ +
+

+ İlk satırdaki {'// @builder'} yorumu kuralları saklar; dialog yeniden + açıldığında kurallar buradan geri yüklenir. +

+
+
+ )} - + + @@ -1358,7 +704,7 @@ function EditorScriptBuilderDialog({ variant="solid" icon={} onClick={() => { - onApply(scriptEditorValue) + onApply(activeScript) onClose() }} > diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts new file mode 100644 index 00000000..011ba98c --- /dev/null +++ b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts @@ -0,0 +1,635 @@ +/** + * Editor Script Builder'ın veri sözlüğü. + * + * Her tarif (recipe) tek satırlık bir script üretir. Üretilen script'in ilk + * satırındaki `// @builder {...}` başlığı kuralları saklar; dialog yeniden + * açıldığında script'i regex ile çözmeye çalışmak yerine bu başlıktan + * kuralları geri okur. + */ + +export type ConditionOperator = + | 'always' + | 'equals' + | 'notEquals' + | 'contains' + | 'empty' + | 'notEmpty' + | 'greaterThan' + | 'lessThan' + | 'isTrue' + | 'isFalse' + +export type RuleCondition = { + operator: ConditionOperator + source: string + value: string +} + +/** + * Kuralın ne zaman çalışacağı. + * - `change`: yalnızca alanın değeri değiştiğinde (varsayılan) + * - `open`: yalnızca form açılırken — varsayılan değer üretmek için + * - `both`: her ikisinde + */ +export type RuleTrigger = 'change' | 'open' | 'both' + +export const triggerLabels: { value: RuleTrigger; label: string; help: string }[] = [ + { value: 'change', label: 'Değiştiğinde', help: 'Yalnızca bu alanın değeri değişince çalışır.' }, + { + value: 'open', + label: 'Form açılırken', + help: 'Popup edit form açıldığında bir kez çalışır. Varsayılan değer üretmek için.', + }, + { value: 'both', label: 'Her ikisi', help: 'Hem açılışta hem değer değişiminde çalışır.' }, +] + +export type ScriptRule = { + id: string + recipe: RecipeId + condition: RuleCondition + /** Tarife göre anlamı değişen serbest parametreler. */ + params: Record + /** Çoklu alan alan tarifler için (sum, clear). */ + fields?: string[] + /** Belirtilmezse tarifin varsayılanı kullanılır. */ + trigger?: RuleTrigger +} + +export type RecipeParam = { + key: string + label: string + /** `field` seçilebilir sütun listesinden gelir, diğerleri serbest metindir. */ + type: 'field' | 'text' | 'number' | 'select' | 'fieldList' + placeholder?: string + help?: string + choices?: { value: string; label: string }[] + optional?: boolean +} + +export type RecipeGroupKey = 'calc' | 'data' | 'view' | 'interaction' | 'integration' + +export type Recipe = { + id: RecipeId + label: string + group: RecipeGroupKey + summary: string + /** Koşul bu tarifte doğrudan aksiyonun argümanı olur (if sarmalanmaz). */ + conditionIsArgument?: boolean + params: RecipeParam[] + build: (rule: ScriptRule) => string + describe: (rule: ScriptRule) => string + example: string +} + +export type RecipeId = + | 'multiply' + | 'sum' + | 'formula' + | 'days' + | 'hours' + | 'copy' + | 'setValue' + | 'clear' + | 'readOnly' + | 'notify' + | 'ask' + | 'openUrl' + | 'apiToField' + | 'custom' + +export const recipeGroups: { key: RecipeGroupKey; title: string; description: string }[] = [ + { key: 'calc', title: 'Hesaplama', description: 'Alanlardan yeni bir değer üretir.' }, + { key: 'data', title: 'Veri', description: 'Alanlara değer yazar, kopyalar veya temizler.' }, + { key: 'view', title: 'Görünüm', description: 'Alanların düzenlenebilirliğini yönetir.' }, + { key: 'interaction', title: 'Etkileşim', description: 'Kullanıcıya mesaj gösterir, sayfa açar.' }, + { key: 'integration', title: 'Entegrasyon', description: 'API ve servis çağrıları, serbest kod.' }, +] + +/* ------------------------------------------------------------------ * + * Yardımcılar + * ------------------------------------------------------------------ */ + +const q = (input: string) => JSON.stringify(input ?? '') + +const label = (input: string, fallback: string) => input || fallback + +const isNumericLiteral = (input: string) => /^-?\d+(\.\d+)?$/.test(String(input).trim()) + +/** Koşulu runtime yardımcılarıyla tek satırlık bir ifadeye çevirir. */ +export const buildConditionExpression = (condition: RuleCondition): string => { + const { operator, source, value } = condition + if (operator === 'always' || !source) return '' + + switch (operator) { + case 'empty': + return `!get(${q(source)})` + case 'notEmpty': + return `!!get(${q(source)})` + case 'isTrue': + return `bool(${q(source)})` + case 'isFalse': + return `!bool(${q(source)})` + case 'contains': + return `str(${q(source)}).includes(${q(value)})` + case 'greaterThan': + return `num(${q(source)}) > ${isNumericLiteral(value) ? value : q(value)}` + case 'lessThan': + return `num(${q(source)}) < ${isNumericLiteral(value) ? value : q(value)}` + case 'notEquals': + return `str(${q(source)}) !== ${q(value)}` + default: + return `str(${q(source)}) === ${q(value)}` + } +} + +export const describeCondition = (condition: RuleCondition): string => { + if (condition.operator === 'always' || !condition.source) return 'Her zaman' + const source = label(condition.source, 'alan') + switch (condition.operator) { + case 'empty': + return `${source} boşsa` + case 'notEmpty': + return `${source} doluysa` + case 'isTrue': + return `${source} işaretliyse` + case 'isFalse': + return `${source} işaretli değilse` + case 'contains': + return `${source} içinde "${label(condition.value, 'değer')}" geçiyorsa` + case 'greaterThan': + return `${source} > ${label(condition.value, 'değer')} ise` + case 'lessThan': + return `${source} < ${label(condition.value, 'değer')} ise` + case 'notEquals': + return `${source} ≠ "${label(condition.value, 'değer')}" ise` + default: + return `${source} = "${label(condition.value, 'değer')}" ise` + } +} + +const numberOrField = (raw: string) => (isNumericLiteral(raw) ? raw : `num(${q(raw)})`) + +/* ------------------------------------------------------------------ * + * Tarifler + * ------------------------------------------------------------------ */ + +export const recipes: Recipe[] = [ + { + id: 'multiply', + label: 'Çarpım (miktar × fiyat)', + group: 'calc', + summary: 'İki alanı çarpar, yuvarlar ve hedefe yazar.', + example: "set('TotalAmount', round(num('Quantity') * num('UnitPrice'), 2))", + params: [ + { key: 'left', label: 'Birinci alan', type: 'field' }, + { key: 'right', label: 'İkinci alan', type: 'field' }, + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + { + key: 'digits', + label: 'Ondalık', + type: 'number', + optional: true, + placeholder: '2', + help: 'Boş bırakılırsa 2 kullanılır.', + }, + ], + build: ({ params }) => + `set(${q(params.target)}, round(${numberOrField(params.left)} * ${numberOrField( + params.right, + )}, ${params.digits || 2}))`, + describe: ({ params }) => + `${label(params.left, 'alan')} × ${label(params.right, 'alan')} sonucunu ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'sum', + label: 'Toplama', + group: 'calc', + summary: 'Seçilen alanları toplar ve hedefe yazar.', + example: "set('Total', round(sum('Net', 'Tax'), 2))", + params: [ + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + { key: 'sources', label: 'Toplanacak alanlar', type: 'fieldList' }, + { key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' }, + ], + build: ({ params, fields }) => + `set(${q(params.target)}, round(sum(${(fields ?? []).map(q).join(', ')}), ${ + params.digits || 2 + }))`, + describe: ({ params, fields }) => + `${(fields ?? []).join(' + ') || 'alanlar'} toplamını ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'formula', + label: 'Serbest formül', + group: 'calc', + summary: 'JavaScript ifadesi yazarsın; num/str/get/selected kullanabilirsin.', + example: "set('Net', num('Gross') - num('Discount'))", + params: [ + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + { + key: 'expression', + label: 'İfade', + type: 'text', + placeholder: "num('Gross') * 0.18", + help: "Kullanılabilir: num('X'), str('X'), get('X'), value, selected('Yol'), round(x, 2)", + }, + ], + build: ({ params }) => `set(${q(params.target)}, ${params.expression || 'null'})`, + describe: ({ params }) => + `${label(params.expression, 'ifade')} sonucunu ${label(params.target, 'hedef')} alanına yaz.`, + }, + { + id: 'days', + label: 'Gün farkı', + group: 'calc', + summary: 'İki tarih arasındaki gün sayısını (bitiş dahil) yazar.', + example: "set('TotalDays', days('StartDate', 'EndDate'))", + params: [ + { key: 'start', label: 'Başlangıç tarihi', type: 'field' }, + { key: 'end', label: 'Bitiş tarihi', type: 'field' }, + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + ], + build: ({ params }) => + `set(${q(params.target)}, days(${q(params.start)}, ${q(params.end)}))`, + describe: ({ params }) => + `${label(params.start, 'başlangıç')} → ${label(params.end, 'bitiş')} gün farkını ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'hours', + label: 'Saat farkı', + group: 'calc', + summary: 'İki saat arasındaki farkı yazar; bitiş küçükse ertesi güne taşar.', + example: "set('TotalHours', hours('StartTime', 'EndTime'))", + params: [ + { key: 'start', label: 'Başlangıç saati', type: 'field' }, + { key: 'end', label: 'Bitiş saati', type: 'field' }, + { key: 'target', label: 'Sonuç alanı', type: 'field' }, + ], + build: ({ params }) => + `set(${q(params.target)}, hours(${q(params.start)}, ${q(params.end)}))`, + describe: ({ params }) => + `${label(params.start, 'başlangıç')} → ${label(params.end, 'bitiş')} saat farkını ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'copy', + label: 'Seçili kayıttan kopyala', + group: 'data', + summary: 'Lookup/GridBox ile seçilen kaydın bir kolonunu form alanına taşır.', + example: "copy('unitPrice', 'UnitPrice')", + params: [ + { + key: 'source', + label: 'Seçili kayıttaki kolon/yol', + type: 'text', + placeholder: 'unitPrice veya Customer.Name', + }, + { key: 'target', label: 'Hedef alan', type: 'field' }, + ], + build: ({ params }) => `copy(${q(params.source)}, ${q(params.target)})`, + describe: ({ params }) => + `Seçili kayıttaki ${label(params.source, 'kolon')} değerini ${label( + params.target, + 'hedef', + )} alanına kopyala.`, + }, + { + id: 'setValue', + label: 'Değer yaz', + group: 'data', + summary: 'Sabit metin veya {Alan} / {value} / {selected.Yol} token’ları yazar.', + example: "set('Aciklama', tpl('{Musteri} - {value}'))", + params: [ + { key: 'target', label: 'Hedef alan', type: 'field' }, + { + key: 'text', + label: 'Değer', + type: 'text', + placeholder: '{Musteri} - {value}', + help: 'Token kullanabilirsin: {AlanAdi}, {value}, {selected.Name}', + }, + ], + build: ({ params }) => `set(${q(params.target)}, tpl(${q(params.text)}))`, + describe: ({ params }) => + `${label(params.target, 'hedef')} alanına "${label(params.text, 'değer')}" yaz.`, + }, + { + id: 'clear', + label: 'Alanları temizle', + group: 'data', + summary: 'Seçilen alanları null yapar.', + example: "clear('Il', 'Ilce')", + params: [{ key: 'sources', label: 'Temizlenecek alanlar', type: 'fieldList' }], + build: ({ fields }) => `clear(${(fields ?? []).map(q).join(', ')})`, + describe: ({ fields }) => `${(fields ?? []).join(', ') || 'alanları'} temizle.`, + }, + { + id: 'readOnly', + label: 'Salt okunur yap', + group: 'view', + summary: 'Koşul sağlandığında alanı kilitler, sağlanmadığında açar.', + conditionIsArgument: true, + example: "readOnly('SatisSebebi', str('Durum') === 'Aktif')", + params: [ + { key: 'target', label: 'Kilitlenecek alan', type: 'field' }, + { + key: 'invert', + label: 'Tersine çevir', + type: 'select', + optional: true, + help: 'Evet seçilirse koşul sağlandığında alan açılır.', + choices: [ + { value: '', label: 'Hayır' }, + { value: 'true', label: 'Evet' }, + ], + }, + ], + build: ({ params, condition }) => { + const expression = buildConditionExpression(condition) || 'true' + const flag = params.invert === 'true' ? `!(${expression})` : expression + return `readOnly(${q(params.target)}, ${flag})` + }, + describe: ({ params, condition }) => + `${describeCondition(condition)} ${label(params.target, 'alan')} alanını ${ + params.invert === 'true' ? 'düzenlenebilir' : 'salt okunur' + } yap.`, + }, + { + id: 'notify', + label: 'Uyarı göster', + group: 'interaction', + summary: 'Kullanıcıya bilgi mesajı gösterir.', + example: "notify('Limit aşıldı')", + params: [ + { key: 'message', label: 'Mesaj', type: 'text', placeholder: '{Musteri} limiti aşıldı' }, + ], + build: ({ params }) => `notify(${q(params.message)})`, + describe: ({ params }) => `"${label(params.message, 'mesaj')}" uyarısını göster.`, + }, + { + id: 'ask', + label: 'Onay iste', + group: 'interaction', + summary: 'Onay ister; kullanıcı vazgeçerse alan eski değerine döner.', + example: "if (!ask('Emin misiniz?')) return", + params: [{ key: 'message', label: 'Mesaj', type: 'text', placeholder: 'Emin misiniz?' }], + build: ({ params }) => `if (!ask(${q(params.message)})) return`, + describe: ({ params }) => + `"${label(params.message, 'mesaj')}" onayını iste; vazgeçilirse değeri geri al.`, + }, + { + id: 'openUrl', + label: 'URL aç', + group: 'interaction', + summary: 'Yeni sekmede veya aynı sekmede adres açar.', + example: "openUrl('/report?id={Id}')", + params: [ + { key: 'url', label: 'URL', type: 'text', placeholder: '/report?id={Id}' }, + { + key: 'target', + label: 'Hedef', + type: 'select', + optional: true, + choices: [ + { value: '_blank', label: '_blank' }, + { value: '_self', label: '_self' }, + ], + }, + ], + build: ({ params }) => + params.target && params.target !== '_blank' + ? `openUrl(${q(params.url)}, ${q(params.target)})` + : `openUrl(${q(params.url)})`, + describe: ({ params }) => `${label(params.url, 'adres')} adresini aç.`, + }, + { + id: 'apiToField', + label: 'API sonucunu alana yaz', + group: 'integration', + summary: 'Adresi çağırır, JSON içinden okuduğu değeri alana yazar.', + example: "set('Unvan', await api('/api/musteri/{value}', 'data.name'))", + params: [ + { key: 'target', label: 'Hedef alan', type: 'field' }, + { key: 'url', label: 'API adresi', type: 'text', placeholder: '/api/musteri/{value}' }, + { + key: 'path', + label: 'Cevap yolu', + type: 'text', + optional: true, + placeholder: 'data.name', + help: 'Boş bırakılırsa tüm JSON yazılır.', + }, + ], + build: ({ params }) => + `set(${q(params.target)}, await api(${q(params.url)}${ + params.path ? `, ${q(params.path)}` : '' + }))`, + describe: ({ params }) => + `${label(params.url, 'api')} çağrısının sonucunu ${label( + params.target, + 'hedef', + )} alanına yaz.`, + }, + { + id: 'custom', + label: 'Serbest tek satır', + group: 'integration', + summary: 'Servis çağrısı veya kendi yazdığın tek satırlık kod.', + example: 'UiEvalService.ApiGenerateBackgroundWorkers()', + params: [ + { + key: 'code', + label: 'Kod', + type: 'text', + placeholder: 'UiEvalService.ApiGenerateBackgroundWorkers()', + }, + ], + build: ({ params }) => (params.code || '').trim().replace(/;$/, ''), + describe: ({ params }) => label(params.code, 'kod') + ' çalıştır.', + }, +] + +export const recipesById = new Map(recipes.map((recipe) => [recipe.id, recipe])) + +/* ------------------------------------------------------------------ * + * Script üretimi ve geri okuma + * ------------------------------------------------------------------ */ + +const BUILDER_HEADER = '// @builder ' + +/** + * Runtime bu direktifi görürse script'i form açılışında da çalıştırır + * (bkz. shouldRunEditorScriptOnContentReady). + */ +const RUN_ON_OPEN_DIRECTIVE = '// @runOnOpen' + +/** + * Grup bazlı varsayılan tetikleyici. + * + * Değer üreten kurallar (hesaplama, veri, kilit) form açılırken de çalışmalıdır; + * varsayılan değerlerle gelen bir kayıtta türetilmiş alanların boş kalmaması + * için. Kullanıcıya bir şey gösteren ya da dış çağrı yapan kurallar (uyarı, + * onay, URL, API) yalnızca değer değiştiğinde çalışır; aksi halde form her + * açıldığında uyarı çıkar veya gereksiz istek atılır. + */ +const groupDefaultTrigger: Record = { + calc: 'both', + data: 'both', + view: 'both', + interaction: 'change', + integration: 'change', +} + +export const effectiveTrigger = (rule: ScriptRule): RuleTrigger => { + if (rule.trigger) return rule.trigger + const recipe = recipesById.get(rule.recipe) + return recipe ? groupDefaultTrigger[recipe.group] : 'change' +} + +/** Script'in tamamı açılışta çalışacak mı? Guard'ların gerekip gerekmediğini belirler. */ +export const scriptRunsOnOpen = (rules: ScriptRule[]) => + rules.some((rule) => effectiveTrigger(rule) !== 'change') + +export const isRuleComplete = (rule: ScriptRule): boolean => { + const recipe = recipesById.get(rule.recipe) + if (!recipe) return false + + const conditionReady = + rule.condition.operator === 'always' || + (!!rule.condition.source && + (['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(rule.condition.operator) || + rule.condition.value.trim() !== '')) + if (!conditionReady) return false + + return recipe.params.every((param) => { + if (param.optional) return true + if (param.type === 'fieldList') return (rule.fields ?? []).length > 0 + return (rule.params[param.key] ?? '').trim() !== '' + }) +} + +/** + * Tek satırlık kural kodu. + * + * `runsOnOpen` yalnızca script'in tamamı açılışta da çalışıyorsa true gelir; + * o durumda her kuralın ne zaman çalışacağı `isReady` ile ayrıştırılmalıdır. + * Aksi halde script zaten sadece değer değişiminde çalıştığı için guard + * gereksizdir ve kod kısa kalır. + */ +export const buildRuleLine = (rule: ScriptRule, runsOnOpen = false): string => { + const recipe = recipesById.get(rule.recipe) + if (!recipe) return '' + + const body = recipe.build(rule) + if (!body) return '' + + const guards: string[] = [] + + if (runsOnOpen) { + const trigger = effectiveTrigger(rule) + if (trigger === 'change') guards.push('!isReady') + else if (trigger === 'open') guards.push('isReady') + } + + if (!recipe.conditionIsArgument) { + const condition = buildConditionExpression(rule.condition) + if (condition) guards.push(condition) + } + + return guards.length ? `if (${guards.join(' && ')}) ${body}` : body +} + +export const buildScriptFromRules = (rules: ScriptRule[]): string => { + const usable = rules.filter(isRuleComplete) + if (!usable.length) return '' + + const runsOnOpen = scriptRunsOnOpen(usable) + const lines = [`${BUILDER_HEADER}${JSON.stringify({ v: 1, rules: usable })}`] + if (runsOnOpen) lines.push(RUN_ON_OPEN_DIRECTIVE) + + return [...lines, ...usable.map((rule) => buildRuleLine(rule, runsOnOpen)).filter(Boolean)].join( + '\n', + ) +} + +export type ParsedScript = { + rules: ScriptRule[] + /** Script builder tarafından üretilmiş ve o günden beri elle değiştirilmemiş mi? */ + inSync: boolean + hasHeader: boolean +} + +export const parseScript = (script?: string): ParsedScript => { + const source = script?.trim() + if (!source || !source.startsWith(BUILDER_HEADER)) { + return { rules: [], inSync: false, hasHeader: false } + } + + const [headerLine] = source.split('\n') + try { + const parsed = JSON.parse(headerLine.slice(BUILDER_HEADER.length)) + const rules: ScriptRule[] = Array.isArray(parsed?.rules) ? parsed.rules : [] + + // Başlıktaki kurallardan script'i yeniden üretip birebir aynı mı diye bakıyoruz; + // farklıysa kullanıcı script'i elle düzenlemiştir ve kuralları ezmemeliyiz. + if (buildScriptFromRules(rules).trim() === source) { + return { rules, inSync: true, hasHeader: true } + } + + // Tetikleyici kavramından önce kaydedilmiş script'ler: o dönemde her kural + // yalnızca değer değişiminde çalışıyordu. Metin bu haliyle eşleşiyorsa + // kurallar geçerlidir; yeni varsayılanlarla yükseltilerek açılır. + const legacy = buildScriptFromRules(rules.map((rule) => ({ ...rule, trigger: 'change' }))) + if (legacy.trim() === source) { + return { rules, inSync: true, hasHeader: true } + } + + return { rules, inSync: false, hasHeader: true } + } catch { + return { rules: [], inSync: false, hasHeader: true } + } +} + +export const createRule = (recipe: RecipeId): ScriptRule => ({ + id: `${Date.now()}_${Math.random().toString(36).slice(2)}`, + recipe, + condition: { operator: 'always', source: '', value: '' }, + params: {}, + fields: [], +}) + +/** Cheatsheet olarak gösterilen runtime API özeti. */ +export const runtimeApiReference: { signature: string; description: string }[] = [ + { signature: 'value / field', description: 'Değişen alanın değeri ve adı.' }, + { signature: "get('Alan')", description: 'Form değerini okur (büyük/küçük harf duyarsız).' }, + { signature: "num('Alan') / str('Alan') / bool('Alan')", description: 'Tip dönüşümlü okuma.' }, + { signature: "dateOf('Alan')", description: 'Date nesnesi ya da null döner.' }, + { signature: "set('Alan', deger)", description: 'Alana yazar. Nesne de verebilirsin.' }, + { signature: "set({ A: 1, B: 2 })", description: 'Birden fazla alanı tek çağrıda yazar.' }, + { signature: "clear('A', 'B')", description: 'Alanları null yapar.' }, + { signature: "copy('kolon', 'Alan')", description: 'Seçili lookup kaydından kopyalar.' }, + { signature: "selected('Yol') / snum() / sstr()", description: 'Seçili kaydı okur.' }, + { signature: "readOnly('Alan', kosul)", description: 'Alanı kilitler veya açar.' }, + { signature: "round(x, 2) / sum('A','B')", description: 'Yuvarlama ve toplama.' }, + { signature: "days('Bas','Bit') / hours('Bas','Bit')", description: 'Tarih ve saat farkı.' }, + { signature: "tpl('{Alan} - {value}')", description: 'Token doldurur.' }, + { signature: "notify('mesaj') / ask('mesaj')", description: 'Uyarı ve onay. ask false ise geri alır.' }, + { signature: "openUrl('/adres')", description: 'Yeni sekmede açar.' }, + { signature: "await api('/api/x', 'data.name')", description: 'JSON çağrısı yapar.' }, + { + signature: 'isReady', + description: + 'Form açılışında true, değer değişiminde false. Script açılışta da çalışsın istiyorsan bu ifadeyi kullan.', + }, +] diff --git a/ui/src/views/form/FormDevExpress.tsx b/ui/src/views/form/FormDevExpress.tsx index 437b7752..7d6f064b 100644 --- a/ui/src/views/form/FormDevExpress.tsx +++ b/ui/src/views/form/FormDevExpress.tsx @@ -245,10 +245,16 @@ const FormDevExpress = (props: { updateCascadeDisabledStates() }, [formData, updateCascadeDisabledStates]) - /** contentReady anında salt-okunur davranışını belirleyen script'leri çalıştırır. */ + // contentReady sırasında yapılan yazmalar yeni bir contentReady doğurabilir. + const contentReadyRunningRef = useRef(false) + + /** + * Form açılışında çalışması gereken script'leri yürütür: salt-okunur + * kararları ve `@runOnOpen` işaretli varsayılan değer kuralları. + */ const runReadOnlyScripts = useCallback( (form: any) => { - if (!form) return + if (!form || contentReadyRunningRef.current) return const currentFormData = { ...(formDataRef.current || {}), @@ -256,27 +262,34 @@ const FormDevExpress = (props: { } formDataRef.current = currentFormData - flattenFormGroups(formItemsRef.current) - .filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript)) - .forEach((formItem) => { - try { - executeEditorScript(formItem.editorScript!, { - formData: currentFormData, - e: { - component: form, - dataField: formItem.dataField, - scriptEvent: 'contentReady', - value: getValueByField(currentFormData, formItem.dataField), - }, - editor: { dataField: formItem.dataField, component: form }, - runtimeSetEditorReadOnly: (field: string, readOnly: boolean) => - applyEditorReadOnly(form, field, readOnly), - setFormData: undefined, - }) - } catch (err) { - console.error('Script execution failed on contentReady for', formItem.name, err) - } - }) + contentReadyRunningRef.current = true + try { + flattenFormGroups(formItemsRef.current) + .filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript)) + .forEach((formItem) => { + try { + executeEditorScript(formItem.editorScript!, { + formData: currentFormData, + e: { + component: form, + dataField: formItem.dataField, + scriptEvent: 'contentReady', + value: getValueByField(currentFormData, formItem.dataField), + }, + editor: { dataField: formItem.dataField, component: form }, + runtimeSetEditorReadOnly: (field: string, readOnly: boolean) => + applyEditorReadOnly(form, field, readOnly), + // Açılışta yazma artık destekleniyor; varsayılan değer üreten + // script'ler bu sayede çalışabiliyor. + setFormData: (newData: any) => commitFormData(newData || {}, form), + }) + } catch (err) { + console.error('Script execution failed on contentReady for', formItem.name, err) + } + }) + } finally { + contentReadyRunningRef.current = false + } }, // applyEditorReadOnly ref tabanlıdır; her render'da yeniden oluşması sorun değil. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/ui/src/views/list/CardView.tsx b/ui/src/views/list/CardView.tsx index c0a52848..90e8c7c7 100644 --- a/ui/src/views/list/CardView.tsx +++ b/ui/src/views/list/CardView.tsx @@ -58,6 +58,7 @@ import { shouldUseMobileEditPopup, } from './shared/editPopup' import { getValueByField, resolveEditorType } from './shared/editingForm' +import { createEditingFormHandlers } from './shared/editingFormScripts' import { flattenColumns } from './shared/columns' import { getInitialExtraFilters, mergeFilterTriplets } from './shared/hooks' import { useFilters } from './useFilters' @@ -643,6 +644,38 @@ const CardView = (props: CardViewProps) => { [cascadeChildrenByParent], ) + // Grid/Tree/Scheduler ile aynı editorScript altyapısı; CardView'ın New ve Edit + // popup formlarında da script'ler çalışsın diye bağlanır. + const editingFormDataRef = useRef>({}) + const editingFormInstanceRef = useRef() + const editorScriptHandlers = useMemo( + () => + createEditingFormHandlers({ + getGridDto: () => gridDto, + getGridInstance: () => cardViewRef.current?.instance?.(), + editingFormDataRef, + editingFormInstanceRef, + }), + [gridDto], + ) + + const onEditingFormContentReady = useCallback( + (event: any) => { + editingFormInstanceRef.current = event.component + editorScriptHandlers.onContentReady(event) + }, + [editorScriptHandlers], + ) + + const onEditingFormDataChanged = useCallback( + (event: any) => { + // Önce cascade temizliği, sonra alanın kendi script'i. + onEditingFormFieldChanged(event) + editorScriptHandlers.onFieldDataChanged(event) + }, + [editorScriptHandlers, onEditingFormFieldChanged], + ) + const applyLookupDisplayValues = useCallback((values: Record>) => { if (lookupDisplayValuesEqual(lookupDisplayValuesRef.current, values)) return lookupDisplayValuesRef.current = values @@ -1159,7 +1192,8 @@ const CardView = (props: CardViewProps) => { colCount: 1, items: editingFormItems, labelLocation: 'top', - onFieldDataChanged: onEditingFormFieldChanged, + onContentReady: onEditingFormContentReady, + onFieldDataChanged: onEditingFormDataChanged, showColonAfterLabel: false, }} popup={{ diff --git a/ui/src/views/list/SchedulerView.tsx b/ui/src/views/list/SchedulerView.tsx index a25f007d..827593c7 100644 --- a/ui/src/views/list/SchedulerView.tsx +++ b/ui/src/views/list/SchedulerView.tsx @@ -37,6 +37,7 @@ import { FaAlignLeft, FaClock, FaHeading } from 'react-icons/fa' import { Avatar } from '@/components/ui' import { getUsers } from '@/services/identity.service' import { useListFormColumns } from './useListFormColumns' +import { createEditingFormHandlers } from './shared/editingFormScripts' import { getEditPopupHeight, getEditPopupMaxHeight, @@ -117,6 +118,20 @@ const SchedulerView = (props: SchedulerViewProps) => { schedulerRef.current?.instance()?.option('dataSource', undefined) }, [listFormCode]) + // Appointment formundaki editorScript'leri Grid/Tree ile aynı altyapıdan çalıştırıyoruz. + const appointmentFormDataRef = useRef>({}) + const appointmentFormInstanceRef = useRef() + const editorScriptHandlers = useMemo( + () => + createEditingFormHandlers({ + getGridDto: () => gridDto, + getGridInstance: () => schedulerRef.current?.instance?.(), + editingFormDataRef: appointmentFormDataRef, + editingFormInstanceRef: appointmentFormInstanceRef, + }), + [gridDto], + ) + const { createSelectDataSource } = useListFormCustomDataSource({ gridRef: schedulerRef }) const { getBandedColumns } = useListFormColumns({ gridDto, @@ -692,8 +707,21 @@ const SchedulerView = (props: SchedulerViewProps) => { e.form.option('colCount', 1) e.form.option('showValidationSummary', false) e.form.option('items', result) + + // Grid/Tree'de olduğu gibi editorScript'ler forma bağlanır. + appointmentFormDataRef.current = { ...((e.appointmentData ?? {}) as Record) } + appointmentFormInstanceRef.current = e.form + e.form.option('onContentReady', editorScriptHandlers.onContentReady) + e.form.option('onFieldDataChanged', editorScriptHandlers.onFieldDataChanged) }, - [gridDto, translate, isPopupFullScreen, listFormColumns, useMobileEditPopup], + [ + gridDto, + translate, + isPopupFullScreen, + listFormColumns, + useMobileEditPopup, + editorScriptHandlers, + ], ) const configuredEditPopup = gridDto?.gridOptions.editingOptionDto?.popup diff --git a/ui/src/views/list/shared/editingForm.ts b/ui/src/views/list/shared/editingForm.ts index aa86167f..69068ff4 100644 --- a/ui/src/views/list/shared/editingForm.ts +++ b/ui/src/views/list/shared/editingForm.ts @@ -174,9 +174,26 @@ export const getActiveEditingForm = (component: any) => { return editForm?.getEditor || editForm?.itemOption ? editForm : undefined } +/** + * Form ilk açıldığında da çalışması gereken script'leri ayıklar. + * + * - `@runOnOpen`: builder, kurallardan en az biri açılışta çalışacaksa bu + * direktifi yazar (varsayılan değer üretmek gibi senaryolar). + * - `readOnly(` / `setEditorReadOnly`: salt-okunur kararı değer değişmeden de + * uygulanmalıdır. + * - `isReady`: elle yazılmış script'lerin açılışa katılma yolu. + * + * Bu imzalardan hiçbirini taşımayan script'ler yalnızca değer değiştiğinde + * çalışır; eski uzun script'lerin davranışı böylece korunur. + */ export const shouldRunEditorScriptOnContentReady = (script?: string) => Boolean( - script && (script.includes('setEditorReadOnly') || script.includes('runtimeSetEditorReadOnly')), + script && + (script.includes('@runOnOpen') || + /\breadOnly\s*\(/.test(script) || + /\bisReady\b/.test(script) || + script.includes('setEditorReadOnly') || + script.includes('runtimeSetEditorReadOnly')), ) /* ------------------------------------------------------------------ * diff --git a/ui/src/views/list/shared/editingFormScripts.ts b/ui/src/views/list/shared/editingFormScripts.ts index a4024a3b..5f256df4 100644 --- a/ui/src/views/list/shared/editingFormScripts.ts +++ b/ui/src/views/list/shared/editingFormScripts.ts @@ -30,9 +30,13 @@ const getEditingRowIndex = (grid: any) => { } } -/** Grid hücrelerine script tarafından yazılan değerleri uygular. */ +/** + * Grid hücrelerine script tarafından yazılan değerleri uygular. + * CardView gibi `cellValue` API'si olmayan bileşenlerde sessizce atlanır; + * form verisi zaten `setFormData` üzerinden güncellenir. + */ const createGridCellWriter = (grid: any, rowIndex: number) => (newData: Record) => { - if (!grid || rowIndex < 0) return + if (!grid || rowIndex < 0 || typeof grid.cellValue !== 'function') return Object.keys(newData).forEach((field) => grid.cellValue(rowIndex, field, newData[field])) } @@ -42,15 +46,19 @@ export const createEditingFormHandlers = ({ editingFormDataRef, editingFormInstanceRef, }: EditingFormHandlerParams) => { + // contentReady sırasında yapılan yazmalar formu güncelleyip yeniden + // contentReady tetikleyebilir; aynı turda tekrar girmeyi engelliyoruz. + let contentReadyRunning = false + /** - * Form ilk render olduğunda salt-okunur davranışını belirleyen editör - * script'lerini çalıştırır. + * Form ilk render olduğunda çalışması gereken editör script'lerini yürütür: + * salt-okunur kararları ve `@runOnOpen` işaretli varsayılan değer kuralları. */ const onContentReady = (e: any) => { editingFormInstanceRef.current = e.component const gridDto = getGridDto() - if (!gridDto) return + if (!gridDto || contentReadyRunning) return const form = e.component const grid = getGridInstance() @@ -79,28 +87,65 @@ export const createEditingFormHandlers = ({ const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) => setFormEditorReadOnly(form, field, readOnly) - scriptFields.forEach((field) => { - try { - const editorValue = - form?.getEditor?.(field.fieldName)?.option?.('value') ?? - getValueByField(formData, field.fieldName) + const { rowIndex } = getEditingRowIndex(grid) + const writeToGrid = createGridCellWriter(grid, rowIndex) - executeEditorScript(field.editorScript!, { - formData, - e: { - component: form, - dataField: field.fieldName, - scriptEvent: 'contentReady', - value: editorValue, - }, - editor: { dataField: field.fieldName, component: grid }, - runtimeSetEditorReadOnly, - setFormData: undefined, - }) - } catch (err) { - console.error('Script exec error on contentReady', field.fieldName, err) - } - }) + /** + * Açılışta yazma artık destekleniyor; varsayılan değer üreten script'ler + * bu sayede çalışabiliyor. Runtime değişmeyen değerleri yazmadığı için + * contentReady döngüsü kendini besleyemez. + */ + const applyFormData = (newData: Record) => { + editingFormDataRef.current = { ...newData } + + // Yazma, formun kendi contentReady'si sürerken yapılırsa DevExtreme + // render döngüsünün ortasına denk gelir; bir tick sonraya alıyoruz. + setTimeout(() => { + const currentData = form?.option?.('formData') || {} + const changedFields = Object.keys(newData).filter( + (fieldName) => currentData[fieldName] !== newData[fieldName], + ) + if (!changedFields.length) return + + // `updateData` değişikliği grid'in düzenleme akışına kaydeder; + // `option('formData', ...)` ile nesneyi baştan yazmak bu bağı koparabiliyor. + if (typeof form?.updateData === 'function') { + changedFields.forEach((fieldName) => form.updateData(fieldName, newData[fieldName])) + } else { + form?.option?.('formData', newData) + } + + writeToGrid(Object.fromEntries(changedFields.map((key) => [key, newData[key]]))) + }, 0) + } + + contentReadyRunning = true + try { + scriptFields.forEach((field) => { + try { + const editorValue = + form?.getEditor?.(field.fieldName)?.option?.('value') ?? + getValueByField(formData, field.fieldName) + + executeEditorScript(field.editorScript!, { + formData, + e: { + component: form, + dataField: field.fieldName, + scriptEvent: 'contentReady', + value: editorValue, + }, + editor: { dataField: field.fieldName, component: grid }, + runtimeSetEditorReadOnly, + setFormData: applyFormData, + }) + } catch (err) { + console.error('Script exec error on contentReady', field.fieldName, err) + } + }) + } finally { + contentReadyRunning = false + } } /** Bir alan değiştiğinde ilgili editör script'ini çalıştırır. */