Claude güncellemesi Editor Script Builder güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-09 02:06:14 +03:00
parent a1f5e5e539
commit 0d27742f8c
9 changed files with 1786 additions and 1330 deletions

View file

@ -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)
}

View file

@ -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 ( 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}>

View file

@ -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
* ı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 ı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} tokenları 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 ı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 ı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
* ı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ıı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ı ı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.',
},
]

View file

@ -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 ı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

View file

@ -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={{

View file

@ -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

View file

@ -174,9 +174,26 @@ export const getActiveEditingForm = (component: any) => {
return editForm?.getEditor || editForm?.itemOption ? editForm : undefined
}
/**
* Form ilk ıldığında da çalışması gereken script'leri ayıklar.
*
* - `@runOnOpen`: builder, kurallardan en az biri ı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 ı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')),
)
/* ------------------------------------------------------------------ *

View file

@ -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)
}
})
/**
* ı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. */