Claude güncellemesi Editor Script Builder güncellemesi
This commit is contained in:
parent
a1f5e5e539
commit
0d27742f8c
9 changed files with 1786 additions and 1330 deletions
|
|
@ -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<string, any>
|
||||
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<any>
|
||||
|
||||
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<string, any> = { ...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<string, any>, 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<typeof fetch>[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<any>
|
||||
/** Yardımcılar enjekte edilebildi mi? Eski script'lerde false olabilir. */
|
||||
withHelpers: boolean
|
||||
}
|
||||
|
||||
const compileCache = new Map<string, CompiledScript>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<Dialog.Body className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Dialog.Body className="flex h-[calc(100vh-16rem)] max-h-[calc(90vh-7rem)] min-h-[320px] !flex-none flex-col gap-3 !overflow-hidden">
|
||||
{/* ── Başlık ─────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
|
||||
<FaMagic className="text-indigo-500" />
|
||||
|
|
@ -436,7 +442,9 @@ function EditorOptionsBuilderDialog({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-12 gap-4 overflow-hidden">
|
||||
{/* flex-1 olmadan bu ızgara içeriğe göre büzülür ve sağdaki JSON
|
||||
önizlemesi yüksekliğini kaybeder. */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4 overflow-hidden">
|
||||
{/* ── Ayar bölümleri ─────────────────────────────────── */}
|
||||
<section className="col-span-8 flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
|
||||
<div className={cardClass}>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, string>
|
||||
/** Ç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<RecipeGroupKey, RuleTrigger> = {
|
||||
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.',
|
||||
},
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Record<string, any>>({})
|
||||
const editingFormInstanceRef = useRef<any>()
|
||||
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<string, Map<string, string>>) => {
|
||||
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={{
|
||||
|
|
|
|||
|
|
@ -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<Record<string, any>>({})
|
||||
const appointmentFormInstanceRef = useRef<any>()
|
||||
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<string, any>) }
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
|
|
|
|||
|
|
@ -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<string, any>) => {
|
||||
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<string, any>) => {
|
||||
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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue