Claude güncellemesi ScriptBuilderDialog güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-10 12:08:08 +03:00
parent 2368ee3825
commit a42776e9c9
19 changed files with 4922 additions and 2663 deletions

2395
README.md

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,21 @@ Primary source of truth for platform behavior is:
If there is any conflict, follow `.github/instructions/ai.instructions.md`.
## Communication Rules (apply to every response)
1. **Never introduce or summarize the platform.** Assume the user knows what this
application is, how it is built, and which technologies it uses. Do not open a response
with "Sozsoft Platform is a multi-tenant low-code engine…" or any equivalent framing.
2. **Answer the request, nothing more.** No unsolicited architecture overviews, no
restating the decision order, no re-explaining ListForm/DeveloperKit concepts unless
the user actually asked about them.
3. **No preamble, no epilogue.** Skip "Great question", "I reviewed the codebase",
summaries of what you are about to do, and closing recaps of what you just did.
4. **Match the question's size.** A yes/no question gets a sentence. A config question
gets the config. Only a full feature request gets a full proposal.
5. **Respond in the language the user writes in** (Turkish → Turkish).
6. Mention a rule from this file only when it changes the answer — never as boilerplate.
## Purpose
- Maximize delivery through runtime configuration.
@ -17,7 +32,9 @@ Primary principle: Configuration first, code last.
## Mandatory Decision Order
For every request, evaluate and propose in this order:
For every request, evaluate in this order — internally. State the chosen step only when the
user is asking how to build something, and state it in one line, not as a walkthrough of the
options you rejected.
1. Dynamic configuration with existing ListForm ecosystem
2. SQL Query Manager + Custom Endpoint
@ -28,14 +45,15 @@ For every request, evaluate and propose in this order:
1. Do not propose new custom React component/page development for standard feature requests.
2. Build new screens using platform configuration mechanisms.
3. Every proposal must include tenant and permission design.
3. Every implementation proposal must include tenant and permission design.
4. Never bypass platform authorization patterns.
5. Never hardcode secrets, tenant IDs, or connection strings.
Exception:
- Custom React/backend code is allowed only when the user explicitly requests implementation and configuration is insufficient.
- In such cases, explain why configuration-first options are not enough.
- Custom React/backend code is allowed only when the user explicitly requests implementation
and configuration is insufficient.
- In such cases, explain why configuration-first options are not enough — in a sentence or two.
## Architecture Guardrails
@ -58,7 +76,12 @@ Exception:
## Response Contract
When producing an implementation proposal, include:
The list below applies **only** to a full implementation proposal for a new screen, module,
or integration. It is not a template for questions, debugging, code review, refactors,
explanations, or small changes.
Even for a full proposal: include only the items that carry real content for that request,
and drop the rest. An empty or obvious heading is noise.
1. Goal
2. Decision flow result (which step used)

View file

@ -0,0 +1,924 @@
import { Button, Dialog } from '@/components/ui'
import Editor, { type Monaco } from '@monaco-editor/react'
import type * as monacoApi from 'monaco-editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
FaArrowDown,
FaArrowUp,
FaBook,
FaCheck,
FaChevronDown,
FaChevronRight,
FaCode,
FaCopy,
FaExclamationTriangle,
FaMagic,
FaPlus,
FaSearch,
FaTimes,
FaTrash,
} from 'react-icons/fa'
import { apiEntryName, attachScriptIntelliSense, insertAtCursor } from './scriptIntelliSense'
import {
scriptRuleConditions,
type ScriptDialect,
type ScriptRecipeParam,
type ScriptRule,
type ScriptRuleCondition,
} from './types'
export type ScriptBuilderDialogProps = {
isOpen: boolean
value?: string
dialect: ScriptDialect
onClose: () => void
onApply: (value: string) => void
/** Kaydet/Vazgeç etiketleri; çağıran kendi çevirisini geçebilir. */
saveLabel?: string
cancelLabel?: string
}
const controlClass =
'w-full min-w-0 h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400'
const cardClass =
'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900'
const editorOptions = {
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: 'on',
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
padding: { top: 8, bottom: 8 },
} as const
const emptyCondition = (kind?: string): ScriptRuleCondition => ({
operator: 'equals',
source: '',
value: '',
...(kind ? { kind } : {}),
})
function ScriptBuilderDialog({
isOpen,
value,
dialect,
onClose,
onApply,
saveLabel = 'Kaydet',
cancelLabel = 'Vazgeç',
}: ScriptBuilderDialogProps) {
const [rules, setRules] = useState<ScriptRule[]>([])
const [code, setCode] = useState('')
// Sihirbaz bir butona basılarak açılır; varsayılan her zaman kod editörüdür.
const [view, setView] = useState<'code' | 'wizard'>('code')
const [openRules, setOpenRules] = useState<Record<string, boolean>>({})
const [paletteOpen, setPaletteOpen] = useState(true)
const [helpOpen, setHelpOpen] = useState(true)
const [helpSearch, setHelpSearch] = useState('')
const [copied, setCopied] = useState(false)
const editorRef = useRef<monacoApi.editor.IStandaloneCodeEditor | null>(null)
const disposeIntelliSense = useRef<(() => void) | null>(null)
/** Sihirbazda ilk düzenleme yapılana kadar kod'a dokunulmaz. */
const wizardTouched = useRef(false)
/**
* Lehçe nesnesi çıranın prop'larından türetildiği için her render'da yeniden
* oluşabilir. Sıfırlama efektinin ona bağlanması, kullanıcı yazarken editörü
* boşaltırdı o yüzden efekt lehçeyi ref üzerinden okur.
*/
const dialectRef = useRef(dialect)
dialectRef.current = dialect
const generatedScript = useMemo(() => dialect.buildScript(rules), [dialect, rules])
const completeRuleCount = rules.filter((rule) => dialect.isRuleComplete(rule)).length
const codeDiverged = code.trim() !== '' && code.trim() !== generatedScript.trim()
useEffect(() => {
if (!isOpen) return
const parsed = dialectRef.current.parseScript(value)
setRules(parsed.rules)
setCode(value?.trim() ?? '')
setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false])))
setPaletteOpen(parsed.rules.length === 0)
setView('code')
setCopied(false)
setHelpSearch('')
wizardTouched.current = false
}, [isOpen, value])
// Kural eklemek, çıkarmak veya değiştirmek script'e anında yansır: sihirbazda
// yapılan her düzenleme kod editörünü de günceller.
useEffect(() => {
if (view !== 'wizard' || !wizardTouched.current) return
setCode(generatedScript)
}, [generatedScript, view])
useEffect(
() => () => {
disposeIntelliSense.current?.()
disposeIntelliSense.current = null
},
[],
)
const optionSources = useMemo(() => {
const sources = new Set<string>()
dialect.conditionKinds.forEach((kind) => kind.optionSource && sources.add(kind.optionSource))
dialect.recipes.forEach((recipe) =>
recipe.params.forEach((param) => param.optionSource && sources.add(param.optionSource)),
)
return [...sources]
}, [dialect])
const handleEditorMount = useCallback(
(editor: monacoApi.editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorRef.current = editor
disposeIntelliSense.current?.()
disposeIntelliSense.current = attachScriptIntelliSense(
monaco,
editor,
() => dialectRef.current,
{ optionSources },
)
},
[optionSources],
)
const touchRules = (next: (current: ScriptRule[]) => ScriptRule[]) => {
wizardTouched.current = true
setRules(next)
}
const updateRule = (id: string, patch: Partial<ScriptRule>) =>
touchRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)))
const updateParam = (id: string, key: string, next: string) =>
touchRules((current) =>
current.map((rule) =>
rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule,
),
)
/**
* Koşul güncellemesi: `index` 0 ise ana koşul, değilse ek koşullardan biri.
* Ek koşul kalmadığında `conditions` alanı tamamen kaldırılır eski tek
* koşullu kurallarla aynı JSON'a serileşmesi buna bağlı.
*/
const updateCondition = (rule: ScriptRule, index: number, patch: Partial<ScriptRuleCondition>) => {
if (index === 0) {
updateRule(rule.id, { condition: { ...rule.condition, ...patch } })
return
}
const conditions = [...(rule.conditions ?? [])]
conditions[index - 1] = { ...conditions[index - 1], ...patch }
updateRule(rule.id, { conditions })
}
const addCondition = (rule: ScriptRule) =>
updateRule(rule.id, {
conditions: [
...(rule.conditions ?? []),
// Tek kaynak türü olan lehçelerde `kind` taşımak gereksiz gürültü.
emptyCondition(dialect.conditionKinds.length > 1 ? rule.condition.kind : undefined),
],
join: rule.join ?? 'and',
})
const removeCondition = (rule: ScriptRule, index: number) => {
const conditions = (rule.conditions ?? []).filter((_, item) => item !== index - 1)
updateRule(rule.id, conditions.length ? { conditions } : { conditions: undefined, join: undefined })
}
const moveRule = (index: number, delta: number) =>
touchRules((current) => {
const target = index + delta
if (target < 0 || target >= current.length) return current
const next = [...current]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
const addRule = (recipeId: string) => {
const rule = dialect.createRule(recipeId)
touchRules((current) => [...current, rule])
setOpenRules((current) => ({ ...current, [rule.id]: true }))
}
const copyScript = () => {
navigator.clipboard?.writeText(view === 'wizard' ? generatedScript : code)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const openWizard = () => {
// Elle yazılmış bir script'i kurallar okuyamıyorsa, kullanıcı bir kural
// değiştirene kadar kod olduğu gibi durur; banner bunu söyler.
const parsed = dialect.parseScript(code)
if (parsed.rules.length) {
setRules(parsed.rules)
setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false])))
}
setView('wizard')
}
const filteredApi = useMemo(() => {
const query = helpSearch.trim().toLocaleLowerCase('tr')
if (!query) return dialect.apiReference
return dialect.apiReference.filter((entry) =>
`${entry.signature} ${entry.description}`.toLocaleLowerCase('tr').includes(query),
)
}, [dialect.apiReference, helpSearch])
const filteredSnippets = useMemo(() => {
const query = helpSearch.trim().toLocaleLowerCase('tr')
if (!query) return dialect.snippets
return dialect.snippets.filter((snippet) =>
`${snippet.label} ${snippet.group} ${snippet.description} ${snippet.code}`
.toLocaleLowerCase('tr')
.includes(query),
)
}, [dialect.snippets, helpSearch])
const renderOptionInput = (
optionSource: string | undefined,
selectedValue: string,
onChange: (next: string) => void,
placeholder: string,
) => {
const options = optionSource ? dialect.getOptions(optionSource) : []
if (!options.length) {
return (
<input
className={controlClass}
placeholder={placeholder}
value={selectedValue}
onChange={(event) => onChange(event.target.value)}
/>
)
}
return (
<select
className={controlClass}
value={options.includes(selectedValue) ? selectedValue : ''}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
const renderParam = (rule: ScriptRule, param: ScriptRecipeParam) => {
if (param.type === 'fieldList') {
const selectedFields = rule.fields ?? []
const options = dialect.getOptions(param.optionSource || '')
return (
<div key={param.key} className="col-span-12 min-w-0">
<span className="mb-1 block text-xs text-gray-500">{param.label}</span>
<div className="flex flex-wrap items-center gap-1.5">
{selectedFields.map((fieldName) => (
<span
key={fieldName}
className="flex items-center gap-1 rounded-full bg-indigo-100 px-2 py-[2px] text-[11px] text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
>
{fieldName}
<button
type="button"
className="text-indigo-400 hover:text-red-500"
onClick={() =>
updateRule(rule.id, {
fields: selectedFields.filter((item) => item !== fieldName),
})
}
>
<FaTimes size={9} />
</button>
</span>
))}
<select
className={`${controlClass} !h-8 !w-44`}
value=""
onChange={(event) => {
const next = event.target.value
if (!next || selectedFields.includes(next)) return
updateRule(rule.id, { fields: [...selectedFields, next] })
}}
>
<option value="">+ ekle</option>
{options
.filter((fieldName) => !selectedFields.includes(fieldName))
.map((fieldName) => (
<option key={fieldName} value={fieldName}>
{fieldName}
</option>
))}
</select>
</div>
</div>
)
}
const currentValue = rule.params[param.key] ?? ''
return (
<label key={param.key} className="col-span-12 min-w-0 md:col-span-4">
<span className="mb-1 block truncate text-xs text-gray-500" title={param.help}>
{param.label}
{param.optional && <span className="ml-1 text-gray-300">(ops.)</span>}
</span>
{param.type === 'option' &&
renderOptionInput(
param.optionSource,
currentValue,
(next) => updateParam(rule.id, param.key, next),
param.placeholder || 'Seç',
)}
{param.type === 'select' && (
<select
className={controlClass}
value={currentValue}
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
>
{param.choices?.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
)}
{(param.type === 'text' || param.type === 'number') && (
<input
className={controlClass}
type={param.type === 'number' ? 'number' : 'text'}
value={currentValue}
placeholder={param.placeholder}
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
/>
)}
{param.help && (
<span className="mt-1 block truncate text-[11px] text-gray-400" title={param.help}>
{param.help}
</span>
)}
</label>
)
}
const renderCondition = (rule: ScriptRule, condition: ScriptRuleCondition, index: number) => {
const operator = dialect.operators.find((item) => item.value === condition.operator)
const kind =
dialect.conditionKinds.find((item) => item.value === condition.kind) ||
dialect.conditionKinds[0]
return (
<div key={index} className="grid grid-cols-12 items-end gap-2">
{index > 0 ? (
<label className="col-span-12 min-w-0 md:col-span-2">
<span className="mb-1 block text-xs text-gray-500">Bağlaç</span>
<select
className={controlClass}
value={rule.join ?? 'and'}
onChange={(event) =>
updateRule(rule.id, { join: event.target.value as ScriptRule['join'] })
}
>
<option value="and">VE</option>
<option value="or">VEYA</option>
</select>
</label>
) : (
<div className="col-span-12 hidden md:col-span-2 md:block">
<span className="mb-1 block text-xs text-gray-500">Koşul</span>
<div className="flex h-9 items-center text-xs font-semibold text-gray-400">EĞER</div>
</div>
)}
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">Karşılaştırma</span>
<select
className={controlClass}
value={condition.operator}
onChange={(event) => updateCondition(rule, index, { operator: event.target.value })}
>
{dialect.operators.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
{operator?.needsSource && dialect.conditionKinds.length > 1 && (
<label className="col-span-12 min-w-0 md:col-span-2">
<span className="mb-1 block text-xs text-gray-500">Kaynak</span>
<select
className={controlClass}
value={condition.kind ?? dialect.conditionKinds[0]?.value ?? ''}
onChange={(event) =>
updateCondition(rule, index, { kind: event.target.value, source: '' })
}
>
{dialect.conditionKinds.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
)}
{operator?.needsSource && (
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">Alan</span>
{renderOptionInput(
kind?.optionSource,
condition.source,
(next) => updateCondition(rule, index, { source: next }),
kind?.placeholder || 'Seç',
)}
</label>
)}
{operator?.needsValue && (
<label className="col-span-12 min-w-0 md:col-span-2">
<span className="mb-1 block text-xs text-gray-500">Değer</span>
<input
className={controlClass}
value={condition.value}
placeholder="Aktif / 1000"
onChange={(event) => updateCondition(rule, index, { value: event.target.value })}
/>
</label>
)}
{index > 0 && (
<div className="col-span-12 flex md:col-span-1">
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Koşulu kaldır"
icon={<FaTimes />}
onClick={() => removeCondition(rule, index)}
/>
</div>
)}
</div>
)
}
const renderRule = (rule: ScriptRule, index: number) => {
const recipe = dialect.getRecipe(rule.recipe)
if (!recipe) return null
const open = openRules[rule.id] ?? true
const complete = dialect.isRuleComplete(rule)
const group = dialect.groups.find((item) => item.key === recipe.group)
const Icon = group?.icon ?? FaCode
const conditions = scriptRuleConditions(rule)
const trigger = dialect.getTrigger?.(rule)
const triggerInfo = dialect.triggers?.find((item) => item.value === trigger)
return (
<div key={rule.id} className={cardClass}>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
className="shrink-0 text-gray-400"
onClick={() => setOpenRules((current) => ({ ...current, [rule.id]: !open }))}
>
{open ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
</button>
<Icon className={complete ? 'text-indigo-500' : 'text-gray-300'} size={13} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">
{index + 1}. {recipe.label}
</div>
<div className="truncate text-[11px] text-gray-400" title={dialect.describeRule(rule)}>
{dialect.describeRule(rule)}
</div>
</div>
{conditions.length > 1 && (
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-[1px] text-[11px] text-violet-700 dark:bg-violet-900 dark:text-violet-200">
{conditions.length} koşul · {rule.join === 'or' ? 'VEYA' : 'VE'}
</span>
)}
{triggerInfo && trigger !== dialect.triggers?.[0]?.value && (
<span
className="shrink-0 rounded-full bg-sky-100 px-2 py-[1px] text-[11px] text-sky-700 dark:bg-sky-900 dark:text-sky-200"
title={triggerInfo.help}
>
{triggerInfo.label}
</span>
)}
{!complete && (
<span
className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-[1px] text-[11px] text-amber-700 dark:bg-amber-900 dark:text-amber-200"
title={
dialect.getRuleWarning?.(rule) ||
'Zorunlu alanlar dolmadığı için bu kural scripte eklenmez.'
}
>
<FaExclamationTriangle size={9} /> eksik
</span>
)}
<div className="flex shrink-0 items-center">
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Yukarı taşı"
icon={<FaArrowUp />}
onClick={() => moveRule(index, -1)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Aşağı taşı"
icon={<FaArrowDown />}
onClick={() => moveRule(index, 1)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Kuralı sil"
icon={<FaTrash />}
onClick={() => touchRules((current) => current.filter((item) => item.id !== rule.id))}
/>
</div>
</div>
{open && (
<div className="border-t border-gray-100 px-3 py-3 dark:border-gray-800">
<p className="mb-3 text-[11px] text-gray-400">{recipe.summary}</p>
<div className="mb-3 flex flex-col gap-2 rounded-md bg-gray-50 p-2 dark:bg-gray-800/40">
{dialect.triggers && (
<label className="w-full md:w-56">
<span className="mb-1 block text-xs text-gray-500">Ne zaman</span>
<select
className={controlClass}
value={trigger ?? dialect.triggers[0].value}
onChange={(event) => updateRule(rule.id, { trigger: event.target.value })}
>
{dialect.triggers.map((item) => (
<option key={item.value} value={item.value} title={item.help}>
{item.label}
</option>
))}
</select>
</label>
)}
{conditions.map((condition, conditionIndex) =>
renderCondition(rule, condition, conditionIndex),
)}
<div>
<Button
size="xs"
type="button"
icon={<FaPlus />}
title="Aynı kurala ikinci bir koşul ekler; VE/VEYA ile birleşir."
onClick={() => addCondition(rule)}
>
Koşul ekle
</Button>
</div>
</div>
<div className="grid grid-cols-12 gap-x-3 gap-y-3">
{recipe.params.map((param) => renderParam(rule, param))}
</div>
<pre className="mt-3 overflow-x-auto rounded border border-gray-100 bg-gray-950 px-3 py-2 font-mono text-[11px] text-emerald-100 dark:border-gray-800">
{dialect.buildRuleLine(rule, rules) || recipe.example}
</pre>
</div>
)}
</div>
)
}
const helpPanel = (
<section className={`flex min-h-0 w-80 shrink-0 flex-col ${cardClass} p-3`}>
<div className="mb-2 flex items-center gap-2">
<FaSearch className="text-gray-400" size={11} />
<input
className="w-full min-w-0 bg-transparent text-xs text-gray-700 outline-none dark:text-gray-100"
placeholder="Kısayol veya snippet ara"
value={helpSearch}
onChange={(event) => setHelpSearch(event.target.value)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Paneli kapat"
icon={<FaTimes />}
onClick={() => setHelpOpen(false)}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Snippet
</div>
<div className="mb-3 flex flex-col gap-1">
{filteredSnippets.map((snippet) => (
<button
key={snippet.label}
type="button"
className="rounded border border-gray-100 px-2 py-1.5 text-left hover:border-indigo-300 hover:bg-indigo-50 dark:border-gray-800 dark:hover:bg-gray-800"
title={`${snippet.description}\n\n${snippet.code}`}
onClick={() => insertAtCursor(editorRef.current, snippet.code)}
>
<div className="truncate text-xs font-medium text-gray-700 dark:text-gray-100">
{snippet.label}
</div>
<div className="truncate text-[10px] text-gray-400">{snippet.description}</div>
</button>
))}
{!filteredSnippets.length && (
<span className="text-[11px] text-gray-400">Eşleşen snippet yok.</span>
)}
</div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Runtime API
</div>
<div className="flex flex-col gap-1">
{filteredApi.map((entry) => (
<button
key={entry.signature}
type="button"
className="rounded px-1 py-1 text-left hover:bg-indigo-50 dark:hover:bg-gray-800"
title={entry.description}
onClick={() => insertAtCursor(editorRef.current, apiEntryName(entry))}
>
<code className="block truncate font-mono text-[11px] text-indigo-600 dark:text-indigo-300">
{entry.signature}
</code>
<span className="block truncate text-[10px] text-gray-400">{entry.description}</span>
</button>
))}
{!filteredApi.length && (
<span className="text-[11px] text-gray-400">Eşleşen kısayol yok.</span>
)}
</div>
</div>
</section>
)
return (
<Dialog
preventScroll
isOpen={isOpen}
width={1360}
contentClassName="flex flex-col"
style={{ overlay: { zIndex: 1300 } }}
onClose={onClose}
onRequestClose={onClose}
>
{/*
Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca
react-modal içeriğine uygulanıyor, ve `.dialog-body` global olarak `flex-1`
alı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 mt-5">
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
<FaCode className="text-indigo-500" />
<h5 className="mr-1">{dialect.title}</h5>
<span
className="rounded-full bg-indigo-100 px-2.5 py-[2px] text-[11px] font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
title={dialect.subjectHint}
>
{dialect.subject}
</span>
{view === 'wizard' && (
<span
className={`rounded-full px-2.5 py-[2px] text-[11px] font-medium ${
completeRuleCount
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-200'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{completeRuleCount}/{rules.length} kural
</span>
)}
<div className="ml-auto flex items-center gap-2">
{view === 'code' && !helpOpen && (
<Button
size="sm"
type="button"
title="Kısayol ve snippet panelini aç"
icon={<FaBook />}
onClick={() => setHelpOpen(true)}
>
Yardım
</Button>
)}
<Button size="sm" type="button" icon={<FaCopy />} onClick={copyScript}>
{copied ? 'Kopyalandı' : 'Kopyala'}
</Button>
<div className="flex overflow-hidden rounded-md border border-gray-200 dark:border-gray-700">
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium ${
view === 'code'
? 'bg-indigo-500 text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={() => setView('code')}
>
<FaCode /> Kod
</button>
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium ${
view === 'wizard'
? 'bg-indigo-500 text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title="Kod yazmadan kural kurmak için sihirbazı aç"
onClick={openWizard}
>
<FaMagic /> Sihirbaz
</button>
</div>
</div>
</div>
{view === 'wizard' && codeDiverged && (
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<FaExclamationTriangle className="shrink-0" />
<span className="flex-1">
Koddaki script kurallardan farklı. Burada bir kuralı değiştirdiğin anda kod
kurallardan yeniden üretilir.
</span>
<Button size="xs" type="button" onClick={() => setView('code')}>
Kodu gör
</Button>
</div>
)}
{view === 'code' ? (
<div className="flex min-h-0 flex-1 gap-3">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center justify-between gap-2 text-xs text-gray-500">
<span className="min-w-0 truncate">{dialect.codeHint}</span>
{rules.length > 0 && (
<Button
disabled={!generatedScript}
size="xs"
type="button"
title="Sihirbazdaki kurallardan script'i yeniden üretir."
icon={<FaMagic />}
onClick={() => setCode(generatedScript)}
>
Kurallardan üret
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={code}
options={editorOptions}
onMount={handleEditorMount}
onChange={(next) => setCode(next || '')}
/>
</div>
</div>
{helpOpen && helpPanel}
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4 overflow-hidden">
<section className="col-span-7 flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
<div className={cardClass}>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => setPaletteOpen((current) => !current)}
>
<span className="shrink-0 text-gray-400">
{paletteOpen ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
</span>
<FaPlus className="text-indigo-500" size={12} />
<span className="flex-1 text-sm font-semibold">Kural Ekle</span>
</button>
{paletteOpen && (
<div className="flex flex-col gap-3 border-t border-gray-100 px-3 py-3 dark:border-gray-800">
{dialect.groups.map((group) => {
const Icon = group.icon
const groupRecipes = dialect.recipes.filter(
(recipe) => recipe.group === group.key,
)
if (!groupRecipes.length) return null
return (
<div key={group.key}>
<div
className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-400"
title={group.description}
>
<Icon size={10} /> {group.title}
</div>
<div className="flex flex-wrap gap-2">
{groupRecipes.map((recipe) => (
<Button
key={recipe.id}
size="sm"
type="button"
title={`${recipe.summary}\n\nÖrnek: ${recipe.example}`}
onClick={() => addRule(recipe.id)}
>
{recipe.label}
</Button>
))}
</div>
</div>
)
})}
</div>
)}
</div>
{rules.map(renderRule)}
{!rules.length && (
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-xs text-gray-400 dark:border-gray-700">
Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir ve
kod sekmesine anında yansır.
</div>
)}
</section>
<section className={`col-span-5 flex min-h-0 flex-col ${cardClass} p-3`}>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-semibold">
<FaCode className="text-gray-400" />
Script Önizleme
</span>
<span className="rounded-full bg-gray-100 px-2 py-[2px] text-[11px] text-gray-500 dark:bg-gray-800 dark:text-gray-300">
{generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'}
</span>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={generatedScript || '// Kural ekledikçe script burada oluşur'}
options={{ ...editorOptions, readOnly: true }}
/>
</div>
<p className="mt-2 text-[11px] text-gray-400">
İlk satırdaki <code>{'// @builder'}</code> yorumu kuralları saklar; dialog yeniden
ıldığında sihirbaz kuralları buradan geri yükler.
</p>
</section>
</div>
)}
</Dialog.Body>
<Dialog.Footer className="mt-1 flex justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
<Button size="sm" type="button" variant="plain" icon={<FaTimes />} onClick={onClose}>
{cancelLabel}
</Button>
<Button
size="sm"
type="button"
variant="solid"
icon={<FaCheck />}
onClick={() => {
onApply(view === 'wizard' ? generatedScript : code)
onClose()
}}
>
{saveLabel}
</Button>
</Dialog.Footer>
</Dialog>
)
}
export default ScriptBuilderDialog

View file

@ -0,0 +1,167 @@
/**
* Script editörünün yazarken yardım eden kısmı.
*
* Monaco'ya lehçeye özel tamamlama, snippet ve hover dokümantasyonu bağlar.
* Sağlayıcılar dil geneline kaydedilir Monaco başka türlü izin vermiyor ama
* her çağrının başında model karşılaştırılır, böylece sayfadaki diğer JavaScript
* editörleri bu önerileri görmez.
*/
import type { Monaco } from '@monaco-editor/react'
import type * as monacoApi from 'monaco-editor'
import type { ScriptApiEntry, ScriptDialect } from './types'
type Editor = monacoApi.editor.IStandaloneCodeEditor
/** `set('Alan', deger)` → `set`. Tamamlama ve hover eşleşmesi bu ad üzerinden. */
export const apiEntryName = (entry: ScriptApiEntry) =>
entry.name || (entry.signature.match(/[A-Za-z_$][\w$.]*/)?.[0] ?? entry.signature)
/**
* İmzayı snippet'e çevirir: `set('Alan', deger)` → `set(${1:'Alan'}, ${2:deger})`.
* Böylece seçildiğinde imleç ilk argümanın üzerinde durur.
*/
const signatureToSnippet = (signature: string) => {
const match = signature.match(/^([A-Za-z_$][\w$.]*)\s*\((.*)\)\s*$/)
if (!match) return signature
const [, name, rawArgs] = match
if (!rawArgs.trim()) return `${name}()`
// Argümanlar virgülle ayrılır; içinde nesne/dizi olan imzalarda bölmeye
// çalışmak yerine tamamını tek yer tutucu yaparız.
const args = rawArgs.includes('{') || rawArgs.includes('[') ? [rawArgs] : rawArgs.split(',')
const body = args.map((arg, index) => `\${${index + 1}:${arg.trim()}}`).join(', ')
return `${name}(${body})`
}
/** İmleç bir string literalinin içinde mi? Alan adları yalnızca orada önerilir. */
const isInsideString = (line: string, column: number) => {
const before = line.slice(0, column - 1)
const single = (before.match(/'/g) || []).length
const double = (before.match(/"/g) || []).length
return single % 2 === 1 || double % 2 === 1
}
export type ScriptIntelliSenseOptions = {
/** Kaynak listeleri; sırayla tamamlamaya eklenir. */
optionSources: string[]
}
/**
* Editöre lehçenin API'sini bağlar; dönen fonksiyon tüm sağlayıcıları kaldırır.
*/
export const attachScriptIntelliSense = (
monaco: Monaco,
editor: Editor,
// Lehçe alan listesi değiştikçe yeniden kurulur; sağlayıcılar her çağrıda
// güncel nesneyi okusun diye referans değil getter alıyoruz.
getDialect: () => ScriptDialect,
options: ScriptIntelliSenseOptions,
) => {
const modelId = editor.getModel()?.uri.toString()
const owns = (model: monacoApi.editor.ITextModel) => model.uri.toString() === modelId
// Script bir handler gövdesi ya da tek satırlık parça; `return`/`await` gibi
// ifadeler tek başına geçersiz görünür. Kırmızı altı çizgiler yardımcı olmak
// yerine kafa karıştırırdı.
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true,
})
const completion = monaco.languages.registerCompletionItemProvider('javascript', {
triggerCharacters: ['.', "'", '"', '('],
provideCompletionItems: (
model: monacoApi.editor.ITextModel,
position: monacoApi.Position,
) => {
if (!owns(model)) return { suggestions: [] }
const dialect = getDialect()
const word = model.getWordUntilPosition(position)
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
}
const line = model.getLineContent(position.lineNumber)
const inString = isInsideString(line, position.column)
const names = options.optionSources.flatMap((source) =>
dialect.getOptions(source).map((option) => ({ source, option })),
)
const seen = new Set<string>()
const nameSuggestions = names
.filter(({ option }) => (seen.has(option) ? false : seen.add(option)))
.map(({ source, option }) => ({
label: option,
kind: monaco.languages.CompletionItemKind.Field,
detail: source,
// String içindeyken tırnak eklemek çift tırnağa yol açardı.
insertText: inString ? option : `'${option}'`,
range,
sortText: `0_${option}`,
}))
// String içinde yalnızca ad önerilir; fonksiyon adı orada anlamsızdır.
if (inString) return { suggestions: nameSuggestions }
const apiSuggestions = dialect.apiReference.map((entry) => ({
label: apiEntryName(entry),
kind: monaco.languages.CompletionItemKind.Function,
detail: entry.signature,
documentation: entry.description,
insertText: entry.insertText ?? signatureToSnippet(entry.signature),
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
sortText: `1_${apiEntryName(entry)}`,
}))
const snippetSuggestions = dialect.snippets.map((snippet) => ({
label: snippet.label,
kind: monaco.languages.CompletionItemKind.Snippet,
detail: snippet.group,
documentation: { value: `${snippet.description}\n\n\`\`\`js\n${snippet.code}\n\`\`\`` },
insertText: snippet.code,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
sortText: `2_${snippet.label}`,
}))
return { suggestions: [...apiSuggestions, ...snippetSuggestions, ...nameSuggestions] }
},
})
const hover = monaco.languages.registerHoverProvider('javascript', {
provideHover: (model: monacoApi.editor.ITextModel, position: monacoApi.Position) => {
if (!owns(model)) return null
const word = model.getWordAtPosition(position)
if (!word) return null
const entry = getDialect().apiReference.find((item) => apiEntryName(item) === word.word)
if (!entry) return null
return {
range: new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
),
contents: [{ value: `\`\`\`js\n${entry.signature}\n\`\`\`` }, { value: entry.description }],
}
},
})
return () => {
completion.dispose()
hover.dispose()
}
}
/** Editörde imlecin bulunduğu yere metin ekler ve odağı geri verir. */
export const insertAtCursor = (editor: Editor | null, text: string) => {
if (!editor) return
const selection = editor.getSelection()
if (!selection) return
editor.executeEdits('script-builder', [{ range: selection, text, forceMoveMarkers: true }])
editor.focus()
}

View file

@ -0,0 +1,173 @@
/**
* Script Builder'ın ortak sözleşmesi.
*
* Dialog'un kendisi hangi runtime için script yazıldığını bilmez; her şey bir
* "lehçe" (dialect) nesnesinden gelir. Bugün iki lehçe var:
*
* - `form` ListForm editor script'i (`get`/`set`/`notify`, alan bazlı)
* - `designer` Visual Designer event script'i (`refs`/`api`/`event`)
*
* Lehçeler kendi tarif sözlüklerini sarmalar; ortak katman yalnızca kuralları
* listeler, koşulları düzenler ve üretilen script'i gösterir.
*/
import type { IconType } from 'react-icons'
export type ScriptRuleCondition = {
operator: string
source: string
value: string
/** Kaynağın türü — yalnızca birden fazla tür sunan lehçelerde anlamlı. */
kind?: string
}
export type ScriptConditionJoin = 'and' | 'or'
/**
* Kural modeli iki lehçe için de aynıdır; `params`/`fields` içeriğinin anlamını
* tarif belirler. `conditions` yalnızca ikinci koşul eklendiğinde doldurulur
* tek koşullu kurallar eskisiyle birebir aynı JSON'a serileşir.
*/
export type ScriptRule = {
id: string
recipe: string
condition: ScriptRuleCondition
conditions?: ScriptRuleCondition[]
join?: ScriptConditionJoin
params: Record<string, string>
fields?: string[]
trigger?: string
}
export type ScriptParamType = 'option' | 'text' | 'number' | 'select' | 'fieldList'
export type ScriptRecipeParam = {
key: string
label: string
type: ScriptParamType
/** `option` ve `fieldList` için: seçenekleri hangi listeden okuyacağı. */
optionSource?: string
placeholder?: string
help?: string
choices?: { value: string; label: string }[]
optional?: boolean
}
export type ScriptRecipeView = {
id: string
label: string
group: string
summary: string
example: string
params: ScriptRecipeParam[]
/** Koşul aksiyonun argümanıdır; kural `if (...)` ile sarmalanmaz. */
conditionIsArgument?: boolean
}
export type ScriptRecipeGroup = {
key: string
title: string
description: string
icon: IconType
}
export type ScriptOperator = {
value: string
label: string
/** Karşılaştırma değeri ister mi? */
needsValue?: boolean
/** Kaynak alan ister mi? `always` istemez. */
needsSource?: boolean
}
export type ScriptConditionKind = {
value: string
label: string
/** Bu türde kaynak seçicisinin okuyacağı liste; boşsa serbest metin. */
optionSource?: string
placeholder?: string
}
export type ScriptTrigger = { value: string; label: string; help: string }
export type ScriptApiEntry = {
signature: string
description: string
/** Tamamlama listesinde kullanılan ad; verilmezse imzadan türetilir. */
name?: string
/** Monaco'ya verilen snippet gövdesi; verilmezse imza olduğu gibi eklenir. */
insertText?: string
}
export type ScriptSnippet = {
label: string
group: string
description: string
/** Monaco snippet sözdizimi (`${1:alan}`) desteklenir. */
code: string
}
export type ScriptParseResult = {
rules: ScriptRule[]
/** Builder tarafından üretilmiş ve o gün bugündür elle değiştirilmemiş mi? */
inSync: boolean
hasHeader: boolean
}
/**
* Dialog'un lehçeden beklediği her şey. Fonksiyonlar lehçe kurulurken verilen
* bağlamı (alan listesi, ref adları, event bilgisi) kapatır; ortak katman
* bunların hiçbirini görmez.
*/
export interface ScriptDialect {
id: string
/** Başlıkta görünen ad. */
title: string
/** Başlıktaki rozet — düzenlenen alan ya da event. */
subject: string
subjectHint?: string
/** Kod modunda üstte gösterilen tek satırlık açıklama. */
codeHint: string
groups: ScriptRecipeGroup[]
recipes: ScriptRecipeView[]
operators: ScriptOperator[]
/** Birden fazlaysa kural kartında kaynak türü seçicisi çıkar. */
conditionKinds: ScriptConditionKind[]
triggers?: ScriptTrigger[]
apiReference: ScriptApiEntry[]
snippets: ScriptSnippet[]
/** Tamamlama listesine eklenecek adlar (alan, ref, event yolu…). */
getOptions: (optionSource: string) => string[]
getRecipe: (recipeId: string) => ScriptRecipeView | undefined
createRule: (recipeId: string) => ScriptRule
isRuleComplete: (rule: ScriptRule) => boolean
/** Kuralın tek satırlık kodu; `rules` tetikleyici guard'ları için gerekir. */
buildRuleLine: (rule: ScriptRule, rules: ScriptRule[]) => string
buildScript: (rules: ScriptRule[]) => string
parseScript: (script?: string) => ScriptParseResult
describeCondition: (condition: ScriptRuleCondition) => string
describeRule: (rule: ScriptRule) => string
/** Kuralın etkin tetikleyicisi; tetikleyicisiz lehçelerde tanımsız. */
getTrigger?: (rule: ScriptRule) => string
/** Kural kartında gösterilecek uyarı; örneğin eksik bağlam. */
getRuleWarning?: (rule: ScriptRule) => string
}
/** Kuralın bütün koşulları; ilki her zaman `condition`. */
export const scriptRuleConditions = (rule: ScriptRule): ScriptRuleCondition[] => [
rule.condition,
...(rule.conditions ?? []),
]
export const DEFAULT_SCRIPT_OPERATORS: ScriptOperator[] = [
{ value: 'always', label: 'Her zaman' },
{ value: 'equals', label: 'Eşitse', needsValue: true, needsSource: true },
{ value: 'notEquals', label: 'Eşit değilse', needsValue: true, needsSource: true },
{ value: 'contains', label: 'İçeriyorsa', needsValue: true, needsSource: true },
{ value: 'empty', label: 'Boşsa', needsSource: true },
{ value: 'notEmpty', label: 'Doluysa', needsSource: true },
{ value: 'greaterThan', label: 'Büyükse', needsValue: true, needsSource: true },
{ value: 'lessThan', label: 'Küçükse', needsValue: true, needsSource: true },
{ value: 'isTrue', label: 'İşaretliyse', needsSource: true },
{ value: 'isFalse', label: 'İşaretli değilse', needsSource: true },
]

View file

@ -0,0 +1,68 @@
import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog'
import { useMemo } from 'react'
import { createDesignerScriptDialect } from './designerScriptDialect'
import type { SqlDataSourceEvent } from './types'
export type DesignerScriptBuilderDialogProps = {
isOpen: boolean
value?: string
/** Düzenlenen event: başlıkta ve event yolu seçicilerinde kullanılır. */
eventName: string
eventInfo?: SqlDataSourceEvent
/** Event'in sahibi komponentin adı, yalnızca başlık için. */
componentLabel?: string
/** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */
sqlRef: string
/** SqlDataSource Select sonucundaki sütun adları. */
recordFields: string[]
/** Sayfadaki tüm ref adları. */
refNames: string[]
onClose: () => void
onApply: (value: string) => void
}
/**
* Designer event'lerinin script editörü. Ortak Script Builder'ı designer
* lehçesiyle kurar; dialog'un kendisi paylaşılan bileşendir.
*/
function DesignerScriptBuilderDialog({
isOpen,
value,
eventName,
eventInfo,
componentLabel,
sqlRef,
recordFields,
refNames,
onClose,
onApply,
}: DesignerScriptBuilderDialogProps) {
// Çağıran her render'da yeni diziler üretiyor; lehçe içeriklerine göre
// sabitlenmezse editör yazarken sıfırlanırdı.
const recordFieldKey = recordFields.join('|')
const refNameKey = refNames.join('|')
const dialect = useMemo(
() =>
createDesignerScriptDialect({
componentLabel,
eventInfo,
eventName,
recordFields: recordFieldKey ? recordFieldKey.split('|') : [],
refNames: refNameKey ? refNameKey.split('|') : [],
sqlRef,
}),
[componentLabel, eventInfo, eventName, recordFieldKey, refNameKey, sqlRef],
)
return (
<ScriptBuilderDialog
dialect={dialect}
isOpen={isOpen}
value={value}
onApply={onApply}
onClose={onClose}
/>
)
}
export default DesignerScriptBuilderDialog

View file

@ -7,8 +7,10 @@ import apiService from '@/services/api.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatLocaleValue } from '@/utils/localeFormat'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
import {
fromDesignerDate,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerTabSlot,
getDesignerValueByPath,
@ -68,6 +70,52 @@ const acceptDesignerDrag = (event: React.DragEvent<HTMLElement>) => {
event.stopPropagation()
event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}
/**
* "Sürüklenen düğümün içindeyiz" bilgisi ağaçtan aşağı akar. Bir bileşen kendi
* alt ağacının içine taşınamaz; onun dışındaki her hedef kardeşler, üst
* konteynerler, kök geçerlidir ve toolbox'tan bırakmayla birebir aynı geri
* bildirimi alır.
*/
const DesignerDraggedSubtreeContext = React.createContext(false)
/**
* ık bir bırakma bölgesi: üzerine gelindiğinde kendini vurgular. Boş konteyner
* yer tutucuları ve Tabs sekme gövdeleri bunu kullanır.
*/
const useDesignerDropZone = (
interactive: boolean,
onDropPayload: (payload: DesignerDragPayload) => void,
) => {
const [over, setOver] = React.useState(false)
const drag = useDesignerDrag()
React.useEffect(() => {
if (!drag) setOver(false)
}, [drag])
return {
over: over && Boolean(drag),
dragging: Boolean(drag) && interactive,
props: {
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
acceptDesignerDrag(event)
setOver(true)
},
onDragLeave: () => setOver(false),
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setOver(false)
const payload = readDesignerDragPayload(event)
if (payload) onDropPayload(payload)
endDesignerDrag()
},
},
}
}
const getTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
@ -204,13 +252,70 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
...(isSqlDataSourceNode(node.type)
? {
getRecord: () => ({}),
setRecord: designTimeOnly('setRecord'),
getField: () => undefined,
setField: designTimeOnly('setField'),
setFields: designTimeOnly('setFields'),
clearFields: designTimeOnly('clearFields'),
getOriginal: () => ({}),
getChanges: () => ({}),
hasChanges: () => false,
getKey: () => undefined,
getKeyField: () => getSqlDataSourceKeyField(node),
getRows: () => [],
getRowCount: () => 0,
getIndex: () => 0,
getMode: () => 'edit',
isNew: () => false,
isBusy: () => false,
getError: () => '',
setError: designTimeOnly('setError'),
goToRow: designTimeOnly('goToRow'),
nextRow: designTimeOnly('nextRow'),
prevRow: designTimeOnly('prevRow'),
firstRow: designTimeOnly('firstRow'),
lastRow: designTimeOnly('lastRow'),
reload: designTimeOnly('reload'),
newRecord: designTimeOnly('newRecord'),
save: designTimeOnly('save'),
remove: designTimeOnly('remove'),
}
: {}),
// Select/AutoComplete/Menu: seçilen kaydın görünmeyen sütunları. Tasarım
// ekranında yalnızca statik seçenekler ve önizleme değeri elde olduğu için
// erişimciler bunlarla çalışır; çalışma zamanında aynı isimler endpoint
// sonucundaki tüm sütunları okur.
...(isDesignerOptionComponent(node.type)
? (() => {
const currentOption = () => {
const value = overrideProps()[valueProperty] ?? node.props[valueProperty]
if (value && typeof value === 'object') return value as Record<string, unknown>
const options = node.props[getDesignerCollectionProperty(node.type)]
if (!Array.isArray(options)) return null
return (
(options.find(
(option) =>
option &&
typeof option === 'object' &&
(option as Record<string, unknown>).value === value,
) as Record<string, unknown>) ?? null
)
}
return {
getOptions: () => {
const options = node.props[getDesignerCollectionProperty(node.type)]
return Array.isArray(options) ? options : []
},
getSelectedOption: () => currentOption(),
getColumn: (path: string) => getDesignerValueByPath(currentOption(), String(path || '')),
getLabel: () => String(currentOption()?.label ?? ''),
getColumnNames: () =>
getDesignerBindingColumns(
node.bindings?.[getDesignerCollectionProperty(node.type)],
),
}
})()
: {}),
}
})
return refs
@ -1216,6 +1321,95 @@ const NodeView = ({
'Tabs',
'SqlDataSource',
].includes(node.type)
/* ---- Sürükleme geri bildirimi ---------------------------------- */
const drag = useDesignerDrag()
const [dropHint, setDropHint] = React.useState<'before' | 'inside' | 'after' | null>(null)
/** Tabs sekmesi / tablo hücresi gibi slot hedeflerinden hangisinin üzerindeyiz. */
const [overSlot, setOverSlot] = React.useState<string | null>(null)
React.useEffect(() => {
if (drag) return
setDropHint(null)
setOverSlot(null)
}, [drag])
/** Slot hedeflerinin ortak sürükleme davranışı: kabul et ve kendini işaretle. */
const slotDropProps = (slot: string) => ({
onDragOver: (event: React.DragEvent<HTMLElement>) => {
if (!interactive) return
acceptDesignerDrag(event)
setOverSlot(slot)
},
onDragLeave: () => setOverSlot((current) => (current === slot ? null : current)),
})
const dragging = interactive && Boolean(drag)
const isDragSource = drag?.nodeId === node.id
// Kendisi ve alt ağacı dışında her hedef geçerli: kardeşinin yanına, bir üst
// konteynerin içine ya da köke taşımak hep anlamlı işlemler.
const insideDraggedSubtree = React.useContext(DesignerDraggedSubtreeContext) || isDragSource
const canDropInside = acceptsDroppedChildren && !insideDraggedSubtree
const canDropBeside = !insideDraggedSubtree
const dragLabel = drag?.label || drag?.name || 'Bileşen'
/**
* Taşımayı başlatan ortak kod. Hem düğüm gövdesi hem de tutamak bunu kullanır:
* Input/Select/Textarea gibi canlı önizleme kontrolleri Chrome'da üst öğenin
* sürüklenmesini başlatmadığı için, gövdesi tamamen bir kontrolle kaplı bir
* bileşenin tutulabileceği tek yer tutamaktır.
*/
const startNodeDrag = (event: React.DragEvent<HTMLElement>) => {
event.stopPropagation()
const payload = JSON.stringify({ source: 'canvas', nodeId: node.id })
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
event.dataTransfer.setData('text/plain', payload)
// `dragstart` içinde senkron bir state güncellemesi sürüklenen düğümü hemen
// yeniden render eder ve tarayıcı sürüklemeyi iptal eder. Vurguları bir tık
// sonraya bırakınca sürükleme sağlam başlar, geri bildirim de kaybolmaz.
beginDesignerDragSoon({ source: 'canvas', nodeId: node.id, label: node.ref || node.type })
}
/** Pointer'ın dikey konumu bırakmanın anlamını belirler. */
const resolveDropHint = (event: React.DragEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect()
const offset = event.clientY - bounds.top
// Konteynerlerde orta bant "içine", dış çeyrekler "yanına" demektir.
if (canDropInside && offset > bounds.height * 0.25 && offset < bounds.height * 0.75) {
return 'inside' as const
}
return offset < bounds.height / 2 ? ('before' as const) : ('after' as const)
}
/** Bırakmanın karşılığı olan ağaç işlemi. Kenar şeritleri de bunu kullanır. */
const applyDrop = (payload: DesignerDragPayload, hint: 'before' | 'inside' | 'after') => {
const placement = hint === 'before' ? 'before' : 'after'
if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
if (hint === 'inside') onMoveIntoContainer?.(payload.nodeId, node.id)
else onReorder?.(payload.nodeId, node.id, placement)
} else if (payload.source === 'library' && payload.name) {
if (hint === 'inside') onDropComponent?.(payload.name, node.id)
else onDropComponentBeside?.(payload.name, node.id, placement)
}
}
/**
* Düğümler arasındaki boşluk hiçbir düğüme ait değildi; oraya bırakılan bileşen
* canvas'ın köküne, yani sayfanın en altına gidiyordu. Sürükleme sürerken her
* düğüm kendi üst ve alt boşluğunu kaplayan görünmez birer şerit açar, böylece
* "araya bırak" tam olarak nişan alınan yere düşer.
*/
const edgeStripProps = (placement: 'before' | 'after') => ({
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
acceptDesignerDrag(event)
setDropHint(canDropBeside ? placement : null)
},
onDragLeave: () => setDropHint(null),
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setDropHint(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (payload) applyDrop(payload, placement)
},
})
const staticChildren = node.props.children
const hasStaticChildren =
staticChildren !== undefined &&
@ -1317,31 +1511,26 @@ const NodeView = ({
return (
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
<div
className="min-h-14 py-2"
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}}
className={`min-h-14 rounded py-2 transition ${
overSlot === slot
? 'bg-emerald-50 outline outline-2 outline-emerald-500 dark:bg-emerald-950/40'
: dragging
? 'outline-dashed outline-1 outline-sky-400/70'
: ''
}`}
{...slotDropProps(slot)}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
if (!raw) return
try {
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
setOverSlot(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (!payload) return
if (payload.source === 'library' && payload.name) {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
}}
>
@ -1414,38 +1603,29 @@ const NodeView = ({
<td
key={slot}
className={
interactive
? 'border border-slate-200 align-top dark:border-slate-700'
: 'align-top'
overSlot === slot
? 'border-2 border-emerald-500 bg-emerald-50 align-top dark:bg-emerald-950/40'
: dragging
? 'border border-dashed border-sky-400 align-top'
: interactive
? 'border border-slate-200 align-top dark:border-slate-700'
: 'align-top'
}
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
// Must match the source's effectAllowed, otherwise the
// browser rejects the drop and never fires onDrop.
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'move' ? 'move' : 'copy'
}}
{...slotDropProps(slot)}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
event.dataTransfer.getData('text/plain')
if (!raw) return
try {
const payload = JSON.parse(raw)
if (payload.source === 'library') {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
// Lets a component be dragged back into a cell, from
// another cell or from anywhere else on the canvas.
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
} catch {
// Ignore payloads that do not belong to the visual designer.
setOverSlot(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (!payload) return
if (payload.source === 'library' && payload.name) {
onDropComponent?.(payload.name, node.id, slot)
} else if (payload.source === 'canvas' && payload.nodeId) {
// Lets a component be dragged back into a cell, from
// another cell or from anywhere else on the canvas.
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
}
}}
>
@ -1571,22 +1751,13 @@ const NodeView = ({
: children
// An empty container's placeholder is an explicit "inside" target, so it never
// depends on where the pointer happens to sit within the node.
const containerDropZoneProps = {
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
if (interactive) acceptDesignerDrag(event)
},
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
const payload = readDesignerDragPayload(event)
if (!payload) return
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
onMoveIntoContainer?.(payload.nodeId, node.id)
}
},
}
const containerDropZone = useDesignerDropZone(interactive, (payload) => {
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
onMoveIntoContainer?.(payload.nodeId, node.id)
}
})
const containerDropZoneProps = containerDropZone.props
// While a Card holds no child node the drop target stays visible below its own
// content — otherwise a Card filled by its `children` text looks like it takes
// no components. renderElement keeps that text above whatever is passed here.
@ -1595,12 +1766,16 @@ const NodeView = ({
? [
<div
key={`card_drop_${node.id}`}
className={`rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700 ${
hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''
}`}
className={`rounded border border-dashed px-3 py-4 text-center text-xs transition ${
containerDropZone.over
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
: containerDropZone.dragging
? 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
: 'border-slate-300 text-slate-400 dark:border-slate-700'
} ${hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''}`}
{...containerDropZoneProps}
>
Bileşeni buraya bırakın
{containerDropZone.over ? `${dragLabel} buraya bırakılacak` : 'Bileşeni buraya bırakın'}
</div>,
]
: renderedChildren
@ -1610,13 +1785,23 @@ const NodeView = ({
if (refHidden && !interactive) return null
return (
<DesignerDraggedSubtreeContext.Provider value={insideDraggedSubtree}>
<div
className={`group/node relative min-h-[28px] rounded-md ${refHidden ? 'opacity-40' : ''} ${
interactive
? selected
? 'z-10 outline outline-2 outline-sky-500 outline-offset-2'
: 'outline outline-1 outline-transparent hover:outline-sky-300'
: ''
// Sürüklenen düğümün kendisi soluklaşır; nereye gittiği hedeften okunur.
isDragSource ? 'opacity-40' : ''
} ${
dropHint === 'inside'
? 'z-10 bg-emerald-500/10 outline outline-2 outline-emerald-500 outline-offset-2'
: // Sürükleme sürerken içine bırakılabilen her konteyner işaretlenir, böylece
// kullanıcı hedefe varmadan önce nereye bırakabileceğini görür.
dragging && canDropInside
? 'outline-dashed outline-1 outline-sky-400/70 outline-offset-2'
: interactive
? selected
? 'z-10 outline outline-2 outline-sky-500 outline-offset-2'
: 'outline outline-1 outline-transparent hover:outline-sky-300'
: ''
}`}
data-designer-node={node.id}
draggable={interactive}
@ -1630,12 +1815,9 @@ const NodeView = ({
}}
onDragStart={(event) => {
if (!interactive) return
event.stopPropagation()
const payload = JSON.stringify({ source: 'canvas', nodeId: node.id })
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
event.dataTransfer.setData('text/plain', payload)
startNodeDrag(event)
}}
onDragEnd={() => endDesignerDrag()}
onDragOver={(event) => {
if (!interactive) return
event.preventDefault()
@ -1644,37 +1826,79 @@ const NodeView = ({
// the drop and never fires onDrop.
event.dataTransfer.dropEffect =
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
setDropHint(canDropBeside || canDropInside ? resolveDropHint(event) : null)
}}
onDragLeave={() => setDropHint(null)}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
event.stopPropagation()
setDropHint(null)
endDesignerDrag()
const payload = readDesignerDragPayload(event)
if (!payload) return
const bounds = event.currentTarget.getBoundingClientRect()
const offset = event.clientY - bounds.top
// A container claims its middle band as "drop inside"; the outer quarters
// stay reserved for placing the node next to it, which is the only
// meaningful option on a component that cannot host children.
const inside =
acceptsDroppedChildren && offset > bounds.height * 0.25 && offset < bounds.height * 0.75
const placement = offset < bounds.height / 2 ? 'before' : 'after'
if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
if (inside) onMoveIntoContainer?.(payload.nodeId, node.id)
else onReorder?.(payload.nodeId, node.id, placement)
} else if (payload.source === 'library' && payload.name) {
if (inside) onDropComponent?.(payload.name, node.id)
else onDropComponentBeside?.(payload.name, node.id, placement)
}
// Bırakma noktası vurguyla birebir aynı kuraldan hesaplanır; kullanıcı
// ekranda ne gördüyse o olur.
applyDrop(payload, resolveDropHint(event))
}}
>
{/* Üst ve alt boşluğu kaplayan görünmez "araya bırak" şeritleri. */}
{dragging && canDropBeside && (
<>
<div className="absolute inset-x-0 -top-2 z-20 h-2" {...edgeStripProps('before')} />
<div className="absolute inset-x-0 -bottom-2 z-20 h-2" {...edgeStripProps('after')} />
</>
)}
{/*
Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen kontrolle
kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini başlatmaz; tutamak
her bileşen için garanti bir tutma noktası verir. Sürükleme sürerken
gizlenir, aksi halde komşu düğümün bırakma alanını kapatırdı.
*/}
{/* Yanına bırakma çizgisi: bileşen tam olarak buraya girer. */}
{dropHint === 'before' && (
<div className="pointer-events-none absolute inset-x-0 -top-1 z-30 flex items-center gap-1">
<span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{dragLabel} üstüne
</span>
<span className="h-[3px] flex-1 rounded bg-emerald-500" />
</div>
)}
{dropHint === 'after' && (
<div className="pointer-events-none absolute inset-x-0 -bottom-1 z-30 flex items-center gap-1">
<span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{dragLabel} altına
</span>
<span className="h-[3px] flex-1 rounded bg-emerald-500" />
</div>
)}
{dropHint === 'inside' && (
<span className="pointer-events-none absolute left-1 top-1 z-30 rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
{node.type} içine
</span>
)}
{/* Sürükleme sürerken bırakılamayan hedef açıkça söylenir. */}
{dragging && insideDraggedSubtree && !isDragSource && (
<span className="pointer-events-none absolute right-1 top-1 z-30 rounded bg-slate-500 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
buraya taşınamaz
</span>
)}
{interactive && (
<div
className={`absolute right-1 top-1 z-20 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-lg ${selected ? 'flex' : 'hidden'}`}
>
<span className="flex items-center gap-1 px-2 text-[10px] font-semibold">
{/*
Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen canlı
kontrolle kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini
başlatmaz; bu rozet her bileşen için garanti bir tutma noktası verir.
*/}
<span
draggable
className="flex cursor-grab items-center gap-1 px-2 text-[10px] font-semibold active:cursor-grabbing"
title={`${node.ref || node.type} — sürükleyerek taşı`}
onDragStart={startNodeDrag}
onDragEnd={() => endDesignerDrag()}
>
<FaGripVertical /> {node.type}
</span>
<button
@ -1765,6 +1989,7 @@ const NodeView = ({
)}
<span className="hidden">{index}</span>
</div>
</DesignerDraggedSubtreeContext.Provider>
)
}
@ -1801,6 +2026,26 @@ const VisualCanvas = ({
// leaves whatever the last event script set in place.
const refFingerprint = Object.keys(nodesByRef).sort().join('|')
React.useEffect(() => setRefState({}), [refFingerprint])
const drag = useDesignerDrag()
// Sürükleme canvas dışında bırakılırsa da vurgular temizlenmeli.
React.useEffect(() => {
if (typeof window === 'undefined') return
const clear = () => endDesignerDrag()
window.addEventListener('dragend', clear)
window.addEventListener('drop', clear)
return () => {
window.removeEventListener('dragend', clear)
window.removeEventListener('drop', clear)
}
}, [])
const rootDropZone = useDesignerDropZone(interactive, (payload) => {
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, null)
// Kök alana bırakmak düğümü içinde bulunduğu konteynerden çıkarır.
else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, null)
}
})
const refStore = React.useMemo<DesignerRefStore>(
() => ({
state: refState,
@ -1825,12 +2070,15 @@ const VisualCanvas = ({
return (
<DesignerRefContext.Provider value={refStore}>
<div
className="min-h-full p-8 text-slate-900 dark:text-slate-100"
className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${
drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : ''
}`}
onClick={() => interactive && onSelect?.('')}
onDragOver={(event) => interactive && event.preventDefault()}
onDrop={(event) => {
if (!interactive) return
event.preventDefault()
endDesignerDrag()
const raw =
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
if (!raw) return
@ -1843,6 +2091,14 @@ const VisualCanvas = ({
}
}}
>
{drag && interactive && (
<div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
<b>{drag.label || drag.name}</b> sürükleniyor · kesikli çerçeveli alanlar içine alır,
yeşil çizgi araya yerleştirir
</span>
</div>
)}
{nodes.length ? (
<div className="space-y-4">
{nodes.map((node, index) => (
@ -1867,9 +2123,32 @@ const VisualCanvas = ({
onNodePropChange={onNodePropChange}
/>
))}
{/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
konteynerin dışına çıkmanın görünür bir yolu yok. */}
{drag && interactive && (
<div
className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
rootDropZone.over
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
: 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
}`}
{...rootDropZone.props}
>
Sayfanın en altına bırak
</div>
)}
</div>
) : (
<div className="flex min-h-[520px] items-center justify-center rounded-xl border-slate-300 bg-white/70 text-center dark:border-slate-700 dark:bg-slate-900/70">
<div
className={`flex min-h-[520px] items-center justify-center rounded-xl border bg-white/70 text-center transition dark:bg-slate-900/70 ${
rootDropZone.over
? 'border-2 border-dashed border-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40'
: drag && interactive
? 'border-2 border-dashed border-sky-400'
: 'border-slate-300 dark:border-slate-700'
}`}
{...(interactive ? rootDropZone.props : {})}
>
<div className="w-full max-w-2xl px-6">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
+

View file

@ -2,6 +2,7 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
import generatedComponentProps from './generated/componentProps.json'
import {
DESIGNER_DATA_COMPONENT_NAMES,
SQL_DATA_SOURCE_EVENTS,
SQL_DATA_SOURCE_TYPE,
getDesignerCollectionProperty,
isDesignerOptionComponent,
@ -133,6 +134,41 @@ const selectedValue = event?.value ?? null
const selectedLabel = event?.label ?? ''
console.log({ selectedValue, selectedLabel })`,
},
[SQL_DATA_SOURCE_TYPE]: {
onLoad: `// Select cevabı forma yerleştikten sonra çalışır.
// event: { rows, count, record, index }
if (!event.count) notify('Kayıt bulunamadı', 'warning')`,
onRecordChange: `// Aktif kayıt her değiştiğinde çalışır.
// event: { record, index, rows, count }
console.log('Aktif kayıt:', event.record)`,
onFieldChange: `// Kayda değer yazıldığında çalışır.
// event: { field, value, previous, record }
if (event.field === 'Quantity' || event.field === 'UnitPrice') {
const total = Number(event.record.Quantity || 0) * Number(event.record.UnitPrice || 0)
refs.{{sql}}.setField('TotalAmount', Math.round(total * 100) / 100)
}`,
onNewRecord: `// Yeni kayıt moduna geçildiğinde varsayılanları yaz.
refs.{{sql}}.setField('Status', 'Taslak')`,
onModeChange: `// event: { mode, record }
console.log('Mod:', event.mode)`,
onBeforeSave: `// false döndürülürse kayıt iptal edilir.
// event: { record, original, payload, mode, isNew }
if (!event.record.Name) {
notify('Ad alanı zorunludur', 'warning')
return false
}`,
onAfterSave: `// event: { record, response, mode, isNew }
notify(event.isNew ? 'Kayıt eklendi' : 'Kayıt güncellendi', 'success')`,
onBeforeDelete: `// false döndürülürse silme iptal edilir.
// event: { record, key }
if (event.record.Status === 'Onaylı') {
notify('Onaylı kayıt silinemez', 'danger')
return false
}`,
onAfterDelete: `notify('Kayıt silindi', 'success')`,
onError: `// event: { message, action, error, record }
notify(event.message, 'danger')`,
},
}
/**
@ -272,6 +308,23 @@ try {
params: { skipCount: 0 },
})
console.log(result)`,
},
{
group: 'Komponent erişimi',
title: 'Selectin görünmeyen sütunlarını okuma',
description:
'Select yalnızca Label gösterir; Data sekmesinde eklenen sütunlar seçenekle birlikte taşınır.',
code: `// onChange içinde: seçilen kaydın diğer sütunları
const fiyat = refs.{{ref}}.getColumn('unitPrice')
const stok = refs.{{ref}}.getColumn('stockQuantity')
refs.{{sql}}.setFields({ UnitPrice: fiyat, Stock: stok })`,
},
{
group: 'Komponent erişimi',
title: 'Seçilen kaydın tamamı',
description: 'Çoklu seçimde dizi döner.',
code: `const secilen = refs.{{ref}}.getSelectedOption()
console.log(secilen, refs.{{ref}}.getLabel())`,
},
{
group: 'SqlDataSource',
@ -787,6 +840,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
category: 'styling',
},
{ name: 'className', type: 'string', value: '', category: 'styling' },
// Record lifecycle. `category: 'events'` keeps them out of the Properties
// panel and moves them into the JavaScript tab, where they are edited as
// scripts rather than as values.
...SQL_DATA_SOURCE_EVENTS.map((event) => ({
name: event.name,
type: 'function' as const,
value: '',
category: 'events' as const,
description: event.cancelable ? `${event.description} (false → iptal)` : event.description,
})),
],
hooks: [],
}

View file

@ -1,10 +1,12 @@
import {
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerTabSlotValue,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
getSqlDataSourceScriptedEvents,
hasSqlDataSourceUrlParams,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
@ -316,10 +318,47 @@ const sqlIdentifiers = (node: DesignerNode) => {
remove: `sqlDelete_${identifier}`,
previous: `sqlPrev_${identifier}`,
next: `sqlNext_${identifier}`,
// Mirrors of the record/mode state, so an event handler declared further down
// the component body reads the current value instead of a closed over one.
recordRef: `sqlRecordRef_${identifier}`,
modeRef: `sqlModeRef_${identifier}`,
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
}
}
/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */
const sqlEventHandler = (node: DesignerNode, eventName: string) =>
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : ''
const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0
/**
* `await`ed call of an event script, emitted only when the event carries one.
* `payload` is the object literal the script sees as `event`.
*/
const sqlEventCall = (node: DesignerNode, eventName: string, payload: string) => {
const handler = sqlEventHandler(node, eventName)
return handler ? `await ${handler}(${payload})` : ''
}
/**
* Cancelable variant: the command stops when the script returns exactly `false`,
* so a script that returns nothing (the common case) never blocks anything.
*/
const sqlCancelableEventCall = (node: DesignerNode, eventName: string, payload: string) => {
const handler = sqlEventHandler(node, eventName)
return handler ? `if ((await ${handler}(${payload})) === false) return` : ''
}
/** Same, but inside a `try` that already owns the busy flag — needs a reset. */
const sqlErrorEventCall = (node: DesignerNode, action: string) => {
const handler = sqlEventHandler(node, 'onError')
const names = sqlIdentifiers(node)
return handler
? `\n await ${handler}({ message: toSqlErrorMessage(error), action: ${JSON.stringify(action)}, error, record: ${names.recordRef}.current })`
: ''
}
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
const sqlSlotLiteral = (
node: DesignerNode,
@ -364,7 +403,16 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
const [${names.mode}, ${names.setMode}] = React.useState("edit")
const [${names.index}, ${names.setIndex}] = React.useState(0)
const [${names.busy}, ${names.setBusy}] = React.useState(false)
const [${names.error}, ${names.setError}] = React.useState("")
const [${names.error}, ${names.setError}] = React.useState("")${
hasSqlEvents(node)
? `
// Event scripts are declared after these hooks, so they read the record and the
// mode through refs rather than through the closure they were created in.
const ${names.recordRef} = React.useRef(${names.record})
${names.recordRef}.current = ${names.record}
const ${names.modeRef} = React.useRef(${names.mode})`
: ''
}
// The Select key can come from the page URL, which is how a detail page reads
// /api/app/orders/{id} or /api/app/orders?id=… for a single record.
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
@ -385,19 +433,54 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
// unsaved draft is not overwritten by a re-render.
React.useEffect(() => {
${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0))
}, [${names.rows}])
}, [${names.rows}])${
sqlEventHandler(node, 'onLoad')
? `
// Fires once the Select result has settled, whether it carries rows or not —
// an empty result is exactly the case a script wants to react to.
React.useEffect(() => {
if (${selectData} === null || ${selectData} === undefined) return
void ${sqlEventHandler(node, 'onLoad')}({ rows: ${names.rows}, count: ${names.rows}.length, record: ${names.rows}[${names.index}] ?? {}, index: ${names.index} })
}, [${names.rows}])`
: ''
}
React.useEffect(() => {
const row = ${names.rows}[${names.index}]
if (!row) return
${names.setRecord}(row)
${names.setOriginal}(row)
${names.setMode}("edit")
}, [${names.rows}, ${names.index}])
${names.setMode}("edit")${
sqlEventHandler(node, 'onRecordChange')
? `
void ${sqlEventHandler(node, 'onRecordChange')}({ record: row, index: ${names.index}, rows: ${names.rows}, count: ${names.rows}.length })`
: ''
}
}, [${names.rows}, ${names.index}])${
sqlEventHandler(node, 'onModeChange')
? `
// The ref starts at the initial mode, so the first render is not reported as a
// change; only real new/edit transitions reach the script.
React.useEffect(() => {
if (${names.modeRef}.current === ${names.mode}) return
${names.modeRef}.current = ${names.mode}
void ${sqlEventHandler(node, 'onModeChange')}({ mode: ${names.mode}, record: ${names.recordRef}.current })
}, [${names.mode}])`
: ''
}
const ${names.hasKey} = (() => {
const value = readSqlField(${names.record}, ${names.key})
return value !== undefined && value !== null && value !== ""
})()
const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), [])
const ${names.setField} = React.useCallback(${
sqlEventHandler(node, 'onFieldChange')
? `(path, value) => {
${names.setRecord}((current) => setSqlField(current, path, value))
// The script is handed the record with the write already applied, so a
// calculation can read the sibling columns without re-deriving the change.
void ${sqlEventHandler(node, 'onFieldChange')}({ field: path, value, previous: getByPath(${names.recordRef}.current, path), record: setSqlField(${names.recordRef}.current, path, value) })
}`
: `(path, value) => ${names.setRecord}((current) => setSqlField(current, path, value))`
}, [])
const ${names.reload} = React.useCallback(async () => {${
setSelectData
? `
@ -420,16 +503,21 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
try {
await ${names.reload}()
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'reload')}
} finally {
${names.setBusy}(false)
}
}, [${names.reload}])
const ${names.create} = React.useCallback(() => {
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
${names.setError}("")
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
${names.setMode}("new")${
sqlEventHandler(node, 'onNewRecord')
? `
${sqlEventCall(node, 'onNewRecord', '{ record: {} }')}`
: ''
}
}, [])
const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), [])
const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}])
@ -445,6 +533,13 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
${names.setError}("Kaydedilecek bir değişiklik yok.")
return
}${
sqlEventHandler(node, 'onBeforeSave')
? `
// Runs before the busy flag is raised: a vetoed save must leave the form in
// exactly the state the user left it in.
${sqlCancelableEventCall(node, 'onBeforeSave', `{ record: ${names.record}, original: ${names.original}, payload, mode: ${names.mode}, isNew }`)}`
: ''
}
${names.setBusy}(true)
${names.setError}("")
@ -461,28 +556,50 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
${names.setOriginal}(saved)
}
}
${names.setMode}("edit")
${names.setMode}("edit")${
sqlEventHandler(node, 'onAfterSave')
? `
${sqlEventCall(node, 'onAfterSave', `{ record: ${names.record}, response: response?.data, mode: ${names.mode}, isNew }`)}`
: ''
}
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'save')}
} finally {
${names.setBusy}(false)
}
}
const ${names.remove} = async () => {
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
sqlEventHandler(node, 'onBeforeDelete')
? `
// Asked before the confirm dialog, so a script can block the delete without
// the user ever being prompted.
${sqlCancelableEventCall(node, 'onBeforeDelete', `{ record: ${names.record}, key: readSqlField(${names.record}, ${names.key}) }`)}`
: ''
}
// Deleting cannot be undone from the form, so it always asks first.
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
${names.setBusy}(true)
${names.setError}("")
try {
try {${
sqlEventHandler(node, 'onAfterDelete')
? `
const deleted = ${names.record}`
: ''
}
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
await ${names.reload}()
await ${names.reload}()${
sqlEventHandler(node, 'onAfterDelete')
? `
${sqlEventCall(node, 'onAfterDelete', '{ record: deleted }')}`
: ''
}
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'delete')}
} finally {
${names.setBusy}(false)
}
@ -940,12 +1057,31 @@ const refTargetFor = (
const names = sqlIdentifiers(node)
extras.push(
` getRecord: () => ${names.record}`,
` setRecord: (record) => ${names.setRecord}(record || {})`,
` getField: (path) => getByPath(${names.record}, path)`,
` setField: (path, value) => ${names.setField}(path, value)`,
// A patch is the readable way to write several columns at once; each write
// still goes through setField so onFieldChange fires for every column.
` setFields: (patch) => Object.entries(patch || {}).forEach(([path, value]) => ${names.setField}(path, value))`,
` clearFields: (...paths) => paths.flat().forEach((path) => ${names.setField}(path, null))`,
` getOriginal: () => ${names.original}`,
` getChanges: () => toSqlChanges(${names.original}, ${names.record}, ${names.key})`,
` hasChanges: () => Object.keys(toSqlChanges(${names.original}, ${names.record}, ${names.key})).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length > 0`,
` getKey: () => readSqlField(${names.record}, ${names.key})`,
` getKeyField: () => ${names.key}`,
` getRows: () => ${names.rows}`,
` getRowCount: () => ${names.rows}.length`,
` getIndex: () => ${names.index}`,
` getMode: () => ${names.mode}`,
` isNew: () => ${names.mode} === "new"`,
` isBusy: () => ${names.busy}`,
` getError: () => ${names.error}`,
` setError: (message) => ${names.setError}(message == null ? "" : String(message))`,
` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`,
` nextRow: () => ${names.next}()`,
` prevRow: () => ${names.previous}()`,
` firstRow: () => ${names.setIndex}(0)`,
` lastRow: () => ${names.setIndex}(Math.max(0, ${names.rows}.length - 1))`,
` reload: () => ${names.refresh}()`,
` newRecord: () => ${names.create}()`,
` save: () => ${names.save}()`,
@ -958,30 +1094,42 @@ const refTargetFor = (
extras,
}
}
if (formField) {
return {
node,
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
extras,
}
}
if (runtimeState) {
return {
node,
getExpression: runtimeState.stateName,
setStatement: `${runtimeState.setterName}(value)`,
extras,
}
}
const valueProperty =
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
return {
node,
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
extras,
const base = formField
? {
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
}
: runtimeState
? {
getExpression: runtimeState.stateName,
setStatement: `${runtimeState.setterName}(value)`,
}
: {
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
}
// Select/AutoComplete/Menu ekranda yalnızca Label gösterir, ama seçenek nesnesi
// kaynak satırın bütün sütunlarını taşır. Bu erişimciler sayesinde bir script
// görünmeyen sütunları okuyup başka bir komponente ya da kayda yazabilir.
if (isDesignerOptionComponent(node.type)) {
const optionsExpression = optionCollectionExpression(node)
const selected = `findDesignerOption(${optionsExpression}, ${base.getExpression})`
const columns = getDesignerBindingColumns(node.bindings?.[getDesignerCollectionProperty(node.type)])
extras.push(
` getOptions: () => ${optionsExpression}`,
` getSelectedOption: () => ${selected}`,
` getColumn: (path) => readDesignerOptionColumn(${selected}, path)`,
` getLabel: () => readDesignerOptionColumn(${selected}, "label") ?? ""`,
// Tasarımda bildirilen ek sütunlar; bir script hangi sütunların garanti
// altında olduğunu buradan öğrenebilir.
` getColumnNames: () => ${JSON.stringify(columns)}`,
)
}
return { node, ...base, extras }
}
/**
@ -1018,7 +1166,26 @@ const refRuntimeCode = (targets: RefTarget[]) => {
})
.join(',\n')
return ` // Component references. Every node on the canvas is addressable by its ref,
// Only emitted when an option component is actually addressable by ref.
const optionHelpers = targets.some((target) => isDesignerOptionComponent(target.node.type))
? ` // Seçili option nesnesi: bir Select değeri kimi zaman option'ın kendisi, kimi
// zaman kayda yazılan ham value olur; ikisi de aynı nesneye çözülür.
const findDesignerOption = (options, value) => {
const list = Array.isArray(options) ? options : []
if (Array.isArray(value)) return value.map((item) => findDesignerOption(list, item)).filter(Boolean)
if (value && typeof value === "object") return value
return list.find((option) => option?.value === value)
?? list.find((option) => String(option?.value ?? "") === String(value ?? ""))
?? null
}
const readDesignerOptionColumn = (option, path) => {
if (Array.isArray(option)) return option.map((item) => readDesignerOptionColumn(item, path))
return String(path || "").split(".").filter(Boolean).reduce((current, key) => current?.[key], option)
}
`
: ''
return `${optionHelpers} // Component references. Every node on the canvas is addressable by its ref,
// so an event script can read or change another component without prop drilling.
const [designerRefState, setDesignerRefState] = React.useState({})
const designerRefProps = (name) => designerRefState[name]?.props || {}
@ -1110,6 +1277,19 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
)
}
// A SqlDataSource event is not a React prop: it is called by the container's
// own runtime with a single payload object, and always awaited so a script
// can veto a command or finish an API call before the save continues.
if (isSqlDataSourceNode(node.type)) {
getSqlDataSourceScriptedEvents(node).forEach((event) => {
const script = node.events[event.name].trim()
handlers.push(
` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`,
)
})
visit(node.children, { sourceId: node.id, setterName: sqlIdentifiers(node).setField })
return
}
const eventNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())

View file

@ -0,0 +1,66 @@
/**
* Designer sürükleme durumu.
*
* Bırakılabilecek yerleri vurgulayabilmek için canvas'ın, sürüklemenin ne zaman
* başladığını bilmesi gerekir. Kaynak toolbox olduğunda bu bilgi başka bir React
* ağacından gelir ve `dataTransfer` yalnızca drop anında okunabilir bu yüzden
* sürükleme modül seviyesinde tutulup ilgilenen her bileşene yayınlanır.
*/
import { useSyncExternalStore } from 'react'
export interface DesignerDragInfo {
source: 'library' | 'canvas'
/** Toolbox'tan sürüklenen komponentin adı. */
name?: string
/** Canvas'tan sürüklenen düğümün id'si. */
nodeId?: string
/** Rozetlerde gösterilen etiket. */
label?: string
}
let designerDrag: DesignerDragInfo | null = null
let pendingStart: ReturnType<typeof setTimeout> | null = null
const listeners = new Set<() => void>()
const emit = () => listeners.forEach((listener) => listener())
export const beginDesignerDrag = (info: DesignerDragInfo) => {
designerDrag = info
emit()
}
/**
* `dragstart` sırasında yapılan senkron bir state güncellemesi sürüklenen DOM
* düğümünü hemen yeniden render eder ve tarayıcı sürüklemeyi iptal eder bu,
* HTML5 sürükleme API'sinin bilinen tuzağı. Vurguları bir sonraki tik'e
* bırakınca sürükleme sağlam başlar ve geri bildirim yine anında görünür.
*/
export const beginDesignerDragSoon = (info: DesignerDragInfo) => {
if (pendingStart) clearTimeout(pendingStart)
pendingStart = setTimeout(() => {
pendingStart = null
beginDesignerDrag(info)
}, 0)
}
export const endDesignerDrag = () => {
if (pendingStart) {
clearTimeout(pendingStart)
pendingStart = null
}
if (!designerDrag) return
designerDrag = null
emit()
}
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
const getSnapshot = () => designerDrag
const getServerSnapshot = () => null
export const useDesignerDrag = () => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

View file

@ -0,0 +1,245 @@
/**
* Visual Designer event script'inin ortak Script Builder lehçesi.
*
* `designerScriptRecipes.ts` içindeki sözlüğü ortak dialog'un sözleşmesine
* çevirir; kural motoru ve `// @builder` başlığı değişmez.
*/
import {
DEFAULT_SCRIPT_OPERATORS,
type ScriptDialect,
type ScriptRecipeGroup,
type ScriptRecipeParam,
type ScriptRecipeView,
type ScriptRule,
type ScriptSnippet,
} from '@/components/scriptBuilder/types'
import { FaBell, FaDatabase, FaMousePointer, FaPlug, FaSignOutAlt } from 'react-icons/fa'
import {
buildDesignerRuleLine,
buildDesignerScript,
createDesignerRule,
describeDesignerCondition,
designerRecipeGroups,
designerRecipes,
designerRecipesById,
designerRuntimeApiReference,
isDesignerRuleComplete,
parseDesignerScript,
type DesignerRecipeGroupKey,
type DesignerRecipeId,
type DesignerRecipeParam,
type DesignerRuleCondition,
type DesignerScriptContext,
type DesignerScriptRule,
} from './designerScriptRecipes'
import type { SqlDataSourceEvent } from './types'
const groupIcons: Record<DesignerRecipeGroupKey, ScriptRecipeGroup['icon']> = {
record: FaDatabase,
component: FaMousePointer,
interaction: FaBell,
integration: FaPlug,
flow: FaSignOutAlt,
}
const RECORD_SOURCE = 'recordField'
const REF_SOURCE = 'refName'
const EVENT_SOURCE = 'eventPath'
const toParam = (param: DesignerRecipeParam): ScriptRecipeParam => ({
key: param.key,
label: param.label,
type:
param.type === 'recordField' || param.type === 'refName' || param.type === 'eventPath'
? 'option'
: param.type,
optionSource:
param.type === 'recordField'
? RECORD_SOURCE
: param.type === 'refName'
? REF_SOURCE
: param.type === 'eventPath'
? EVENT_SOURCE
: param.type === 'fieldList'
? RECORD_SOURCE
: undefined,
placeholder: param.placeholder,
help: param.help,
choices: param.choices,
optional: param.optional,
})
/** Kod editöründe tek tıkla eklenebilen çok satırlı kalıplar. */
const snippets = (sqlRef: string): ScriptSnippet[] => {
const sql = sqlRef || 'sqlDataSource1'
return [
{
label: 'Alan değişimine göre hesapla',
group: 'Kayıt',
description: 'Sadece ilgili sütunlar değişince hesaplar.',
code: `if (['\${1:Quantity}', '\${2:UnitPrice}'].includes(event.field)) {\n const total = Number(event.record.\${1:Quantity} || 0) * Number(event.record.\${2:UnitPrice} || 0)\n refs.${sql}.setField('\${3:TotalAmount}', Math.round(total * 100) / 100)\n}`,
},
{
label: 'Kaydetmeden önce doğrula',
group: 'Akış',
description: 'Eksik alan varsa işlemi iptal eder.',
code: `const eksik = ['\${1:Name}', '\${2:Code}'].filter((column) => !event.record[column])\nif (eksik.length) {\n notify('Zorunlu alanlar: ' + eksik.join(', '), 'warning')\n return false\n}`,
},
{
label: 'Çok koşullu blok',
group: 'Akış',
description: 'VE / VEYA ile birleşen koşullar.',
code: `if (refs.${sql}.getMode() === 'new' || Number(refs.${sql}.getField('\${1:Total}') || 0) > \${2:1000}) {\n \${3:notify('Kontrol edin')}\n}`,
},
{
label: 'Toplu alan yazma',
group: 'Kayıt',
description: 'Birden fazla sütunu tek çağrıda doldurur.',
code: `refs.${sql}.setFields({ \${1:Status}: '\${2:Taslak}', \${3:Currency}: '\${4:TRY}' })`,
},
{
label: 'Komponent görünürlüğü',
group: 'Komponent',
description: 'Koşula göre gösterir veya gizler.',
code: `refs.\${1:card1}.setVisible(refs.${sql}.getMode() === '\${2:edit}')`,
},
{
label: 'API çağrısı ve hata yakalama',
group: 'Entegrasyon',
description: 'Çağrı başarısızsa bildirim gösterir.',
code: `try {\n const sonuc = await api.get('\${1:/api/app/customer/1}')\n refs.${sql}.setField('\${2:Title}', sonuc?.\${3:name})\n} catch (error) {\n notify(api.errorMessage(error), 'danger')\n}`,
},
{
label: 'Satırlar arasında dolaş',
group: 'Kayıt',
description: 'Select sonucundaki tüm satırları gezer.',
code: `refs.${sql}.getRows().forEach((row, index) => {\n \${1:console.log(index, row)}\n})`,
},
{
label: 'Değişiklik varsa kaydet',
group: 'Kayıt',
description: 'Boşuna istek atmaz.',
code: `if (refs.${sql}.hasChanges()) await refs.${sql}.save()`,
},
{
label: 'Event içeriğini incele',
group: 'Akış',
description: 'Geliştirirken event nesnesinin ne taşıdığını görmek için.',
code: `console.log('\${1:event}', JSON.parse(JSON.stringify(event ?? null)))`,
},
]
}
export type DesignerScriptDialectContext = {
/** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */
sqlRef: string
/** Select sonucundaki sütun adları. */
recordFields: string[]
/** Sayfadaki tüm ref adları. */
refNames: string[]
/** Düzenlenen event. */
eventName: string
eventInfo?: SqlDataSourceEvent
/** Event'in sahibi komponentin adı; başlıkta gösterilir. */
componentLabel?: string
}
export const createDesignerScriptDialect = (
context: DesignerScriptDialectContext,
): ScriptDialect => {
const sqlContext: DesignerScriptContext = {
sqlRef: context.sqlRef,
cancelable: Boolean(context.eventInfo?.cancelable),
}
const sorted = (values: string[]) =>
Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right))
const recordFields = sorted(context.recordFields)
const refNames = sorted(context.refNames)
const eventPaths = (context.eventInfo?.payload ?? []).map((item) => item.key)
// "İşlemi iptal et" yalnızca false döndürebilen event'lerde anlamlı; diğer
// event'lerde üretilen `return false` sessizce hiçbir şey yapmazdı.
const usableRecipes = designerRecipes.filter(
(recipe) => !recipe.cancelableOnly || context.eventInfo?.cancelable,
)
const recipeViews: ScriptRecipeView[] = usableRecipes.map((recipe) => ({
id: recipe.id,
label: recipe.label,
group: recipe.group,
summary: recipe.summary,
example: recipe.example,
conditionIsArgument: recipe.conditionIsArgument,
params: recipe.params.map(toParam),
}))
const asDesignerRule = (rule: ScriptRule) => rule as unknown as DesignerScriptRule
return {
id: 'designer',
title: 'Event Script',
subject: `${context.componentLabel ? `${context.componentLabel}.` : ''}${context.eventName}`,
subjectHint: context.eventInfo?.description,
codeHint: context.eventInfo?.cancelable
? 'event / refs / api / notify hazır. false döndürürsen işlem iptal edilir.'
: 'event / refs / api / notify hazır. Ctrl+Space tamamlama listesini açar.',
groups: designerRecipeGroups.map((group) => ({
key: group.key,
title: group.title,
description: group.description,
icon: groupIcons[group.key],
})),
recipes: recipeViews,
operators: DEFAULT_SCRIPT_OPERATORS,
conditionKinds: [
{ value: 'record', label: 'Kayıt sütunu', optionSource: RECORD_SOURCE },
{ value: 'ref', label: 'Komponent değeri', optionSource: REF_SOURCE },
{ value: 'event', label: 'Event verisi', optionSource: EVENT_SOURCE },
{ value: 'expression', label: 'Serbest ifade', placeholder: `refs.${context.sqlRef}.getMode()` },
],
apiReference: designerRuntimeApiReference,
snippets: snippets(context.sqlRef),
getOptions: (optionSource) =>
optionSource === RECORD_SOURCE
? recordFields
: optionSource === REF_SOURCE
? refNames
: optionSource === EVENT_SOURCE
? eventPaths
: [],
getRecipe: (recipeId) => recipeViews.find((recipe) => recipe.id === recipeId),
createRule: (recipeId) =>
createDesignerRule(recipeId as DesignerRecipeId) as unknown as ScriptRule,
isRuleComplete: (rule) => isDesignerRuleComplete(asDesignerRule(rule), sqlContext),
buildRuleLine: (rule) => buildDesignerRuleLine(asDesignerRule(rule), sqlContext),
buildScript: (rules) => buildDesignerScript(rules.map(asDesignerRule), sqlContext),
parseScript: (script) => {
const parsed = parseDesignerScript(script, sqlContext)
return { ...parsed, rules: parsed.rules as unknown as ScriptRule[] }
},
describeCondition: (condition) =>
describeDesignerCondition(condition as unknown as DesignerRuleCondition),
describeRule: (rule) => {
const recipe = designerRecipesById.get(rule.recipe as DesignerRecipeId)
if (!recipe) return ''
const action = recipe.describe(asDesignerRule(rule))
if (recipe.conditionIsArgument) return action
const conditions = [rule.condition, ...(rule.conditions ?? [])]
.map((condition) =>
describeDesignerCondition(condition as unknown as DesignerRuleCondition),
)
.filter((text) => text !== 'Her zaman')
const prefix = conditions.length
? conditions.join(rule.join === 'or' ? ' veya ' : ' ve ')
: 'Her zaman'
return `${prefix}${action}`
},
getRuleWarning: (rule) => {
const recipe = designerRecipesById.get(rule.recipe as DesignerRecipeId)
return recipe?.needsSqlRef && !context.sqlRef
? 'Bu kural bir SqlDataSource ref adı gerektiriyor.'
: ''
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,20 @@ export interface DesignerBinding {
path: string
labelPath?: string
valuePath?: string
/**
* Label ve Value dışında, seçilen kaydın script ile okunabilmesi istenen
* sütunlar. Ekranda hiçbiri görünmez; `refs.<ad>.getColumn('sutun')` ile
* okunup başka bir komponentin değerine ya da SqlDataSource kaydına yazılır.
*/
columns?: string[]
}
/** Bir option bağlantısının bildirilmiş ek sütunları. */
export const getDesignerBindingColumns = (binding?: DesignerBinding): string[] =>
Array.isArray(binding?.columns)
? binding.columns.filter((column): column is string => typeof column === 'string' && !!column)
: []
/**
* Data sources are no longer read-only: an endpoint can also be attached as the
* insert/update/delete command of a SqlDataSource, mirroring ASP.NET's
@ -457,6 +469,136 @@ export const SQL_DATA_SOURCE_SLOTS: readonly SqlDataSourceSlot[] = [
export const getSqlDataSourceSlot = (property: string) =>
SQL_DATA_SOURCE_SLOTS.find((slot) => slot.property === property)
/**
* Lifecycle of the record the container owns. Every entry is emitted by the code
* generator as a handler that receives a single `event` object, so a script never
* has to know which piece of React state actually fired it.
*
* A `cancelable` event stops the command when the script returns `false` that
* is the only way a Save or a Delete can be vetoed from configuration.
*/
export interface SqlDataSourceEvent {
name: string
label: string
description: string
cancelable?: boolean
/** Fields of the `event` object; drives the builder's condition/param pickers. */
payload: { key: string; description: string }[]
}
export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [
{
name: 'onLoad',
label: 'Select tamamlandı',
description: 'Select endpointi cevap verdiğinde, satırlar forma yerleştikten sonra çalışır.',
payload: [
{ key: 'rows', description: 'Select sonucundaki tüm satırlar' },
{ key: 'count', description: 'Satır sayısı' },
{ key: 'record', description: 'Aktif satır' },
{ key: 'index', description: 'Aktif satırın sırası' },
],
},
{
name: 'onRecordChange',
label: 'Aktif kayıt değişti',
description: 'Satırlar arasında gezinildiğinde veya yeni kayıt yüklendiğinde çalışır.',
payload: [
{ key: 'record', description: 'Yeni aktif kayıt' },
{ key: 'index', description: 'Satır sırası' },
{ key: 'rows', description: 'Tüm satırlar' },
{ key: 'count', description: 'Satır sayısı' },
],
},
{
name: 'onFieldChange',
label: 'Alan değişti',
description: 'İçerideki bir komponent kayda değer yazdığında çalışır; hesaplama için idealdir.',
payload: [
{ key: 'field', description: 'Değişen sütun adı' },
{ key: 'value', description: 'Yeni değer' },
{ key: 'previous', description: 'Önceki değer' },
{ key: 'record', description: 'Değişiklik uygulanmış kayıt' },
],
},
{
name: 'onNewRecord',
label: 'Yeni kayıt',
description: 'Yeni butonuyla boş kayda geçildiğinde çalışır; varsayılan değer yazmak için.',
payload: [{ key: 'record', description: 'Boş kayıt' }],
},
{
name: 'onModeChange',
label: 'Mod değişti',
description: 'Kayıt new/edit modları arasında geçtiğinde çalışır.',
payload: [
{ key: 'mode', description: '"new" veya "edit"' },
{ key: 'record', description: 'Güncel kayıt' },
],
},
{
name: 'onBeforeSave',
label: 'Kaydetmeden önce',
description: 'Save çağrılmadan önce çalışır. Script false döndürürse kayıt iptal edilir.',
cancelable: true,
payload: [
{ key: 'record', description: 'Kaydedilecek kayıt' },
{ key: 'original', description: 'Yüklendiği andaki hali' },
{ key: 'payload', description: 'Endpointe gidecek gövde (Update ise sadece değişenler)' },
{ key: 'mode', description: '"new" veya "edit"' },
{ key: 'isNew', description: 'Insert mi çalışacak?' },
],
},
{
name: 'onAfterSave',
label: 'Kaydettikten sonra',
description: 'Insert/Update başarıyla tamamlandığında çalışır.',
payload: [
{ key: 'record', description: 'Gönderilen kayıt' },
{ key: 'response', description: 'Endpoint cevabı' },
{ key: 'mode', description: 'Kaydetme anındaki mod' },
{ key: 'isNew', description: 'Insert mi çalıştı?' },
],
},
{
name: 'onBeforeDelete',
label: 'Silmeden önce',
description: 'Onay kutusundan önce çalışır. Script false döndürürse silme iptal edilir.',
cancelable: true,
payload: [
{ key: 'record', description: 'Silinecek kayıt' },
{ key: 'key', description: 'Key sütunundaki değer' },
],
},
{
name: 'onAfterDelete',
label: 'Sildikten sonra',
description: 'Delete endpointi başarıyla tamamlandığında çalışır.',
payload: [{ key: 'record', description: 'Silinen kayıt' }],
},
{
name: 'onError',
label: 'Hata oluştu',
description: 'Select/Insert/Update/Delete sırasında hata alındığında çalışır.',
payload: [
{ key: 'message', description: 'Kullanıcıya gösterilen hata metni' },
{ key: 'action', description: '"reload" | "save" | "delete"' },
{ key: 'error', description: 'Ham hata nesnesi' },
{ key: 'record', description: 'Hata anındaki kayıt' },
],
},
] as const
export const SQL_DATA_SOURCE_EVENT_NAMES: readonly string[] = SQL_DATA_SOURCE_EVENTS.map(
(event) => event.name,
)
export const getSqlDataSourceEvent = (name: string) =>
SQL_DATA_SOURCE_EVENTS.find((event) => event.name === name)
/** Scripted events of a SqlDataSource node, in lifecycle order. */
export const getSqlDataSourceScriptedEvents = (node: DesignerNode) =>
SQL_DATA_SOURCE_EVENTS.filter((event) => Boolean(node.events?.[event.name]?.trim()))
export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) =>
String(node.props?.[property] ?? '').trim()

View file

@ -91,159 +91,159 @@ export const ROUTES_ENUM = {
accessDenied: '/admin/access-denied',
coordinator: {
videoroom: {
dashboard: '/admin/coordinator/videoroom/dashboard',
roomList: '/admin/coordinator/videoroom/rooms',
roomDetail: '/admin/coordinator/videoroom/room/:id',
planning: '/admin/coordinator/videoroom/planning/:id',
},
exams: '/admin/coordinator/exams',
examDetail: '/admin/coordinator/exam/:id',
assignments: '/admin/coordinator/assignments',
assignmentDetail: '/admin/coordinator/assignment/:id',
tests: '/admin/coordinator/tests',
testDetail: '/admin/coordinator/test/:id',
},
// coordinator: {
// videoroom: {
// dashboard: '/admin/coordinator/videoroom/dashboard',
// roomList: '/admin/coordinator/videoroom/rooms',
// roomDetail: '/admin/coordinator/videoroom/room/:id',
// planning: '/admin/coordinator/videoroom/planning/:id',
// },
// exams: '/admin/coordinator/exams',
// examDetail: '/admin/coordinator/exam/:id',
// assignments: '/admin/coordinator/assignments',
// assignmentDetail: '/admin/coordinator/assignment/:id',
// tests: '/admin/coordinator/tests',
// testDetail: '/admin/coordinator/test/:id',
// },
supplychain: {
materialTypes: '/admin/supplychain/materials/types',
materialGroups: '/admin/supplychain/materials/groups',
materials: '/admin/supplychain/materials',
materialsNew: '/admin/supplychain/materials/new',
materialsEdit: '/admin/supplychain/materials/edit/:id',
materialsDetail: '/admin/supplychain/materials/detail/:id',
suppliers: '/admin/supplychain/suppliers',
suppliersNew: '/admin/supplychain/suppliers/new',
suppliersEdit: '/admin/supplychain/suppliers/edit/:id',
suppliersDetail: '/admin/supplychain/suppliers/:id',
requests: '/admin/supplychain/requests',
requestsNew: '/admin/supplychain/requests/new',
requestsEdit: '/admin/supplychain/requests/edit/:id',
requestsView: '/admin/supplychain/requests/view/:id',
requisitions: '/admin/supplychain/requisitions',
requisitionsNew: '/admin/supplychain/requisitions/new',
requisitionsEdit: '/admin/supplychain/requisitions/edit/:id',
requisitionsDetail: '/admin/supplychain/requisitions/:id',
quotations: '/admin/supplychain/quotations',
quotationsNew: '/admin/supplychain/quotations/new',
quotationsEdit: '/admin/supplychain/quotations/edit/:id',
quotationsView: '/admin/supplychain/quotations/view/:id',
approvals: '/admin/supplychain/approvals',
orders: '/admin/supplychain/orders',
ordersNew: '/admin/supplychain/orders/new',
ordersEdit: '/admin/supplychain/orders/edit/:id',
ordersView: '/admin/supplychain/orders/view/:id',
delivery: '/admin/supplychain/delivery',
},
// supplychain: {
// materialTypes: '/admin/supplychain/materials/types',
// materialGroups: '/admin/supplychain/materials/groups',
// materials: '/admin/supplychain/materials',
// materialsNew: '/admin/supplychain/materials/new',
// materialsEdit: '/admin/supplychain/materials/edit/:id',
// materialsDetail: '/admin/supplychain/materials/detail/:id',
// suppliers: '/admin/supplychain/suppliers',
// suppliersNew: '/admin/supplychain/suppliers/new',
// suppliersEdit: '/admin/supplychain/suppliers/edit/:id',
// suppliersDetail: '/admin/supplychain/suppliers/:id',
// requests: '/admin/supplychain/requests',
// requestsNew: '/admin/supplychain/requests/new',
// requestsEdit: '/admin/supplychain/requests/edit/:id',
// requestsView: '/admin/supplychain/requests/view/:id',
// requisitions: '/admin/supplychain/requisitions',
// requisitionsNew: '/admin/supplychain/requisitions/new',
// requisitionsEdit: '/admin/supplychain/requisitions/edit/:id',
// requisitionsDetail: '/admin/supplychain/requisitions/:id',
// quotations: '/admin/supplychain/quotations',
// quotationsNew: '/admin/supplychain/quotations/new',
// quotationsEdit: '/admin/supplychain/quotations/edit/:id',
// quotationsView: '/admin/supplychain/quotations/view/:id',
// approvals: '/admin/supplychain/approvals',
// orders: '/admin/supplychain/orders',
// ordersNew: '/admin/supplychain/orders/new',
// ordersEdit: '/admin/supplychain/orders/edit/:id',
// ordersView: '/admin/supplychain/orders/view/:id',
// delivery: '/admin/supplychain/delivery',
// },
maintenance: {
equipment: '/admin/maintenance/equipment',
equipmentNew: '/admin/maintenance/equipment/new',
equipmentEdit: '/admin/maintenance/equipment/edit/:id',
equipmentDetail: '/admin/maintenance/equipment/:id',
workcenters: '/admin/maintenance/workcenters',
plans: '/admin/maintenance/plans',
calendar: '/admin/maintenance/calendar',
teams: '/admin/maintenance/teams',
faults: '/admin/maintenance/faults',
workorders: '/admin/maintenance/workorders',
workordersNew: '/admin/maintenance/workorders/new',
workordersDetail: '/admin/maintenance/workorders/:id',
},
// maintenance: {
// equipment: '/admin/maintenance/equipment',
// equipmentNew: '/admin/maintenance/equipment/new',
// equipmentEdit: '/admin/maintenance/equipment/edit/:id',
// equipmentDetail: '/admin/maintenance/equipment/:id',
// workcenters: '/admin/maintenance/workcenters',
// plans: '/admin/maintenance/plans',
// calendar: '/admin/maintenance/calendar',
// teams: '/admin/maintenance/teams',
// faults: '/admin/maintenance/faults',
// workorders: '/admin/maintenance/workorders',
// workordersNew: '/admin/maintenance/workorders/new',
// workordersDetail: '/admin/maintenance/workorders/:id',
// },
warehouse: {
definitions: '/admin/warehouse/definitions',
tracking: '/admin/warehouse/tracking',
putaway: '/admin/warehouse/putaway',
receipt: '/admin/warehouse/receipt',
issue: '/admin/warehouse/issue',
transfer: '/admin/warehouse/transfer',
inventory: '/admin/warehouse/inventory',
movements: '/admin/warehouse/movements',
movementDetail: '/admin/warehouse/movements/:id',
stocklevel: '/admin/warehouse/stocklevel',
warehouses: '/admin/warehouse/warehouses',
warehouseNew: '/admin/warehouse/new',
warehouseEdit: '/admin/warehouse/edit/:id',
warehouseDetail: '/admin/warehouse/warehouses/:id',
},
// warehouse: {
// definitions: '/admin/warehouse/definitions',
// tracking: '/admin/warehouse/tracking',
// putaway: '/admin/warehouse/putaway',
// receipt: '/admin/warehouse/receipt',
// issue: '/admin/warehouse/issue',
// transfer: '/admin/warehouse/transfer',
// inventory: '/admin/warehouse/inventory',
// movements: '/admin/warehouse/movements',
// movementDetail: '/admin/warehouse/movements/:id',
// stocklevel: '/admin/warehouse/stocklevel',
// warehouses: '/admin/warehouse/warehouses',
// warehouseNew: '/admin/warehouse/new',
// warehouseEdit: '/admin/warehouse/edit/:id',
// warehouseDetail: '/admin/warehouse/warehouses/:id',
// },
projects: {
list: '/admin/projects',
new: '/admin/projects/new',
edit: '/admin/projects/edit/:id',
detail: '/admin/projects/:id',
tasks: '/admin/projects/tasks',
phases: '/admin/projects/phases',
activities: '/admin/projects/activities',
workload: '/admin/projects/workload',
costTracking: '/admin/projects/cost-tracking',
dailyUpdates: '/admin/projects/daily-updates',
},
// projects: {
// list: '/admin/projects',
// new: '/admin/projects/new',
// edit: '/admin/projects/edit/:id',
// detail: '/admin/projects/:id',
// tasks: '/admin/projects/tasks',
// phases: '/admin/projects/phases',
// activities: '/admin/projects/activities',
// workload: '/admin/projects/workload',
// costTracking: '/admin/projects/cost-tracking',
// dailyUpdates: '/admin/projects/daily-updates',
// },
hr: {
employees: '/admin/hr/employees',
employeesNew: '/admin/hr/employees/new',
employeesEdit: '/admin/hr/employees/edit/:id',
employeesDetail: '/admin/hr/employees/:id',
departments: '/admin/hr/departments',
jobPositions: '/admin/hr/job-positions',
employmentTypes: '/admin/hr/employment-types',
organization: '/admin/hr/organization',
badges: '/admin/hr/badges',
leaveManagement: '/admin/hr/leave-management',
overtimes: '/admin/hr/overtimes-management',
payroll: '/admin/hr/payroll',
costCenters: '/admin/hr/cost-centers',
evaluationTemplates: '/admin/hr/360-templates',
evaluation: '/admin/hr/360-evaluation',
},
// hr: {
// employees: '/admin/hr/employees',
// employeesNew: '/admin/hr/employees/new',
// employeesEdit: '/admin/hr/employees/edit/:id',
// employeesDetail: '/admin/hr/employees/:id',
// departments: '/admin/hr/departments',
// jobPositions: '/admin/hr/job-positions',
// employmentTypes: '/admin/hr/employment-types',
// organization: '/admin/hr/organization',
// badges: '/admin/hr/badges',
// leaveManagement: '/admin/hr/leave-management',
// overtimes: '/admin/hr/overtimes-management',
// payroll: '/admin/hr/payroll',
// costCenters: '/admin/hr/cost-centers',
// evaluationTemplates: '/admin/hr/360-templates',
// evaluation: '/admin/hr/360-evaluation',
// },
crm: {
customers: '/admin/crm/customers',
customersNew: '/admin/crm/customers/new',
customersEdit: '/admin/crm/customers/edit/:id',
customersDetail: '/admin/crm/customers/:id',
salesTeams: '/admin/crm/sales-teams',
salesTeamsNew: '/admin/crm/sales-teams/new',
salesTeamsEdit: '/admin/crm/sales-teams/edit/:id',
salesTeamsDetail: '/admin/crm/sales-teams/:id',
lossReasons: '/admin/crm/loss-reasons',
opportunities: '/admin/crm/opportunities',
opportunitiesNew: '/admin/crm/opportunities/new',
opportunitiesEdit: '/admin/crm/opportunities/edit/:id',
opportunitiesDetail: '/admin/crm/opportunities/:id',
activities: '/admin/crm/activities',
salesOrders: '/admin/crm/sales-orders',
salesOrdersNew: '/admin/crm/sales-orders/new',
salesOrdersEdit: '/admin/crm/sales-orders/edit/:id',
salesOrdersDetail: '/admin/crm/sales-orders/:id',
},
// crm: {
// customers: '/admin/crm/customers',
// customersNew: '/admin/crm/customers/new',
// customersEdit: '/admin/crm/customers/edit/:id',
// customersDetail: '/admin/crm/customers/:id',
// salesTeams: '/admin/crm/sales-teams',
// salesTeamsNew: '/admin/crm/sales-teams/new',
// salesTeamsEdit: '/admin/crm/sales-teams/edit/:id',
// salesTeamsDetail: '/admin/crm/sales-teams/:id',
// lossReasons: '/admin/crm/loss-reasons',
// opportunities: '/admin/crm/opportunities',
// opportunitiesNew: '/admin/crm/opportunities/new',
// opportunitiesEdit: '/admin/crm/opportunities/edit/:id',
// opportunitiesDetail: '/admin/crm/opportunities/:id',
// activities: '/admin/crm/activities',
// salesOrders: '/admin/crm/sales-orders',
// salesOrdersNew: '/admin/crm/sales-orders/new',
// salesOrdersEdit: '/admin/crm/sales-orders/edit/:id',
// salesOrdersDetail: '/admin/crm/sales-orders/:id',
// },
mrp: {
operationTypes: '/admin/mrp/operation-types',
workcenters: '/admin/mrp/workcenters',
operations: '/admin/mrp/operations',
bom: '/admin/mrp/bom',
productionOrders: '/admin/mrp/production-orders',
productionOrderNew: '/admin/mrp/production-orders/new',
productionOrderEdit: '/admin/mrp/production-orders/:id/edit',
productionOrderDetail: '/admin/mrp/production-orders/:id',
workOrders: '/admin/mrp/work-orders',
demandPlanning: '/admin/mrp/demand-planning',
materialRequirements: '/admin/mrp/material-requirements',
planningGantt: '/admin/mrp/planning-gantt',
},
// mrp: {
// operationTypes: '/admin/mrp/operation-types',
// workcenters: '/admin/mrp/workcenters',
// operations: '/admin/mrp/operations',
// bom: '/admin/mrp/bom',
// productionOrders: '/admin/mrp/production-orders',
// productionOrderNew: '/admin/mrp/production-orders/new',
// productionOrderEdit: '/admin/mrp/production-orders/:id/edit',
// productionOrderDetail: '/admin/mrp/production-orders/:id',
// workOrders: '/admin/mrp/work-orders',
// demandPlanning: '/admin/mrp/demand-planning',
// materialRequirements: '/admin/mrp/material-requirements',
// planningGantt: '/admin/mrp/planning-gantt',
// },
accounting: {
currentAccounts: '/admin/accounting/current-accounts',
waybills: '/admin/accounting/waybills',
invoices: '/admin/accounting/invoices',
invoicesNew: '/admin/accounting/invoices/new',
cash: '/admin/accounting/cash',
bank: '/admin/accounting/bank',
checkNote: '/admin/accounting/check-note',
},
// accounting: {
// currentAccounts: '/admin/accounting/current-accounts',
// waybills: '/admin/accounting/waybills',
// invoices: '/admin/accounting/invoices',
// invoicesNew: '/admin/accounting/invoices/new',
// cash: '/admin/accounting/cash',
// bank: '/admin/accounting/bank',
// checkNote: '/admin/accounting/check-note',
// },
},
}

View file

@ -319,7 +319,7 @@ function EditorOptionsBuilderDialog({
`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">
<Dialog.Body className="flex h-[calc(100vh-16rem)] max-h-[calc(90vh-7rem)] min-h-[320px] !flex-none flex-col gap-3 !overflow-hidden mt-5">
{/* ── 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" />

View file

@ -1,49 +1,8 @@
import { Button, Dialog } from '@/components/ui'
import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog'
import { SelectBoxOption } from '@/types/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import Editor from '@monaco-editor/react'
import { useEffect, useMemo, useState } from 'react'
import type { IconType } from 'react-icons'
import {
FaArrowDown,
FaArrowUp,
FaBell,
FaBook,
FaCalculator,
FaCheck,
FaChevronDown,
FaChevronRight,
FaCode,
FaCopy,
FaDatabase,
FaExclamationTriangle,
FaEye,
FaMagic,
FaPlug,
FaPlus,
FaTimes,
FaTrash,
} from 'react-icons/fa'
import {
buildRuleLine,
buildScriptFromRules,
createRule,
describeCondition,
effectiveTrigger,
isRuleComplete,
parseScript,
recipeGroups,
recipes,
recipesById,
runtimeApiReference,
scriptRunsOnOpen,
triggerLabels,
type ConditionOperator,
type RecipeGroupKey,
type RecipeParam,
type RuleTrigger,
type ScriptRule,
} from './editor-script/scriptRecipes'
import { useMemo } from 'react'
import { createFormScriptDialect } from './editor-script/formScriptDialect'
type EditorScriptBuilderDialogProps = {
isOpen: boolean
@ -54,36 +13,10 @@ type EditorScriptBuilderDialogProps = {
onApply: (value: string) => void
}
const controlClass =
'w-full min-w-0 h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400'
const cardClass =
'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900'
const groupIcons: Record<RecipeGroupKey, IconType> = {
calc: FaCalculator,
data: FaDatabase,
view: FaEye,
interaction: FaBell,
integration: FaPlug,
}
const operatorLabels: { value: ConditionOperator; label: string }[] = [
{ value: 'always', label: 'Her zaman' },
{ value: 'equals', label: 'Eşitse' },
{ value: 'notEquals', label: 'Eşit değilse' },
{ value: 'contains', label: 'İçeriyorsa' },
{ value: 'empty', label: 'Boşsa' },
{ value: 'notEmpty', label: 'Doluysa' },
{ value: 'greaterThan', label: 'Büyükse' },
{ value: 'lessThan', label: 'Küçükse' },
{ value: 'isTrue', label: 'İşaretliyse' },
{ value: 'isFalse', label: 'İşaretli değilse' },
]
const operatorNeedsValue = (operator: ConditionOperator) =>
['equals', 'notEquals', 'contains', 'greaterThan', 'lessThan'].includes(operator)
/**
* ListForm editor script'inin düzenleyicisi. Ortak Script Builder'ı form
* lehçesiyle kurar; kural motoru ve üretilen script değişmedi.
*/
function EditorScriptBuilderDialog({
isOpen,
value,
@ -94,624 +27,24 @@ function EditorScriptBuilderDialog({
}: EditorScriptBuilderDialogProps) {
const { translate } = useLocalization()
const availableFields = useMemo(
() =>
Array.from(
new Set(fields.map((field) => String(field.value || field.label || '')).filter(Boolean)),
).sort((left, right) => left.localeCompare(right)),
[fields],
// Çağıran her render'da yeni bir dizi verebilir; lehçe alan adlarına göre
// sabitlenmezse editör yazarken sıfırlanırdı.
const fieldKey = fields.map((field) => String(field.value || field.label || '')).join('|')
const dialect = useMemo(
() => createFormScriptDialect({ currentField, fields: fieldKey.split('|') }),
[currentField, fieldKey],
)
const [rules, setRules] = useState<ScriptRule[]>([])
const [manualScript, setManualScript] = useState('')
const [mode, setMode] = useState<'rules' | 'manual'>('rules')
const [openRules, setOpenRules] = useState<Record<string, boolean>>({})
const [paletteOpen, setPaletteOpen] = useState(true)
const [helpOpen, setHelpOpen] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!isOpen) return
const parsed = parseScript(value)
const existing = value?.trim() ?? ''
setRules(parsed.rules)
setManualScript(existing)
setCopied(false)
setHelpOpen(false)
setPaletteOpen(parsed.rules.length === 0)
setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false])))
// Elle yazılmış ya da builder dışında değiştirilmiş script'i kurallara
// zorlamıyoruz; kullanıcı kendisi geçmedikçe olduğu gibi korunur.
setMode(!existing || parsed.inSync ? 'rules' : 'manual')
}, [isOpen, value])
const generatedScript = useMemo(() => buildScriptFromRules(rules), [rules])
const runsOnOpen = useMemo(() => scriptRunsOnOpen(rules.filter(isRuleComplete)), [rules])
const activeScript = mode === 'rules' ? generatedScript : manualScript
const completeRuleCount = rules.filter(isRuleComplete).length
const manualDiffers = manualScript.trim() !== '' && manualScript.trim() !== generatedScript.trim()
const updateRule = (id: string, patch: Partial<ScriptRule>) =>
setRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)))
const updateParam = (id: string, key: string, next: string) =>
setRules((current) =>
current.map((rule) =>
rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule,
),
)
const moveRule = (index: number, delta: number) =>
setRules((current) => {
const target = index + delta
if (target < 0 || target >= current.length) return current
const next = [...current]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
const addRule = (recipeId: ScriptRule['recipe']) => {
const rule = createRule(recipeId)
setRules((current) => [...current, rule])
setOpenRules((current) => ({ ...current, [rule.id]: true }))
}
const copyScript = () => {
navigator.clipboard?.writeText(activeScript)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const renderFieldSelect = (
selectedValue: string,
onChange: (next: string) => void,
placeholder = 'Alan seç',
) => (
<select
className={controlClass}
value={selectedValue}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{placeholder}</option>
{availableFields.map((fieldName) => (
<option key={fieldName} value={fieldName}>
{fieldName}
</option>
))}
</select>
)
const renderParam = (rule: ScriptRule, param: RecipeParam) => {
if (param.type === 'fieldList') {
const selectedFields = rule.fields ?? []
return (
<div key={param.key} className="col-span-12 min-w-0">
<span className="mb-1 block text-xs text-gray-500">{param.label}</span>
<div className="flex flex-wrap items-center gap-1.5">
{selectedFields.map((fieldName) => (
<span
key={fieldName}
className="flex items-center gap-1 rounded-full bg-indigo-100 px-2 py-[2px] text-[11px] text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
>
{fieldName}
<button
type="button"
className="text-indigo-400 hover:text-red-500"
onClick={() =>
updateRule(rule.id, {
fields: selectedFields.filter((item) => item !== fieldName),
})
}
>
<FaTimes size={9} />
</button>
</span>
))}
<select
className={`${controlClass} !h-8 !w-44`}
value=""
onChange={(event) => {
const next = event.target.value
if (!next || selectedFields.includes(next)) return
updateRule(rule.id, { fields: [...selectedFields, next] })
}}
>
<option value="">+ alan ekle</option>
{availableFields
.filter((fieldName) => !selectedFields.includes(fieldName))
.map((fieldName) => (
<option key={fieldName} value={fieldName}>
{fieldName}
</option>
))}
</select>
</div>
</div>
)
}
const currentValue = rule.params[param.key] ?? ''
return (
<label key={param.key} className="col-span-12 min-w-0 md:col-span-4">
<span className="mb-1 block truncate text-xs text-gray-500" title={param.help}>
{param.label}
{param.optional && <span className="ml-1 text-gray-300">(ops.)</span>}
</span>
{param.type === 'field' && renderFieldSelect(currentValue, (next) =>
updateParam(rule.id, param.key, next),
)}
{param.type === 'select' && (
<select
className={controlClass}
value={currentValue}
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
>
{param.choices?.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
)}
{(param.type === 'text' || param.type === 'number') && (
<input
className={controlClass}
type={param.type === 'number' ? 'number' : 'text'}
value={currentValue}
placeholder={param.placeholder}
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
/>
)}
{param.help && (
<span className="mt-1 block truncate text-[11px] text-gray-400" title={param.help}>
{param.help}
</span>
)}
</label>
)
}
const renderRule = (rule: ScriptRule, index: number) => {
const recipe = recipesById.get(rule.recipe)
if (!recipe) return null
const open = openRules[rule.id] ?? true
const complete = isRuleComplete(rule)
const Icon = groupIcons[recipe.group]
return (
<div key={rule.id} className={cardClass}>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
className="shrink-0 text-gray-400"
onClick={() => setOpenRules((current) => ({ ...current, [rule.id]: !open }))}
>
{open ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
</button>
<Icon className={complete ? 'text-indigo-500' : 'text-gray-300'} size={13} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">
{index + 1}. {recipe.label}
</div>
<div className="truncate text-[11px] text-gray-400" title={recipe.describe(rule)}>
{recipe.conditionIsArgument
? recipe.describe(rule)
: `${describeCondition(rule.condition)}${recipe.describe(rule)}`}
</div>
</div>
{effectiveTrigger(rule) !== 'change' && (
<span
className="shrink-0 rounded-full bg-sky-100 px-2 py-[1px] text-[11px] text-sky-700 dark:bg-sky-900 dark:text-sky-200"
title="Bu kural form açılırken de çalışır."
>
{effectiveTrigger(rule) === 'open' ? 'açılışta' : 'açılış + değişim'}
</span>
)}
{!complete && (
<span
className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-[1px] text-[11px] text-amber-700 dark:bg-amber-900 dark:text-amber-200"
title="Zorunlu alanlar dolmadığı için bu kural script'e eklenmez."
>
<FaExclamationTriangle size={9} /> eksik
</span>
)}
<div className="flex shrink-0 items-center">
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Yukarı taşı"
icon={<FaArrowUp />}
onClick={() => moveRule(index, -1)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Aşağı taşı"
icon={<FaArrowDown />}
onClick={() => moveRule(index, 1)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Kuralı sil"
icon={<FaTrash />}
onClick={() => setRules((current) => current.filter((item) => item.id !== rule.id))}
/>
</div>
</div>
{open && (
<div className="border-t border-gray-100 px-3 py-3 dark:border-gray-800">
<p className="mb-3 text-[11px] text-gray-400">{recipe.summary}</p>
<div className="mb-3 grid grid-cols-12 items-end gap-2 rounded-md bg-gray-50 p-2 dark:bg-gray-800/40">
<label className="col-span-12 min-w-0 md:col-span-3">
<span
className="mb-1 block text-xs text-gray-500"
title="Form açılırken çalıştırmak, varsayılan değer üretmek için kullanılır."
>
Ne zaman
</span>
<select
className={controlClass}
value={effectiveTrigger(rule)}
onChange={(event) =>
updateRule(rule.id, { trigger: event.target.value as RuleTrigger })
}
>
{triggerLabels.map((trigger) => (
<option key={trigger.value} value={trigger.value} title={trigger.help}>
{trigger.label}
</option>
))}
</select>
</label>
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">
{recipe.conditionIsArgument ? 'Kilit koşulu' : 'Koşul'}
</span>
<select
className={controlClass}
value={rule.condition.operator}
onChange={(event) =>
updateRule(rule.id, {
condition: {
...rule.condition,
operator: event.target.value as ConditionOperator,
},
})
}
>
{operatorLabels.map((operator) => (
<option key={operator.value} value={operator.value}>
{operator.label}
</option>
))}
</select>
</label>
{rule.condition.operator !== 'always' && (
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">Koşul alanı</span>
{renderFieldSelect(rule.condition.source, (next) =>
updateRule(rule.id, { condition: { ...rule.condition, source: next } }),
)}
</label>
)}
{operatorNeedsValue(rule.condition.operator) && (
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">Karşılaştırma değeri</span>
<input
className={controlClass}
value={rule.condition.value}
placeholder="Aktif / 1000"
onChange={(event) =>
updateRule(rule.id, {
condition: { ...rule.condition, value: event.target.value },
})
}
/>
</label>
)}
</div>
<div className="grid grid-cols-12 gap-x-3 gap-y-3">
{recipe.params.map((param) => renderParam(rule, param))}
</div>
<pre className="mt-3 overflow-x-auto rounded border border-gray-100 bg-gray-950 px-3 py-2 font-mono text-[11px] text-emerald-100 dark:border-gray-800">
{buildRuleLine(rule, runsOnOpen) || recipe.example}
</pre>
</div>
)}
</div>
)
}
return (
<Dialog
preventScroll
<ScriptBuilderDialog
cancelLabel={translate('::Cancel')}
dialect={dialect}
isOpen={isOpen}
width={1360}
contentClassName="flex flex-col"
style={{ overlay: { zIndex: 1300 } }}
saveLabel={translate('::Save')}
value={value}
onApply={onApply}
onClose={onClose}
onRequestClose={onClose}
>
{/*
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" />
<h5 className="mr-1">Editor Script</h5>
<span
className="rounded-full bg-indigo-100 px-2.5 py-[2px] text-[11px] font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
title="Bu alan değiştiğinde çalışacak script."
>
{currentField || 'alan seçilmemiş'}
</span>
<span
className={`rounded-full px-2.5 py-[2px] text-[11px] font-medium ${
completeRuleCount
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-200'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{completeRuleCount}/{rules.length} kural
</span>
<div className="ml-auto flex items-center gap-2">
<Button
size="sm"
type="button"
title="Runtime API özeti"
icon={<FaBook />}
onClick={() => setHelpOpen((current) => !current)}
>
Kısayollar
</Button>
<div className="flex overflow-hidden rounded-md border border-gray-200 dark:border-gray-700">
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium ${
mode === 'rules'
? 'bg-indigo-500 text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={() => setMode('rules')}
>
<FaMagic /> Kurallar
</button>
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium ${
mode === 'manual'
? 'bg-indigo-500 text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={() => {
if (!manualScript.trim()) setManualScript(generatedScript)
setMode('manual')
}}
>
<FaCode /> Script
</button>
</div>
</div>
</div>
{mode === 'rules' && manualDiffers && (
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<FaExclamationTriangle className="shrink-0" />
<span className="flex-1">
Kayıtlı script kurallardan farklı. Kurallar sekmesinde kaydedersen elle yazılmış hali
değişir.
</span>
<Button size="xs" type="button" onClick={() => setMode('manual')}>
Script&apos;i gör
</Button>
</div>
)}
{helpOpen && (
<div className={`${cardClass} max-h-56 overflow-y-auto p-3`}>
<div className="mb-2 text-sm font-semibold">Script içinde kullanabileceklerin</div>
<div className="grid grid-cols-1 gap-x-6 gap-y-1 md:grid-cols-2">
{runtimeApiReference.map((item) => (
<div key={item.signature} className="flex gap-2 text-[11px]">
<code className="shrink-0 rounded bg-gray-100 px-1 font-mono text-indigo-600 dark:bg-gray-800 dark:text-indigo-300">
{item.signature}
</code>
<span className="min-w-0 text-gray-500">{item.description}</span>
</div>
))}
</div>
</div>
)}
{mode === 'manual' ? (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex items-center justify-between gap-2 text-xs text-gray-500">
<span>
Tek satırlık yardımcılarla yaz. Eski uzun script&apos;ler de çalışmaya devam eder.
</span>
<div className="flex items-center gap-2">
<Button
disabled={!generatedScript}
size="sm"
type="button"
title="Kurallar sekmesindeki tanımdan script'i yeniden üretir ve buradakini değiştirir."
icon={<FaMagic />}
onClick={() => setManualScript(generatedScript)}
>
Kurallardan üret
</Button>
<Button size="sm" type="button" icon={<FaCopy />} onClick={copyScript}>
{copied ? 'Kopyalandı' : 'Kopyala'}
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={manualScript}
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: 'on',
}}
onChange={(next) => setManualScript(next || '')}
/>
</div>
</div>
) : (
// flex-1 olmadan bu ızgara içeriğe göre büzülür ve sağdaki Monaco
// (height="100%") yüksekliğini kaybeder.
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4 overflow-hidden">
{/* ── Kurallar ─────────────────────────────────────────── */}
<section className="col-span-7 flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
<div className={cardClass}>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => setPaletteOpen((current) => !current)}
>
<span className="shrink-0 text-gray-400">
{paletteOpen ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
</span>
<FaPlus className="text-indigo-500" size={12} />
<span className="flex-1 text-sm font-semibold">Kural Ekle</span>
</button>
{paletteOpen && (
<div className="flex flex-col gap-3 border-t border-gray-100 px-3 py-3 dark:border-gray-800">
{recipeGroups.map((group) => {
const Icon = groupIcons[group.key]
return (
<div key={group.key}>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
<Icon size={10} /> {group.title}
</div>
<div className="flex flex-wrap gap-2">
{recipes
.filter((recipe) => recipe.group === group.key)
.map((recipe) => (
<Button
key={recipe.id}
size="sm"
type="button"
title={`${recipe.summary}\n\nÖrnek: ${recipe.example}`}
onClick={() => addRule(recipe.id)}
>
{recipe.label}
</Button>
))}
</div>
</div>
)
})}
</div>
)}
</div>
{rules.map(renderRule)}
{!rules.length && (
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-xs text-gray-400 dark:border-gray-700">
Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir.
</div>
)}
</section>
{/* ── Önizleme ─────────────────────────────────────────── */}
<section className={`col-span-5 flex min-h-0 flex-col ${cardClass} p-3`}>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-semibold">
<FaCode className="text-gray-400" />
Script Önizleme
</span>
<div className="flex items-center gap-2">
<span className="rounded-full bg-gray-100 px-2 py-[2px] text-[11px] text-gray-500 dark:bg-gray-800 dark:text-gray-300">
{generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'}
</span>
<Button
shape="circle"
size="sm"
type="button"
variant="plain"
title="Script'i panoya kopyala"
icon={copied ? <FaCheck /> : <FaCopy />}
onClick={copyScript}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={generatedScript || '// Kural ekledikçe script burada oluşur'}
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
readOnly: true,
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: 'on',
}}
/>
</div>
<p className="mt-2 text-[11px] text-gray-400">
İlk satırdaki <code>{'// @builder'}</code> yorumu kuralları saklar; dialog yeniden
ıldığında kurallar buradan geri yüklenir.
</p>
</section>
</div>
)}
</Dialog.Body>
<Dialog.Footer className="mt-1 flex justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
<Button size="sm" type="button" variant="plain" icon={<FaTimes />} onClick={onClose}>
{translate('::Cancel')}
</Button>
<Button
size="sm"
type="button"
variant="solid"
icon={<FaCheck />}
onClick={() => {
onApply(activeScript)
onClose()
}}
>
{translate('::Save')}
</Button>
</Dialog.Footer>
</Dialog>
/>
)
}

View file

@ -0,0 +1,203 @@
/**
* ListForm editor script'inin ortak Script Builder lehçesi.
*
* Bu dosya yeni bir kural motoru getirmez; `scriptRecipes.ts` içindeki mevcut
* sözlüğü ortak dialog'un beklediği sözleşmeye çevirir. Üretilen script ve
* `// @builder` başlığı bire bir aynı kaldığı için eskiden kaydedilmiş bütün
* script'ler değişmeden okunmaya devam eder.
*/
import {
DEFAULT_SCRIPT_OPERATORS,
type ScriptApiEntry,
type ScriptDialect,
type ScriptRecipeGroup,
type ScriptRecipeParam,
type ScriptRecipeView,
type ScriptRule,
type ScriptSnippet,
} from '@/components/scriptBuilder/types'
import { FaBell, FaCalculator, FaDatabase, FaEye, FaPlug } from 'react-icons/fa'
import {
buildRuleLine,
buildScriptFromRules,
createRule,
describeCondition,
effectiveTrigger,
isRuleComplete,
parseScript,
recipeGroups,
recipes,
recipesById,
runtimeApiReference,
scriptRunsOnOpen,
triggerLabels,
type RecipeGroupKey,
type RecipeId,
type RecipeParam,
type ScriptRule as FormScriptRule,
} from './scriptRecipes'
const groupIcons: Record<RecipeGroupKey, ScriptRecipeGroup['icon']> = {
calc: FaCalculator,
data: FaDatabase,
view: FaEye,
interaction: FaBell,
integration: FaPlug,
}
/** Alan seçicisi tek bir listeden beslenir: form kolonları. */
const FIELD_SOURCE = 'field'
const toParam = (param: RecipeParam): ScriptRecipeParam => ({
key: param.key,
label: param.label,
type:
param.type === 'field' ? 'option' : param.type === 'fieldList' ? 'fieldList' : param.type,
optionSource: param.type === 'field' || param.type === 'fieldList' ? FIELD_SOURCE : undefined,
placeholder: param.placeholder,
help: param.help,
choices: param.choices,
optional: param.optional,
})
const recipeViews: ScriptRecipeView[] = recipes.map((recipe) => ({
id: recipe.id,
label: recipe.label,
group: recipe.group,
summary: recipe.summary,
example: recipe.example,
conditionIsArgument: recipe.conditionIsArgument,
params: recipe.params.map(toParam),
}))
const apiReference: ScriptApiEntry[] = runtimeApiReference
/**
* Kod editöründe tek tıkla eklenebilen kalıplar. Tarifler tek satır üretir;
* buradakiler ise elle yazarken en çok ihtiyaç duyulan çok satırlı iskeletler.
*/
const snippets: ScriptSnippet[] = [
{
label: 'Koşullu değer yaz',
group: 'Akış',
description: 'Bir alan doluysa hedefe yaz.',
code: "if (get('${1:Kaynak}')) set('${2:Hedef}', get('${1:Kaynak}'))",
},
{
label: 'Çok koşullu blok',
group: 'Akış',
description: 'VE / VEYA ile birleşen iki koşul.',
code: "if (str('${1:Durum}') === '${2:Aktif}' && num('${3:Tutar}') > ${4:1000}) {\n ${5:notify('Limit aşıldı')}\n}",
},
{
label: 'Açılışta / değişimde ayrımı',
group: 'Akış',
description: 'isReady form açılışında true gelir.',
code: "if (isReady) {\n ${1:// form açılırken}\n} else {\n ${2:// değer değişince}\n}",
},
{
label: 'Toplu alan yazma',
group: 'Veri',
description: 'Tek çağrıda birden fazla alan.',
code: "set({ ${1:Alan1}: ${2:null}, ${3:Alan2}: ${4:null} })",
},
{
label: 'Seçili kayıttan doldur',
group: 'Veri',
description: 'Lookup seçiminden birkaç alanı birden taşı.',
code: "copy('${1:kolon}', '${2:Hedef}')\ncopy('${3:kolon2}', '${4:Hedef2}')",
},
{
label: 'Onay iste, vazgeçilirse geri al',
group: 'Etkileşim',
description: 'ask false dönerse alan eski değerine döner.',
code: "if (!ask('${1:Emin misiniz?}')) return",
},
{
label: 'API çağrısı ve hata yakalama',
group: 'Entegrasyon',
description: 'Çağrı başarısızsa uyarı gösterir.',
code: "try {\n set('${1:Hedef}', await api('${2:/api/app/musteri/{value}}', '${3:data.name}'))\n} catch (error) {\n notify('${4:Servise ulaşılamadı}')\n}",
},
{
label: 'Hesaplama ve yuvarlama',
group: 'Hesaplama',
description: 'İki alandan türetilmiş değer.',
code: "set('${1:Toplam}', round(num('${2:Miktar}') * num('${3:Fiyat}'), ${4:2}))",
},
{
label: 'Alanı koşula göre kilitle',
group: 'Görünüm',
description: 'Koşul doğruyken salt okunur olur.',
code: "readOnly('${1:Alan}', str('${2:Durum}') === '${3:Kapali}')",
},
]
export type FormScriptDialectContext = {
/** Form kolonları. */
fields: string[]
/** Script'in bağlı olduğu alan; başlıkta gösterilir. */
currentField?: string
}
export const createFormScriptDialect = (context: FormScriptDialectContext): ScriptDialect => {
const fields = Array.from(new Set(context.fields.filter(Boolean))).sort((left, right) =>
left.localeCompare(right),
)
const asFormRule = (rule: ScriptRule) => rule as unknown as FormScriptRule
return {
id: 'form',
title: 'Editor Script',
subject: context.currentField || 'alan seçilmemiş',
subjectHint: 'Bu alan değiştiğinde çalışacak script.',
codeHint:
'Tek satırlık yardımcılarla yaz; get/set/notify hazır. Ctrl+Space tamamlama listesini açar.',
groups: recipeGroups.map((group) => ({
key: group.key,
title: group.title,
description: group.description,
icon: groupIcons[group.key],
})),
recipes: recipeViews,
operators: DEFAULT_SCRIPT_OPERATORS,
conditionKinds: [{ value: FIELD_SOURCE, label: 'Form alanı', optionSource: FIELD_SOURCE }],
triggers: triggerLabels,
apiReference,
snippets,
getOptions: (optionSource) => (optionSource === FIELD_SOURCE ? fields : []),
getRecipe: (recipeId) => recipeViews.find((recipe) => recipe.id === recipeId),
createRule: (recipeId) => createRule(recipeId as RecipeId) as unknown as ScriptRule,
isRuleComplete: (rule) => isRuleComplete(asFormRule(rule)),
// Guard'ların gerekip gerekmediği script'in tamamına bağlı, o yüzden tek bir
// kuralın önizlemesi de bütün kurallara bakılarak üretilir.
buildRuleLine: (rule, rules) =>
buildRuleLine(
asFormRule(rule),
scriptRunsOnOpen(rules.map(asFormRule).filter(isRuleComplete)),
),
buildScript: (rules) => buildScriptFromRules(rules.map(asFormRule)),
parseScript: (script) => {
const parsed = parseScript(script)
return { ...parsed, rules: parsed.rules as unknown as ScriptRule[] }
},
describeCondition: (condition) =>
describeCondition(condition as unknown as FormScriptRule['condition']),
describeRule: (rule) => {
const recipe = recipesById.get(rule.recipe as RecipeId)
if (!recipe) return ''
const action = recipe.describe(asFormRule(rule))
if (recipe.conditionIsArgument) return action
const conditions = [rule.condition, ...(rule.conditions ?? [])]
.map((condition) => describeCondition(condition as unknown as FormScriptRule['condition']))
.filter((text) => text !== 'Her zaman')
const prefix = conditions.length
? conditions.join(rule.join === 'or' ? ' veya ' : ' ve ')
: 'Her zaman'
return `${prefix}${action}`
},
getTrigger: (rule) => effectiveTrigger(asFormRule(rule)),
}
}

View file

@ -43,10 +43,20 @@ export const triggerLabels: { value: RuleTrigger; label: string; help: string }[
{ value: 'both', label: 'Her ikisi', help: 'Hem açılışta hem değer değişiminde çalışır.' },
]
/** Ek koşullar nasıl birleşecek. */
export type ConditionJoin = 'and' | 'or'
export type ScriptRule = {
id: string
recipe: RecipeId
condition: RuleCondition
/**
* İkinci ve sonraki koşullar. Tek koşullu kurallar bu alanı hiç taşımaz
* böylece eskiden kaydedilmiş script'ler birebir aynı JSON'a ve aynı koda
* üretilmeye devam eder.
*/
conditions?: RuleCondition[]
join?: ConditionJoin
/** Tarife göre anlamı değişen serbest parametreler. */
params: Record<string, string>
/** Çoklu alan alan tarifler için (sum, clear). */
@ -55,6 +65,12 @@ export type ScriptRule = {
trigger?: RuleTrigger
}
/** Kuralın bütün koşulları, ilki her zaman `condition`. */
export const ruleConditions = (rule: ScriptRule): RuleCondition[] => [
rule.condition,
...(rule.conditions ?? []),
]
export type RecipeParam = {
key: string
label: string
@ -83,8 +99,11 @@ export type Recipe = {
export type RecipeId =
| 'multiply'
| 'subtract'
| 'percent'
| 'sum'
| 'formula'
| 'today'
| 'days'
| 'hours'
| 'copy'
@ -142,6 +161,17 @@ export const buildConditionExpression = (condition: RuleCondition): string => {
}
}
/**
* Kuralın tüm koşullarını tek ifadeye çevirir. Tek koşulda çıktı eskisiyle
* birebir aynıdır; birden fazlasında her parça parantezlenip `&&` / `||` ile
* birleştirilir.
*/
export const buildRuleConditionExpression = (rule: ScriptRule): string => {
const parts = ruleConditions(rule).map(buildConditionExpression).filter(Boolean)
if (parts.length <= 1) return parts[0] ?? ''
return parts.map((part) => `(${part})`).join(rule.join === 'or' ? ' || ' : ' && ')
}
export const describeCondition = (condition: RuleCondition): string => {
if (condition.operator === 'always' || !condition.source) return 'Her zaman'
const source = label(condition.source, 'alan')
@ -203,6 +233,104 @@ export const recipes: Recipe[] = [
'hedef',
)} alanına yaz.`,
},
{
id: 'subtract',
label: 'Fark (brüt indirim)',
group: 'calc',
summary: 'Birinci alandan ikinciyi çıkarır, yuvarlar ve hedefe yazar.',
example: "set('Net', round(num('Gross') - num('Discount'), 2))",
params: [
{ key: 'left', label: ıkarılan alan', type: 'field' },
{ key: 'right', label: ıkan alan', type: 'field' },
{ key: 'target', label: 'Sonuç alanı', type: 'field' },
{ key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' },
],
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: 'percent',
label: 'Yüzde (KDV, iskonto)',
group: 'calc',
summary: 'Tutarın belirtilen yüzdesini hesaplar; oran alan ya da sabit olabilir.',
example: "set('Kdv', round(num('Tutar') * num('KdvOrani') / 100, 2))",
params: [
{ key: 'amount', label: 'Tutar alanı', type: 'field' },
{
key: 'rate',
label: 'Oran',
type: 'text',
placeholder: '20 veya KdvOrani',
help: 'Sayı yazarsan sabit oran, alan adı yazarsan o alandan okunur.',
},
{ key: 'target', label: 'Sonuç alanı', type: 'field' },
{ key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' },
{
key: 'mode',
label: 'Sonuç',
type: 'select',
optional: true,
choices: [
{ value: '', label: 'Yalnızca yüzde tutarı' },
{ value: 'add', label: 'Tutar + yüzde' },
{ value: 'subtract', label: 'Tutar yüzde' },
],
},
],
build: ({ params }) => {
const amount = numberOrField(params.amount)
const portion = `${amount} * ${numberOrField(params.rate)} / 100`
const expression =
params.mode === 'add'
? `${amount} + ${portion}`
: params.mode === 'subtract'
? `${amount} - ${portion}`
: portion
return `set(${q(params.target)}, round(${expression}, ${params.digits || 2}))`
},
describe: ({ params }) =>
`${label(params.amount, 'tutar')} üzerinden %${label(params.rate, 'oran')} hesabını ${label(
params.target,
'hedef',
)} alanına yaz.`,
},
{
id: 'today',
label: 'Bugünün tarihi',
group: 'calc',
summary: 'Bugünü (veya kaç gün sonrasını) tarih alanına yazar.',
example: "set('Tarih', new Date().toISOString().slice(0, 10))",
params: [
{ key: 'target', label: 'Hedef alan', type: 'field' },
{
key: 'offset',
label: 'Gün ekle',
type: 'number',
optional: true,
placeholder: '0',
help: 'Negatif yazarsan geçmiş bir tarih üretir.',
},
],
build: ({ params }) => {
const offset = Number(params.offset) || 0
const base = offset
? `new Date(Date.now() + ${offset} * 86400000)`
: 'new Date()'
return `set(${q(params.target)}, ${base}.toISOString().slice(0, 10))`
},
describe: ({ params }) => {
const offset = Number(params.offset) || 0
const when = offset > 0 ? `${offset} gün sonrasını` : offset < 0 ? `${-offset} gün öncesini` : 'bugünü'
return `${when} ${label(params.target, 'hedef')} alanına yaz.`
},
},
{
id: 'sum',
label: 'Toplama',
@ -355,10 +483,10 @@ export const recipes: Recipe[] = [
],
},
],
build: ({ params, condition }) => {
const expression = buildConditionExpression(condition) || 'true'
const flag = params.invert === 'true' ? `!(${expression})` : expression
return `readOnly(${q(params.target)}, ${flag})`
build: (rule) => {
const expression = buildRuleConditionExpression(rule) || 'true'
const flag = rule.params.invert === 'true' ? `!(${expression})` : expression
return `readOnly(${q(rule.params.target)}, ${flag})`
},
describe: ({ params, condition }) =>
`${describeCondition(condition)} ${label(params.target, 'alan')} alanını ${
@ -505,11 +633,13 @@ 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() !== ''))
const conditionReady = ruleConditions(rule).every(
(condition) =>
condition.operator === 'always' ||
(!!condition.source &&
(['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(condition.operator) ||
condition.value.trim() !== '')),
)
if (!conditionReady) return false
return recipe.params.every((param) => {
@ -543,7 +673,7 @@ export const buildRuleLine = (rule: ScriptRule, runsOnOpen = false): string => {
}
if (!recipe.conditionIsArgument) {
const condition = buildConditionExpression(rule.condition)
const condition = buildRuleConditionExpression(rule)
if (condition) guards.push(condition)
}

View file

@ -27,6 +27,7 @@ import {
FaSlidersH,
FaSync,
FaTabletAlt,
FaTimes,
FaTrash,
FaUndo,
} from 'react-icons/fa'
@ -39,6 +40,11 @@ import { developerKitService } from '@/services/developerKit.service'
import { Button, Notification, toast } from '@/components/ui'
import StyleModal from '@/components/codeLayout/StyleModal'
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
beginDesignerDragSoon,
endDesignerDrag,
useDesignerDrag,
} from '@/components/visualDesigner/designerDrag'
import {
DESIGNER_PRIMARY_EVENTS,
DESIGNER_SCRIPT_RECIPES,
@ -47,8 +53,10 @@ import {
getDesignerEventSnippet,
} from '@/components/visualDesigner/catalog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
import {
collectDesignerRefs,
getSqlDataSourceEvent,
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
@ -56,6 +64,7 @@ import {
renameDesignerRefInNodes,
renameDesignerRefInScript,
withDesignerRefs,
getDesignerBindingColumns,
getDesignerCollectionProperty,
getDesignerValueByPath,
getSqlDataSourceEndpointId,
@ -1171,6 +1180,13 @@ const VisualComponentDesigner = () => {
null,
)
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
const activeDrag = useDesignerDrag()
/** Kural tabanlı script builder'ın düzenlediği event. */
const [scriptBuilderTarget, setScriptBuilderTarget] = useState<{
nodeId: string
eventName: string
} | null>(null)
const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
const undoStack = useRef<DesignerDocument[]>([])
const redoStack = useRef<DesignerDocument[]>([])
@ -2341,6 +2357,7 @@ const VisualComponentDesigner = () => {
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
? selectedNode?.bindings?.[optionDataProperty]
: undefined
const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding)
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
@ -2882,6 +2899,69 @@ const VisualComponentDesigner = () => {
))}
</div>
)}
{/*
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
başka bir komponente ya da SqlDataSource kaydına yazabilir.
*/}
{selectCollectionSample && selectColumnFields.length > 0 && (
<div>
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold text-slate-500">
Ek sütunlar
<code className="font-normal normal-case text-sky-600">
refs.{selectedNode?.ref}.getColumn(&apos;&apos;)
</code>
</span>
<div className="flex flex-wrap items-center gap-1">
{selectOptionColumns.map((column) => (
<span
key={column}
className="flex items-center gap-1 rounded-full bg-sky-100 px-2 py-[2px] text-[10px] text-sky-700 dark:bg-sky-950 dark:text-sky-200"
>
{column}
<button
className="text-sky-400 hover:text-red-500"
title="Sütunu kaldır"
type="button"
onClick={() =>
updateSelectedBindingDetails(optionDataProperty, {
columns: selectOptionColumns.filter((item) => item !== column),
})
}
>
<FaTimes size={8} />
</button>
</span>
))}
<select
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value=""
onChange={(event) => {
const next = event.target.value
if (!next || selectOptionColumns.includes(next)) return
updateSelectedBindingDetails(optionDataProperty, {
columns: [...selectOptionColumns, next],
})
}}
>
<option value="">+ sütun ekle</option>
{selectColumnFields
.filter((field) => !selectOptionColumns.includes(field.path))
.map((field) => (
<option key={field.path} value={field.path}>
{field.path}
</option>
))}
</select>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
Ekranda yalnızca Label görünür. Buraya eklediğin sütunlar seçenekle birlikte
taşınır; script içinde{' '}
<code>refs.{selectedNode?.ref || 'select1'}.getColumn(&apos;sutun&apos;)</code>{' '}
ile okunur.
</p>
</div>
)}
{selectCollectionSample &&
selectColumnFields.length > 0 &&
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
@ -3668,6 +3748,27 @@ const VisualComponentDesigner = () => {
current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet,
)
}
/**
* Builder context of the event being edited: the SqlDataSource that owns the
* record (the node itself, or the nearest one above it) plus the columns and
* ref names its rules can address.
*/
const scriptBuilderNode = scriptBuilderTarget
? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId)
: null
const scriptBuilderSqlNode = scriptBuilderNode
? isSqlDataSourceNode(scriptBuilderNode.type)
? scriptBuilderNode
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
.reverse()
.find((node) => isSqlDataSourceNode(node.type))
: undefined
const scriptBuilderRecordFields = scriptBuilderSqlNode
? Object.keys(
(sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {},
)
: []
const endpointResultText = endpointResultModal
? typeof endpointResultModal.result === 'string'
? endpointResultModal.result
@ -3817,8 +3918,16 @@ const VisualComponentDesigner = () => {
<button
key={`${definition.toolboxGroup}-${definition.name}`}
draggable
className="group min-w-0 rounded-lg border border-slate-200 bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950"
title={definition.description}
className={`group min-w-0 rounded-lg border bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950 ${
activeDrag?.source === 'library' && activeDrag.name === definition.name
? 'border-sky-500 ring-2 ring-sky-300 dark:border-sky-500'
: 'border-slate-200 dark:border-slate-700'
}`}
title={
definition.acceptsChildren
? `${definition.description}\n\nİçine başka komponent alabilir.`
: definition.description
}
type="button"
onClick={() => addComponent(definition.name, null)}
onDragStart={(event) => {
@ -3829,7 +3938,13 @@ const VisualComponentDesigner = () => {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
event.dataTransfer.setData('text/plain', payload)
// Canvas bırakma hedeflerini vurgulayabilmek için
// sürüklemenin başladığını duyuruyoruz — senkron bir
// state güncellemesi sürüklemeyi iptal ettirebildiği
// için bir tık sonraya bırakarak.
beginDesignerDragSoon({ source: 'library', name: definition.name })
}}
onDragEnd={() => endDesignerDrag()}
>
<div className="mb-1 flex h-6 w-6 items-center justify-center rounded bg-slate-100 text-[10px] font-bold text-slate-500 group-hover:bg-sky-100 group-hover:text-sky-600 dark:bg-slate-800">
{definition.name.slice(0, 2).toUpperCase()}
@ -4123,11 +4238,35 @@ const VisualComponentDesigner = () => {
onFocus={() => setFocusedEventName(eventName)}
>
<span className="flex items-center justify-between bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<code>{eventName}(event)</code>
<span className="flex items-center gap-2">
<span
className="min-w-0 truncate"
title={getSqlDataSourceEvent(eventName)?.description}
>
<code>{eventName}(event)</code>
{getSqlDataSourceEvent(eventName) &&
isSqlDataSourceNode(activeJavaScriptTarget.node.type) && (
<span className="ml-2 font-normal text-slate-400">
{getSqlDataSourceEvent(eventName)?.label}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[9px] font-normal text-slate-400">
{activeJavaScriptTarget.node.type}
</span>
<button
className="rounded bg-indigo-100 px-2 py-1 text-[9px] font-semibold text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950 dark:text-indigo-300"
title="Kural tabanlı script builder"
type="button"
onClick={() =>
setScriptBuilderTarget({
nodeId: activeJavaScriptTarget.node.id,
eventName,
})
}
>
Builder
</button>
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-sky-950 dark:text-sky-300"
disabled={Boolean(
@ -4138,9 +4277,11 @@ const VisualComponentDesigner = () => {
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
resolveRecipeCode(
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
),
),
)
}
@ -4719,6 +4860,23 @@ const VisualComponentDesigner = () => {
</div>
</div>
)}
{scriptBuilderTarget && scriptBuilderNode && (
<DesignerScriptBuilderDialog
isOpen
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
eventInfo={getSqlDataSourceEvent(scriptBuilderTarget.eventName)}
eventName={scriptBuilderTarget.eventName}
recordFields={scriptBuilderRecordFields}
refNames={[...documentRefs.keys()]}
sqlRef={scriptBuilderSqlNode?.ref || ''}
value={scriptBuilderNode.events[scriptBuilderTarget.eventName] || ''}
onApply={(next) =>
updateNodeEvent(scriptBuilderTarget.nodeId, scriptBuilderTarget.eventName, next)
}
onClose={() => setScriptBuilderTarget(null)}
/>
)}
</div>
)
}