Claude güncellemesi ScriptBuilderDialog güncellemesi
This commit is contained in:
parent
2368ee3825
commit
a42776e9c9
19 changed files with 4922 additions and 2663 deletions
33
claude.md
33
claude.md
|
|
@ -7,6 +7,21 @@ Primary source of truth for platform behavior is:
|
||||||
|
|
||||||
If there is any conflict, follow `.github/instructions/ai.instructions.md`.
|
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
|
## Purpose
|
||||||
|
|
||||||
- Maximize delivery through runtime configuration.
|
- Maximize delivery through runtime configuration.
|
||||||
|
|
@ -17,7 +32,9 @@ Primary principle: Configuration first, code last.
|
||||||
|
|
||||||
## Mandatory Decision Order
|
## 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
|
1. Dynamic configuration with existing ListForm ecosystem
|
||||||
2. SQL Query Manager + Custom Endpoint
|
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.
|
1. Do not propose new custom React component/page development for standard feature requests.
|
||||||
2. Build new screens using platform configuration mechanisms.
|
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.
|
4. Never bypass platform authorization patterns.
|
||||||
5. Never hardcode secrets, tenant IDs, or connection strings.
|
5. Never hardcode secrets, tenant IDs, or connection strings.
|
||||||
|
|
||||||
Exception:
|
Exception:
|
||||||
|
|
||||||
- Custom React/backend code is allowed only when the user explicitly requests implementation and configuration is insufficient.
|
- Custom React/backend code is allowed only when the user explicitly requests implementation
|
||||||
- In such cases, explain why configuration-first options are not enough.
|
and configuration is insufficient.
|
||||||
|
- In such cases, explain why configuration-first options are not enough — in a sentence or two.
|
||||||
|
|
||||||
## Architecture Guardrails
|
## Architecture Guardrails
|
||||||
|
|
||||||
|
|
@ -58,7 +76,12 @@ Exception:
|
||||||
|
|
||||||
## Response Contract
|
## 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
|
1. Goal
|
||||||
2. Decision flow result (which step used)
|
2. Decision flow result (which step used)
|
||||||
|
|
|
||||||
924
ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx
Normal file
924
ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx
Normal 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 çağı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 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={() => 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
|
||||||
|
açı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
|
||||||
167
ui/src/components/scriptBuilder/scriptIntelliSense.ts
Normal file
167
ui/src/components/scriptBuilder/scriptIntelliSense.ts
Normal 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()
|
||||||
|
}
|
||||||
173
ui/src/components/scriptBuilder/types.ts
Normal file
173
ui/src/components/scriptBuilder/types.ts
Normal 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 },
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -7,8 +7,10 @@ import apiService from '@/services/api.service'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import { formatLocaleValue } from '@/utils/localeFormat'
|
import { formatLocaleValue } from '@/utils/localeFormat'
|
||||||
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
|
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
|
||||||
|
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
|
||||||
import {
|
import {
|
||||||
fromDesignerDate,
|
fromDesignerDate,
|
||||||
|
getDesignerBindingColumns,
|
||||||
getDesignerCollectionProperty,
|
getDesignerCollectionProperty,
|
||||||
getDesignerTabSlot,
|
getDesignerTabSlot,
|
||||||
getDesignerValueByPath,
|
getDesignerValueByPath,
|
||||||
|
|
@ -68,6 +70,52 @@ const acceptDesignerDrag = (event: React.DragEvent<HTMLElement>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Açı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) =>
|
const getTableDimension = (value: unknown, fallback: number) =>
|
||||||
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
|
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
|
||||||
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
|
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
|
||||||
|
|
@ -204,13 +252,70 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
|
||||||
...(isSqlDataSourceNode(node.type)
|
...(isSqlDataSourceNode(node.type)
|
||||||
? {
|
? {
|
||||||
getRecord: () => ({}),
|
getRecord: () => ({}),
|
||||||
|
setRecord: designTimeOnly('setRecord'),
|
||||||
|
getField: () => undefined,
|
||||||
setField: designTimeOnly('setField'),
|
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'),
|
reload: designTimeOnly('reload'),
|
||||||
newRecord: designTimeOnly('newRecord'),
|
newRecord: designTimeOnly('newRecord'),
|
||||||
save: designTimeOnly('save'),
|
save: designTimeOnly('save'),
|
||||||
remove: designTimeOnly('remove'),
|
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
|
return refs
|
||||||
|
|
@ -1216,6 +1321,95 @@ const NodeView = ({
|
||||||
'Tabs',
|
'Tabs',
|
||||||
'SqlDataSource',
|
'SqlDataSource',
|
||||||
].includes(node.type)
|
].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 staticChildren = node.props.children
|
||||||
const hasStaticChildren =
|
const hasStaticChildren =
|
||||||
staticChildren !== undefined &&
|
staticChildren !== undefined &&
|
||||||
|
|
@ -1317,31 +1511,26 @@ const NodeView = ({
|
||||||
return (
|
return (
|
||||||
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
|
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
|
||||||
<div
|
<div
|
||||||
className="min-h-14 py-2"
|
className={`min-h-14 rounded py-2 transition ${
|
||||||
onDragOver={(event) => {
|
overSlot === slot
|
||||||
if (!interactive) return
|
? 'bg-emerald-50 outline outline-2 outline-emerald-500 dark:bg-emerald-950/40'
|
||||||
event.preventDefault()
|
: dragging
|
||||||
event.stopPropagation()
|
? 'outline-dashed outline-1 outline-sky-400/70'
|
||||||
event.dataTransfer.dropEffect =
|
: ''
|
||||||
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
}`}
|
||||||
}}
|
{...slotDropProps(slot)}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const raw =
|
setOverSlot(null)
|
||||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
endDesignerDrag()
|
||||||
event.dataTransfer.getData('text/plain')
|
const payload = readDesignerDragPayload(event)
|
||||||
if (!raw) return
|
if (!payload) return
|
||||||
try {
|
if (payload.source === 'library' && payload.name) {
|
||||||
const payload = JSON.parse(raw)
|
onDropComponent?.(payload.name, node.id, slot)
|
||||||
if (payload.source === 'library') {
|
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||||
onDropComponent?.(payload.name, node.id, slot)
|
onMoveIntoContainer?.(payload.nodeId, 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.
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1414,38 +1603,29 @@ const NodeView = ({
|
||||||
<td
|
<td
|
||||||
key={slot}
|
key={slot}
|
||||||
className={
|
className={
|
||||||
interactive
|
overSlot === slot
|
||||||
? 'border border-slate-200 align-top dark:border-slate-700'
|
? 'border-2 border-emerald-500 bg-emerald-50 align-top dark:bg-emerald-950/40'
|
||||||
: 'align-top'
|
: dragging
|
||||||
|
? 'border border-dashed border-sky-400 align-top'
|
||||||
|
: interactive
|
||||||
|
? 'border border-slate-200 align-top dark:border-slate-700'
|
||||||
|
: 'align-top'
|
||||||
}
|
}
|
||||||
onDragOver={(event) => {
|
{...slotDropProps(slot)}
|
||||||
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'
|
|
||||||
}}
|
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const raw =
|
setOverSlot(null)
|
||||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
endDesignerDrag()
|
||||||
event.dataTransfer.getData('text/plain')
|
const payload = readDesignerDragPayload(event)
|
||||||
if (!raw) return
|
if (!payload) return
|
||||||
try {
|
if (payload.source === 'library' && payload.name) {
|
||||||
const payload = JSON.parse(raw)
|
onDropComponent?.(payload.name, node.id, slot)
|
||||||
if (payload.source === 'library') {
|
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||||
onDropComponent?.(payload.name, node.id, slot)
|
// Lets a component be dragged back into a cell, from
|
||||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
// another cell or from anywhere else on the canvas.
|
||||||
// Lets a component be dragged back into a cell, from
|
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1571,22 +1751,13 @@ const NodeView = ({
|
||||||
: children
|
: children
|
||||||
// An empty container's placeholder is an explicit "inside" target, so it never
|
// An empty container's placeholder is an explicit "inside" target, so it never
|
||||||
// depends on where the pointer happens to sit within the node.
|
// depends on where the pointer happens to sit within the node.
|
||||||
const containerDropZoneProps = {
|
const containerDropZone = useDesignerDropZone(interactive, (payload) => {
|
||||||
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
|
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
|
||||||
if (interactive) acceptDesignerDrag(event)
|
else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
|
||||||
},
|
onMoveIntoContainer?.(payload.nodeId, node.id)
|
||||||
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
|
}
|
||||||
if (!interactive) return
|
})
|
||||||
event.preventDefault()
|
const containerDropZoneProps = containerDropZone.props
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// While a Card holds no child node the drop target stays visible below its own
|
// 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
|
// content — otherwise a Card filled by its `children` text looks like it takes
|
||||||
// no components. renderElement keeps that text above whatever is passed here.
|
// no components. renderElement keeps that text above whatever is passed here.
|
||||||
|
|
@ -1595,12 +1766,16 @@ const NodeView = ({
|
||||||
? [
|
? [
|
||||||
<div
|
<div
|
||||||
key={`card_drop_${node.id}`}
|
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 ${
|
className={`rounded border border-dashed px-3 py-4 text-center text-xs transition ${
|
||||||
hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''
|
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}
|
{...containerDropZoneProps}
|
||||||
>
|
>
|
||||||
Bileşeni buraya bırakın
|
{containerDropZone.over ? `${dragLabel} buraya bırakılacak` : 'Bileşeni buraya bırakın'}
|
||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
: renderedChildren
|
: renderedChildren
|
||||||
|
|
@ -1610,13 +1785,23 @@ const NodeView = ({
|
||||||
if (refHidden && !interactive) return null
|
if (refHidden && !interactive) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DesignerDraggedSubtreeContext.Provider value={insideDraggedSubtree}>
|
||||||
<div
|
<div
|
||||||
className={`group/node relative min-h-[28px] rounded-md ${refHidden ? 'opacity-40' : ''} ${
|
className={`group/node relative min-h-[28px] rounded-md ${refHidden ? 'opacity-40' : ''} ${
|
||||||
interactive
|
// Sürüklenen düğümün kendisi soluklaşır; nereye gittiği hedeften okunur.
|
||||||
? selected
|
isDragSource ? 'opacity-40' : ''
|
||||||
? 'z-10 outline outline-2 outline-sky-500 outline-offset-2'
|
} ${
|
||||||
: 'outline outline-1 outline-transparent hover:outline-sky-300'
|
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}
|
data-designer-node={node.id}
|
||||||
draggable={interactive}
|
draggable={interactive}
|
||||||
|
|
@ -1630,12 +1815,9 @@ const NodeView = ({
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
event.stopPropagation()
|
startNodeDrag(event)
|
||||||
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)
|
|
||||||
}}
|
}}
|
||||||
|
onDragEnd={() => endDesignerDrag()}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -1644,37 +1826,79 @@ const NodeView = ({
|
||||||
// the drop and never fires onDrop.
|
// the drop and never fires onDrop.
|
||||||
event.dataTransfer.dropEffect =
|
event.dataTransfer.dropEffect =
|
||||||
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
||||||
|
setDropHint(canDropBeside || canDropInside ? resolveDropHint(event) : null)
|
||||||
}}
|
}}
|
||||||
|
onDragLeave={() => setDropHint(null)}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
setDropHint(null)
|
||||||
|
endDesignerDrag()
|
||||||
const payload = readDesignerDragPayload(event)
|
const payload = readDesignerDragPayload(event)
|
||||||
if (!payload) return
|
if (!payload) return
|
||||||
|
// Bırakma noktası vurguyla birebir aynı kuraldan hesaplanır; kullanıcı
|
||||||
const bounds = event.currentTarget.getBoundingClientRect()
|
// ekranda ne gördüyse o olur.
|
||||||
const offset = event.clientY - bounds.top
|
applyDrop(payload, resolveDropHint(event))
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Ü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 && (
|
{interactive && (
|
||||||
<div
|
<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'}`}
|
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}
|
<FaGripVertical /> {node.type}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1765,6 +1989,7 @@ const NodeView = ({
|
||||||
)}
|
)}
|
||||||
<span className="hidden">{index}</span>
|
<span className="hidden">{index}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</DesignerDraggedSubtreeContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1801,6 +2026,26 @@ const VisualCanvas = ({
|
||||||
// leaves whatever the last event script set in place.
|
// leaves whatever the last event script set in place.
|
||||||
const refFingerprint = Object.keys(nodesByRef).sort().join('|')
|
const refFingerprint = Object.keys(nodesByRef).sort().join('|')
|
||||||
React.useEffect(() => setRefState({}), [refFingerprint])
|
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>(
|
const refStore = React.useMemo<DesignerRefStore>(
|
||||||
() => ({
|
() => ({
|
||||||
state: refState,
|
state: refState,
|
||||||
|
|
@ -1825,12 +2070,15 @@ const VisualCanvas = ({
|
||||||
return (
|
return (
|
||||||
<DesignerRefContext.Provider value={refStore}>
|
<DesignerRefContext.Provider value={refStore}>
|
||||||
<div
|
<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?.('')}
|
onClick={() => interactive && onSelect?.('')}
|
||||||
onDragOver={(event) => interactive && event.preventDefault()}
|
onDragOver={(event) => interactive && event.preventDefault()}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
endDesignerDrag()
|
||||||
const raw =
|
const raw =
|
||||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
|
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
|
||||||
if (!raw) return
|
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 ? (
|
{nodes.length ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{nodes.map((node, index) => (
|
{nodes.map((node, index) => (
|
||||||
|
|
@ -1867,9 +2123,32 @@ const VisualCanvas = ({
|
||||||
onNodePropChange={onNodePropChange}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<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="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">
|
<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">
|
||||||
+
|
+
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
|
||||||
import generatedComponentProps from './generated/componentProps.json'
|
import generatedComponentProps from './generated/componentProps.json'
|
||||||
import {
|
import {
|
||||||
DESIGNER_DATA_COMPONENT_NAMES,
|
DESIGNER_DATA_COMPONENT_NAMES,
|
||||||
|
SQL_DATA_SOURCE_EVENTS,
|
||||||
SQL_DATA_SOURCE_TYPE,
|
SQL_DATA_SOURCE_TYPE,
|
||||||
getDesignerCollectionProperty,
|
getDesignerCollectionProperty,
|
||||||
isDesignerOptionComponent,
|
isDesignerOptionComponent,
|
||||||
|
|
@ -133,6 +134,41 @@ const selectedValue = event?.value ?? null
|
||||||
const selectedLabel = event?.label ?? ''
|
const selectedLabel = event?.label ?? ''
|
||||||
console.log({ selectedValue, selectedLabel })`,
|
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 },
|
params: { skipCount: 0 },
|
||||||
})
|
})
|
||||||
console.log(result)`,
|
console.log(result)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Komponent erişimi',
|
||||||
|
title: 'Select’in 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',
|
group: 'SqlDataSource',
|
||||||
|
|
@ -787,6 +840,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
||||||
category: 'styling',
|
category: 'styling',
|
||||||
},
|
},
|
||||||
{ name: 'className', type: 'string', value: '', 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: [],
|
hooks: [],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import {
|
import {
|
||||||
|
getDesignerBindingColumns,
|
||||||
getDesignerCollectionProperty,
|
getDesignerCollectionProperty,
|
||||||
getDesignerTabSlotValue,
|
getDesignerTabSlotValue,
|
||||||
getSqlDataSourceEndpointId,
|
getSqlDataSourceEndpointId,
|
||||||
getSqlDataSourceKeyField,
|
getSqlDataSourceKeyField,
|
||||||
getSqlDataSourceKeyParam,
|
getSqlDataSourceKeyParam,
|
||||||
getSqlDataSourceKeySource,
|
getSqlDataSourceKeySource,
|
||||||
|
getSqlDataSourceScriptedEvents,
|
||||||
hasSqlDataSourceUrlParams,
|
hasSqlDataSourceUrlParams,
|
||||||
isDesignerDateComponent,
|
isDesignerDateComponent,
|
||||||
isDesignerDateOnlyComponent,
|
isDesignerDateOnlyComponent,
|
||||||
|
|
@ -316,10 +318,47 @@ const sqlIdentifiers = (node: DesignerNode) => {
|
||||||
remove: `sqlDelete_${identifier}`,
|
remove: `sqlDelete_${identifier}`,
|
||||||
previous: `sqlPrev_${identifier}`,
|
previous: `sqlPrev_${identifier}`,
|
||||||
next: `sqlNext_${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}`,
|
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. */
|
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
|
||||||
const sqlSlotLiteral = (
|
const sqlSlotLiteral = (
|
||||||
node: DesignerNode,
|
node: DesignerNode,
|
||||||
|
|
@ -364,7 +403,16 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
||||||
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
||||||
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
||||||
const [${names.busy}, ${names.setBusy}] = React.useState(false)
|
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
|
// 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.
|
// /api/app/orders/{id} or /api/app/orders?id=… for a single record.
|
||||||
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
|
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.
|
// unsaved draft is not overwritten by a re-render.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0))
|
${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(() => {
|
React.useEffect(() => {
|
||||||
const row = ${names.rows}[${names.index}]
|
const row = ${names.rows}[${names.index}]
|
||||||
if (!row) return
|
if (!row) return
|
||||||
${names.setRecord}(row)
|
${names.setRecord}(row)
|
||||||
${names.setOriginal}(row)
|
${names.setOriginal}(row)
|
||||||
${names.setMode}("edit")
|
${names.setMode}("edit")${
|
||||||
}, [${names.rows}, ${names.index}])
|
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 ${names.hasKey} = (() => {
|
||||||
const value = readSqlField(${names.record}, ${names.key})
|
const value = readSqlField(${names.record}, ${names.key})
|
||||||
return value !== undefined && value !== null && value !== ""
|
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 () => {${
|
const ${names.reload} = React.useCallback(async () => {${
|
||||||
setSelectData
|
setSelectData
|
||||||
? `
|
? `
|
||||||
|
|
@ -420,16 +503,21 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
||||||
try {
|
try {
|
||||||
await ${names.reload}()
|
await ${names.reload}()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
${names.setError}(toSqlErrorMessage(error))
|
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'reload')}
|
||||||
} finally {
|
} finally {
|
||||||
${names.setBusy}(false)
|
${names.setBusy}(false)
|
||||||
}
|
}
|
||||||
}, [${names.reload}])
|
}, [${names.reload}])
|
||||||
const ${names.create} = React.useCallback(() => {
|
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
||||||
${names.setError}("")
|
${names.setError}("")
|
||||||
${names.setRecord}({})
|
${names.setRecord}({})
|
||||||
${names.setOriginal}({})
|
${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.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}])
|
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) {
|
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
|
||||||
${names.setError}("Kaydedilecek bir değişiklik yok.")
|
${names.setError}("Kaydedilecek bir değişiklik yok.")
|
||||||
return
|
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.setBusy}(true)
|
||||||
${names.setError}("")
|
${names.setError}("")
|
||||||
|
|
@ -461,28 +556,50 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
||||||
${names.setOriginal}(saved)
|
${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) {
|
} catch (error) {
|
||||||
${names.setError}(toSqlErrorMessage(error))
|
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'save')}
|
||||||
} finally {
|
} finally {
|
||||||
${names.setBusy}(false)
|
${names.setBusy}(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ${names.remove} = async () => {
|
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.
|
// 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?"
|
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
|
||||||
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
|
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
|
||||||
${names.setBusy}(true)
|
${names.setBusy}(true)
|
||||||
${names.setError}("")
|
${names.setError}("")
|
||||||
try {
|
try {${
|
||||||
|
sqlEventHandler(node, 'onAfterDelete')
|
||||||
|
? `
|
||||||
|
const deleted = ${names.record}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
|
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
|
||||||
${names.setRecord}({})
|
${names.setRecord}({})
|
||||||
${names.setOriginal}({})
|
${names.setOriginal}({})
|
||||||
${names.setMode}("new")
|
${names.setMode}("new")
|
||||||
await ${names.reload}()
|
await ${names.reload}()${
|
||||||
|
sqlEventHandler(node, 'onAfterDelete')
|
||||||
|
? `
|
||||||
|
${sqlEventCall(node, 'onAfterDelete', '{ record: deleted }')}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
${names.setError}(toSqlErrorMessage(error))
|
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'delete')}
|
||||||
} finally {
|
} finally {
|
||||||
${names.setBusy}(false)
|
${names.setBusy}(false)
|
||||||
}
|
}
|
||||||
|
|
@ -940,12 +1057,31 @@ const refTargetFor = (
|
||||||
const names = sqlIdentifiers(node)
|
const names = sqlIdentifiers(node)
|
||||||
extras.push(
|
extras.push(
|
||||||
` getRecord: () => ${names.record}`,
|
` getRecord: () => ${names.record}`,
|
||||||
|
` setRecord: (record) => ${names.setRecord}(record || {})`,
|
||||||
|
` getField: (path) => getByPath(${names.record}, path)`,
|
||||||
` setField: (path, value) => ${names.setField}(path, value)`,
|
` 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}`,
|
` getRows: () => ${names.rows}`,
|
||||||
|
` getRowCount: () => ${names.rows}.length`,
|
||||||
|
` getIndex: () => ${names.index}`,
|
||||||
` getMode: () => ${names.mode}`,
|
` getMode: () => ${names.mode}`,
|
||||||
|
` isNew: () => ${names.mode} === "new"`,
|
||||||
` isBusy: () => ${names.busy}`,
|
` isBusy: () => ${names.busy}`,
|
||||||
` getError: () => ${names.error}`,
|
` getError: () => ${names.error}`,
|
||||||
|
` setError: (message) => ${names.setError}(message == null ? "" : String(message))`,
|
||||||
` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`,
|
` 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}()`,
|
` reload: () => ${names.refresh}()`,
|
||||||
` newRecord: () => ${names.create}()`,
|
` newRecord: () => ${names.create}()`,
|
||||||
` save: () => ${names.save}()`,
|
` save: () => ${names.save}()`,
|
||||||
|
|
@ -958,30 +1094,42 @@ const refTargetFor = (
|
||||||
extras,
|
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 =
|
const valueProperty =
|
||||||
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
|
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
|
||||||
return {
|
const base = formField
|
||||||
node,
|
? {
|
||||||
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
|
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
|
||||||
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
|
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
|
||||||
extras,
|
}
|
||||||
|
: 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')
|
.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.
|
// so an event script can read or change another component without prop drilling.
|
||||||
const [designerRefState, setDesignerRefState] = React.useState({})
|
const [designerRefState, setDesignerRefState] = React.useState({})
|
||||||
const designerRefProps = (name) => designerRefState[name]?.props || {}
|
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})`,
|
` 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(
|
const eventNames = new Set(
|
||||||
Object.entries(node.events)
|
Object.entries(node.events)
|
||||||
.filter(([, script]) => script.trim())
|
.filter(([, script]) => script.trim())
|
||||||
|
|
|
||||||
66
ui/src/components/visualDesigner/designerDrag.ts
Normal file
66
ui/src/components/visualDesigner/designerDrag.ts
Normal 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)
|
||||||
245
ui/src/components/visualDesigner/designerScriptDialect.ts
Normal file
245
ui/src/components/visualDesigner/designerScriptDialect.ts
Normal 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.'
|
||||||
|
: ''
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
1056
ui/src/components/visualDesigner/designerScriptRecipes.ts
Normal file
1056
ui/src/components/visualDesigner/designerScriptRecipes.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,8 +8,20 @@ export interface DesignerBinding {
|
||||||
path: string
|
path: string
|
||||||
labelPath?: string
|
labelPath?: string
|
||||||
valuePath?: 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
|
* 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
|
* 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) =>
|
export const getSqlDataSourceSlot = (property: string) =>
|
||||||
SQL_DATA_SOURCE_SLOTS.find((slot) => slot.property === property)
|
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) =>
|
export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) =>
|
||||||
String(node.props?.[property] ?? '').trim()
|
String(node.props?.[property] ?? '').trim()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,159 +91,159 @@ export const ROUTES_ENUM = {
|
||||||
|
|
||||||
accessDenied: '/admin/access-denied',
|
accessDenied: '/admin/access-denied',
|
||||||
|
|
||||||
coordinator: {
|
// coordinator: {
|
||||||
videoroom: {
|
// videoroom: {
|
||||||
dashboard: '/admin/coordinator/videoroom/dashboard',
|
// dashboard: '/admin/coordinator/videoroom/dashboard',
|
||||||
roomList: '/admin/coordinator/videoroom/rooms',
|
// roomList: '/admin/coordinator/videoroom/rooms',
|
||||||
roomDetail: '/admin/coordinator/videoroom/room/:id',
|
// roomDetail: '/admin/coordinator/videoroom/room/:id',
|
||||||
planning: '/admin/coordinator/videoroom/planning/:id',
|
// planning: '/admin/coordinator/videoroom/planning/:id',
|
||||||
},
|
// },
|
||||||
exams: '/admin/coordinator/exams',
|
// exams: '/admin/coordinator/exams',
|
||||||
examDetail: '/admin/coordinator/exam/:id',
|
// examDetail: '/admin/coordinator/exam/:id',
|
||||||
assignments: '/admin/coordinator/assignments',
|
// assignments: '/admin/coordinator/assignments',
|
||||||
assignmentDetail: '/admin/coordinator/assignment/:id',
|
// assignmentDetail: '/admin/coordinator/assignment/:id',
|
||||||
tests: '/admin/coordinator/tests',
|
// tests: '/admin/coordinator/tests',
|
||||||
testDetail: '/admin/coordinator/test/:id',
|
// testDetail: '/admin/coordinator/test/:id',
|
||||||
},
|
// },
|
||||||
|
|
||||||
supplychain: {
|
// supplychain: {
|
||||||
materialTypes: '/admin/supplychain/materials/types',
|
// materialTypes: '/admin/supplychain/materials/types',
|
||||||
materialGroups: '/admin/supplychain/materials/groups',
|
// materialGroups: '/admin/supplychain/materials/groups',
|
||||||
materials: '/admin/supplychain/materials',
|
// materials: '/admin/supplychain/materials',
|
||||||
materialsNew: '/admin/supplychain/materials/new',
|
// materialsNew: '/admin/supplychain/materials/new',
|
||||||
materialsEdit: '/admin/supplychain/materials/edit/:id',
|
// materialsEdit: '/admin/supplychain/materials/edit/:id',
|
||||||
materialsDetail: '/admin/supplychain/materials/detail/:id',
|
// materialsDetail: '/admin/supplychain/materials/detail/:id',
|
||||||
suppliers: '/admin/supplychain/suppliers',
|
// suppliers: '/admin/supplychain/suppliers',
|
||||||
suppliersNew: '/admin/supplychain/suppliers/new',
|
// suppliersNew: '/admin/supplychain/suppliers/new',
|
||||||
suppliersEdit: '/admin/supplychain/suppliers/edit/:id',
|
// suppliersEdit: '/admin/supplychain/suppliers/edit/:id',
|
||||||
suppliersDetail: '/admin/supplychain/suppliers/:id',
|
// suppliersDetail: '/admin/supplychain/suppliers/:id',
|
||||||
requests: '/admin/supplychain/requests',
|
// requests: '/admin/supplychain/requests',
|
||||||
requestsNew: '/admin/supplychain/requests/new',
|
// requestsNew: '/admin/supplychain/requests/new',
|
||||||
requestsEdit: '/admin/supplychain/requests/edit/:id',
|
// requestsEdit: '/admin/supplychain/requests/edit/:id',
|
||||||
requestsView: '/admin/supplychain/requests/view/:id',
|
// requestsView: '/admin/supplychain/requests/view/:id',
|
||||||
requisitions: '/admin/supplychain/requisitions',
|
// requisitions: '/admin/supplychain/requisitions',
|
||||||
requisitionsNew: '/admin/supplychain/requisitions/new',
|
// requisitionsNew: '/admin/supplychain/requisitions/new',
|
||||||
requisitionsEdit: '/admin/supplychain/requisitions/edit/:id',
|
// requisitionsEdit: '/admin/supplychain/requisitions/edit/:id',
|
||||||
requisitionsDetail: '/admin/supplychain/requisitions/:id',
|
// requisitionsDetail: '/admin/supplychain/requisitions/:id',
|
||||||
quotations: '/admin/supplychain/quotations',
|
// quotations: '/admin/supplychain/quotations',
|
||||||
quotationsNew: '/admin/supplychain/quotations/new',
|
// quotationsNew: '/admin/supplychain/quotations/new',
|
||||||
quotationsEdit: '/admin/supplychain/quotations/edit/:id',
|
// quotationsEdit: '/admin/supplychain/quotations/edit/:id',
|
||||||
quotationsView: '/admin/supplychain/quotations/view/:id',
|
// quotationsView: '/admin/supplychain/quotations/view/:id',
|
||||||
approvals: '/admin/supplychain/approvals',
|
// approvals: '/admin/supplychain/approvals',
|
||||||
orders: '/admin/supplychain/orders',
|
// orders: '/admin/supplychain/orders',
|
||||||
ordersNew: '/admin/supplychain/orders/new',
|
// ordersNew: '/admin/supplychain/orders/new',
|
||||||
ordersEdit: '/admin/supplychain/orders/edit/:id',
|
// ordersEdit: '/admin/supplychain/orders/edit/:id',
|
||||||
ordersView: '/admin/supplychain/orders/view/:id',
|
// ordersView: '/admin/supplychain/orders/view/:id',
|
||||||
delivery: '/admin/supplychain/delivery',
|
// delivery: '/admin/supplychain/delivery',
|
||||||
},
|
// },
|
||||||
|
|
||||||
maintenance: {
|
// maintenance: {
|
||||||
equipment: '/admin/maintenance/equipment',
|
// equipment: '/admin/maintenance/equipment',
|
||||||
equipmentNew: '/admin/maintenance/equipment/new',
|
// equipmentNew: '/admin/maintenance/equipment/new',
|
||||||
equipmentEdit: '/admin/maintenance/equipment/edit/:id',
|
// equipmentEdit: '/admin/maintenance/equipment/edit/:id',
|
||||||
equipmentDetail: '/admin/maintenance/equipment/:id',
|
// equipmentDetail: '/admin/maintenance/equipment/:id',
|
||||||
workcenters: '/admin/maintenance/workcenters',
|
// workcenters: '/admin/maintenance/workcenters',
|
||||||
plans: '/admin/maintenance/plans',
|
// plans: '/admin/maintenance/plans',
|
||||||
calendar: '/admin/maintenance/calendar',
|
// calendar: '/admin/maintenance/calendar',
|
||||||
teams: '/admin/maintenance/teams',
|
// teams: '/admin/maintenance/teams',
|
||||||
faults: '/admin/maintenance/faults',
|
// faults: '/admin/maintenance/faults',
|
||||||
workorders: '/admin/maintenance/workorders',
|
// workorders: '/admin/maintenance/workorders',
|
||||||
workordersNew: '/admin/maintenance/workorders/new',
|
// workordersNew: '/admin/maintenance/workorders/new',
|
||||||
workordersDetail: '/admin/maintenance/workorders/:id',
|
// workordersDetail: '/admin/maintenance/workorders/:id',
|
||||||
},
|
// },
|
||||||
|
|
||||||
warehouse: {
|
// warehouse: {
|
||||||
definitions: '/admin/warehouse/definitions',
|
// definitions: '/admin/warehouse/definitions',
|
||||||
tracking: '/admin/warehouse/tracking',
|
// tracking: '/admin/warehouse/tracking',
|
||||||
putaway: '/admin/warehouse/putaway',
|
// putaway: '/admin/warehouse/putaway',
|
||||||
receipt: '/admin/warehouse/receipt',
|
// receipt: '/admin/warehouse/receipt',
|
||||||
issue: '/admin/warehouse/issue',
|
// issue: '/admin/warehouse/issue',
|
||||||
transfer: '/admin/warehouse/transfer',
|
// transfer: '/admin/warehouse/transfer',
|
||||||
inventory: '/admin/warehouse/inventory',
|
// inventory: '/admin/warehouse/inventory',
|
||||||
movements: '/admin/warehouse/movements',
|
// movements: '/admin/warehouse/movements',
|
||||||
movementDetail: '/admin/warehouse/movements/:id',
|
// movementDetail: '/admin/warehouse/movements/:id',
|
||||||
stocklevel: '/admin/warehouse/stocklevel',
|
// stocklevel: '/admin/warehouse/stocklevel',
|
||||||
warehouses: '/admin/warehouse/warehouses',
|
// warehouses: '/admin/warehouse/warehouses',
|
||||||
warehouseNew: '/admin/warehouse/new',
|
// warehouseNew: '/admin/warehouse/new',
|
||||||
warehouseEdit: '/admin/warehouse/edit/:id',
|
// warehouseEdit: '/admin/warehouse/edit/:id',
|
||||||
warehouseDetail: '/admin/warehouse/warehouses/:id',
|
// warehouseDetail: '/admin/warehouse/warehouses/:id',
|
||||||
},
|
// },
|
||||||
|
|
||||||
projects: {
|
// projects: {
|
||||||
list: '/admin/projects',
|
// list: '/admin/projects',
|
||||||
new: '/admin/projects/new',
|
// new: '/admin/projects/new',
|
||||||
edit: '/admin/projects/edit/:id',
|
// edit: '/admin/projects/edit/:id',
|
||||||
detail: '/admin/projects/:id',
|
// detail: '/admin/projects/:id',
|
||||||
tasks: '/admin/projects/tasks',
|
// tasks: '/admin/projects/tasks',
|
||||||
phases: '/admin/projects/phases',
|
// phases: '/admin/projects/phases',
|
||||||
activities: '/admin/projects/activities',
|
// activities: '/admin/projects/activities',
|
||||||
workload: '/admin/projects/workload',
|
// workload: '/admin/projects/workload',
|
||||||
costTracking: '/admin/projects/cost-tracking',
|
// costTracking: '/admin/projects/cost-tracking',
|
||||||
dailyUpdates: '/admin/projects/daily-updates',
|
// dailyUpdates: '/admin/projects/daily-updates',
|
||||||
},
|
// },
|
||||||
|
|
||||||
hr: {
|
// hr: {
|
||||||
employees: '/admin/hr/employees',
|
// employees: '/admin/hr/employees',
|
||||||
employeesNew: '/admin/hr/employees/new',
|
// employeesNew: '/admin/hr/employees/new',
|
||||||
employeesEdit: '/admin/hr/employees/edit/:id',
|
// employeesEdit: '/admin/hr/employees/edit/:id',
|
||||||
employeesDetail: '/admin/hr/employees/:id',
|
// employeesDetail: '/admin/hr/employees/:id',
|
||||||
departments: '/admin/hr/departments',
|
// departments: '/admin/hr/departments',
|
||||||
jobPositions: '/admin/hr/job-positions',
|
// jobPositions: '/admin/hr/job-positions',
|
||||||
employmentTypes: '/admin/hr/employment-types',
|
// employmentTypes: '/admin/hr/employment-types',
|
||||||
organization: '/admin/hr/organization',
|
// organization: '/admin/hr/organization',
|
||||||
badges: '/admin/hr/badges',
|
// badges: '/admin/hr/badges',
|
||||||
leaveManagement: '/admin/hr/leave-management',
|
// leaveManagement: '/admin/hr/leave-management',
|
||||||
overtimes: '/admin/hr/overtimes-management',
|
// overtimes: '/admin/hr/overtimes-management',
|
||||||
payroll: '/admin/hr/payroll',
|
// payroll: '/admin/hr/payroll',
|
||||||
costCenters: '/admin/hr/cost-centers',
|
// costCenters: '/admin/hr/cost-centers',
|
||||||
evaluationTemplates: '/admin/hr/360-templates',
|
// evaluationTemplates: '/admin/hr/360-templates',
|
||||||
evaluation: '/admin/hr/360-evaluation',
|
// evaluation: '/admin/hr/360-evaluation',
|
||||||
},
|
// },
|
||||||
|
|
||||||
crm: {
|
// crm: {
|
||||||
customers: '/admin/crm/customers',
|
// customers: '/admin/crm/customers',
|
||||||
customersNew: '/admin/crm/customers/new',
|
// customersNew: '/admin/crm/customers/new',
|
||||||
customersEdit: '/admin/crm/customers/edit/:id',
|
// customersEdit: '/admin/crm/customers/edit/:id',
|
||||||
customersDetail: '/admin/crm/customers/:id',
|
// customersDetail: '/admin/crm/customers/:id',
|
||||||
salesTeams: '/admin/crm/sales-teams',
|
// salesTeams: '/admin/crm/sales-teams',
|
||||||
salesTeamsNew: '/admin/crm/sales-teams/new',
|
// salesTeamsNew: '/admin/crm/sales-teams/new',
|
||||||
salesTeamsEdit: '/admin/crm/sales-teams/edit/:id',
|
// salesTeamsEdit: '/admin/crm/sales-teams/edit/:id',
|
||||||
salesTeamsDetail: '/admin/crm/sales-teams/:id',
|
// salesTeamsDetail: '/admin/crm/sales-teams/:id',
|
||||||
lossReasons: '/admin/crm/loss-reasons',
|
// lossReasons: '/admin/crm/loss-reasons',
|
||||||
opportunities: '/admin/crm/opportunities',
|
// opportunities: '/admin/crm/opportunities',
|
||||||
opportunitiesNew: '/admin/crm/opportunities/new',
|
// opportunitiesNew: '/admin/crm/opportunities/new',
|
||||||
opportunitiesEdit: '/admin/crm/opportunities/edit/:id',
|
// opportunitiesEdit: '/admin/crm/opportunities/edit/:id',
|
||||||
opportunitiesDetail: '/admin/crm/opportunities/:id',
|
// opportunitiesDetail: '/admin/crm/opportunities/:id',
|
||||||
activities: '/admin/crm/activities',
|
// activities: '/admin/crm/activities',
|
||||||
salesOrders: '/admin/crm/sales-orders',
|
// salesOrders: '/admin/crm/sales-orders',
|
||||||
salesOrdersNew: '/admin/crm/sales-orders/new',
|
// salesOrdersNew: '/admin/crm/sales-orders/new',
|
||||||
salesOrdersEdit: '/admin/crm/sales-orders/edit/:id',
|
// salesOrdersEdit: '/admin/crm/sales-orders/edit/:id',
|
||||||
salesOrdersDetail: '/admin/crm/sales-orders/:id',
|
// salesOrdersDetail: '/admin/crm/sales-orders/:id',
|
||||||
},
|
// },
|
||||||
|
|
||||||
mrp: {
|
// mrp: {
|
||||||
operationTypes: '/admin/mrp/operation-types',
|
// operationTypes: '/admin/mrp/operation-types',
|
||||||
workcenters: '/admin/mrp/workcenters',
|
// workcenters: '/admin/mrp/workcenters',
|
||||||
operations: '/admin/mrp/operations',
|
// operations: '/admin/mrp/operations',
|
||||||
bom: '/admin/mrp/bom',
|
// bom: '/admin/mrp/bom',
|
||||||
productionOrders: '/admin/mrp/production-orders',
|
// productionOrders: '/admin/mrp/production-orders',
|
||||||
productionOrderNew: '/admin/mrp/production-orders/new',
|
// productionOrderNew: '/admin/mrp/production-orders/new',
|
||||||
productionOrderEdit: '/admin/mrp/production-orders/:id/edit',
|
// productionOrderEdit: '/admin/mrp/production-orders/:id/edit',
|
||||||
productionOrderDetail: '/admin/mrp/production-orders/:id',
|
// productionOrderDetail: '/admin/mrp/production-orders/:id',
|
||||||
workOrders: '/admin/mrp/work-orders',
|
// workOrders: '/admin/mrp/work-orders',
|
||||||
demandPlanning: '/admin/mrp/demand-planning',
|
// demandPlanning: '/admin/mrp/demand-planning',
|
||||||
materialRequirements: '/admin/mrp/material-requirements',
|
// materialRequirements: '/admin/mrp/material-requirements',
|
||||||
planningGantt: '/admin/mrp/planning-gantt',
|
// planningGantt: '/admin/mrp/planning-gantt',
|
||||||
},
|
// },
|
||||||
|
|
||||||
accounting: {
|
// accounting: {
|
||||||
currentAccounts: '/admin/accounting/current-accounts',
|
// currentAccounts: '/admin/accounting/current-accounts',
|
||||||
waybills: '/admin/accounting/waybills',
|
// waybills: '/admin/accounting/waybills',
|
||||||
invoices: '/admin/accounting/invoices',
|
// invoices: '/admin/accounting/invoices',
|
||||||
invoicesNew: '/admin/accounting/invoices/new',
|
// invoicesNew: '/admin/accounting/invoices/new',
|
||||||
cash: '/admin/accounting/cash',
|
// cash: '/admin/accounting/cash',
|
||||||
bank: '/admin/accounting/bank',
|
// bank: '/admin/accounting/bank',
|
||||||
checkNote: '/admin/accounting/check-note',
|
// checkNote: '/admin/accounting/check-note',
|
||||||
},
|
// },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ function EditorOptionsBuilderDialog({
|
||||||
`height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp
|
`height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp
|
||||||
yüksekliği doğrudan veriyoruz.
|
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 ─────────────────────────────────────────────── */}
|
{/* ── Başlık ─────────────────────────────────────────────── */}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
|
<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" />
|
<FaMagic className="text-indigo-500" />
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,8 @@
|
||||||
import { Button, Dialog } from '@/components/ui'
|
import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog'
|
||||||
import { SelectBoxOption } from '@/types/shared'
|
import { SelectBoxOption } from '@/types/shared'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import Editor from '@monaco-editor/react'
|
import { useMemo } from 'react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { createFormScriptDialect } from './editor-script/formScriptDialect'
|
||||||
import type { IconType } from 'react-icons'
|
|
||||||
import {
|
|
||||||
FaArrowDown,
|
|
||||||
FaArrowUp,
|
|
||||||
FaBell,
|
|
||||||
FaBook,
|
|
||||||
FaCalculator,
|
|
||||||
FaCheck,
|
|
||||||
FaChevronDown,
|
|
||||||
FaChevronRight,
|
|
||||||
FaCode,
|
|
||||||
FaCopy,
|
|
||||||
FaDatabase,
|
|
||||||
FaExclamationTriangle,
|
|
||||||
FaEye,
|
|
||||||
FaMagic,
|
|
||||||
FaPlug,
|
|
||||||
FaPlus,
|
|
||||||
FaTimes,
|
|
||||||
FaTrash,
|
|
||||||
} from 'react-icons/fa'
|
|
||||||
import {
|
|
||||||
buildRuleLine,
|
|
||||||
buildScriptFromRules,
|
|
||||||
createRule,
|
|
||||||
describeCondition,
|
|
||||||
effectiveTrigger,
|
|
||||||
isRuleComplete,
|
|
||||||
parseScript,
|
|
||||||
recipeGroups,
|
|
||||||
recipes,
|
|
||||||
recipesById,
|
|
||||||
runtimeApiReference,
|
|
||||||
scriptRunsOnOpen,
|
|
||||||
triggerLabels,
|
|
||||||
type ConditionOperator,
|
|
||||||
type RecipeGroupKey,
|
|
||||||
type RecipeParam,
|
|
||||||
type RuleTrigger,
|
|
||||||
type ScriptRule,
|
|
||||||
} from './editor-script/scriptRecipes'
|
|
||||||
|
|
||||||
type EditorScriptBuilderDialogProps = {
|
type EditorScriptBuilderDialogProps = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
|
@ -54,36 +13,10 @@ type EditorScriptBuilderDialogProps = {
|
||||||
onApply: (value: string) => void
|
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'
|
* ListForm editor script'inin düzenleyicisi. Ortak Script Builder'ı form
|
||||||
|
* lehçesiyle kurar; kural motoru ve üretilen script değişmedi.
|
||||||
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)
|
|
||||||
|
|
||||||
function EditorScriptBuilderDialog({
|
function EditorScriptBuilderDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
value,
|
value,
|
||||||
|
|
@ -94,624 +27,24 @@ function EditorScriptBuilderDialog({
|
||||||
}: EditorScriptBuilderDialogProps) {
|
}: EditorScriptBuilderDialogProps) {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
|
|
||||||
const availableFields = useMemo(
|
// Çağıran her render'da yeni bir dizi verebilir; lehçe alan adlarına göre
|
||||||
() =>
|
// sabitlenmezse editör yazarken sıfırlanırdı.
|
||||||
Array.from(
|
const fieldKey = fields.map((field) => String(field.value || field.label || '')).join('|')
|
||||||
new Set(fields.map((field) => String(field.value || field.label || '')).filter(Boolean)),
|
const dialect = useMemo(
|
||||||
).sort((left, right) => left.localeCompare(right)),
|
() => createFormScriptDialect({ currentField, fields: fieldKey.split('|') }),
|
||||||
[fields],
|
[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 (
|
return (
|
||||||
<Dialog
|
<ScriptBuilderDialog
|
||||||
preventScroll
|
cancelLabel={translate('::Cancel')}
|
||||||
|
dialect={dialect}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
width={1360}
|
saveLabel={translate('::Save')}
|
||||||
contentClassName="flex flex-col"
|
value={value}
|
||||||
style={{ overlay: { zIndex: 1300 } }}
|
onApply={onApply}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onRequestClose={onClose}
|
/>
|
||||||
>
|
|
||||||
{/*
|
|
||||||
Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca
|
|
||||||
react-modal içeriğine uygulanıyor (iç panel `height: auto`), ve
|
|
||||||
`.dialog-body` global olarak `flex-1` alıyor — flex-basis 0% olduğu için
|
|
||||||
`height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp
|
|
||||||
yüksekliği doğrudan veriyoruz.
|
|
||||||
*/}
|
|
||||||
<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'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'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
|
|
||||||
açı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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.' },
|
{ 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 = {
|
export type ScriptRule = {
|
||||||
id: string
|
id: string
|
||||||
recipe: RecipeId
|
recipe: RecipeId
|
||||||
condition: RuleCondition
|
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. */
|
/** Tarife göre anlamı değişen serbest parametreler. */
|
||||||
params: Record<string, string>
|
params: Record<string, string>
|
||||||
/** Çoklu alan alan tarifler için (sum, clear). */
|
/** Çoklu alan alan tarifler için (sum, clear). */
|
||||||
|
|
@ -55,6 +65,12 @@ export type ScriptRule = {
|
||||||
trigger?: RuleTrigger
|
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 = {
|
export type RecipeParam = {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -83,8 +99,11 @@ export type Recipe = {
|
||||||
|
|
||||||
export type RecipeId =
|
export type RecipeId =
|
||||||
| 'multiply'
|
| 'multiply'
|
||||||
|
| 'subtract'
|
||||||
|
| 'percent'
|
||||||
| 'sum'
|
| 'sum'
|
||||||
| 'formula'
|
| 'formula'
|
||||||
|
| 'today'
|
||||||
| 'days'
|
| 'days'
|
||||||
| 'hours'
|
| 'hours'
|
||||||
| 'copy'
|
| '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 => {
|
export const describeCondition = (condition: RuleCondition): string => {
|
||||||
if (condition.operator === 'always' || !condition.source) return 'Her zaman'
|
if (condition.operator === 'always' || !condition.source) return 'Her zaman'
|
||||||
const source = label(condition.source, 'alan')
|
const source = label(condition.source, 'alan')
|
||||||
|
|
@ -203,6 +233,104 @@ export const recipes: Recipe[] = [
|
||||||
'hedef',
|
'hedef',
|
||||||
)} alanına yaz.`,
|
)} 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',
|
id: 'sum',
|
||||||
label: 'Toplama',
|
label: 'Toplama',
|
||||||
|
|
@ -355,10 +483,10 @@ export const recipes: Recipe[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
build: ({ params, condition }) => {
|
build: (rule) => {
|
||||||
const expression = buildConditionExpression(condition) || 'true'
|
const expression = buildRuleConditionExpression(rule) || 'true'
|
||||||
const flag = params.invert === 'true' ? `!(${expression})` : expression
|
const flag = rule.params.invert === 'true' ? `!(${expression})` : expression
|
||||||
return `readOnly(${q(params.target)}, ${flag})`
|
return `readOnly(${q(rule.params.target)}, ${flag})`
|
||||||
},
|
},
|
||||||
describe: ({ params, condition }) =>
|
describe: ({ params, condition }) =>
|
||||||
`${describeCondition(condition)} ${label(params.target, 'alan')} alanını ${
|
`${describeCondition(condition)} ${label(params.target, 'alan')} alanını ${
|
||||||
|
|
@ -505,11 +633,13 @@ export const isRuleComplete = (rule: ScriptRule): boolean => {
|
||||||
const recipe = recipesById.get(rule.recipe)
|
const recipe = recipesById.get(rule.recipe)
|
||||||
if (!recipe) return false
|
if (!recipe) return false
|
||||||
|
|
||||||
const conditionReady =
|
const conditionReady = ruleConditions(rule).every(
|
||||||
rule.condition.operator === 'always' ||
|
(condition) =>
|
||||||
(!!rule.condition.source &&
|
condition.operator === 'always' ||
|
||||||
(['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(rule.condition.operator) ||
|
(!!condition.source &&
|
||||||
rule.condition.value.trim() !== ''))
|
(['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(condition.operator) ||
|
||||||
|
condition.value.trim() !== '')),
|
||||||
|
)
|
||||||
if (!conditionReady) return false
|
if (!conditionReady) return false
|
||||||
|
|
||||||
return recipe.params.every((param) => {
|
return recipe.params.every((param) => {
|
||||||
|
|
@ -543,7 +673,7 @@ export const buildRuleLine = (rule: ScriptRule, runsOnOpen = false): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!recipe.conditionIsArgument) {
|
if (!recipe.conditionIsArgument) {
|
||||||
const condition = buildConditionExpression(rule.condition)
|
const condition = buildRuleConditionExpression(rule)
|
||||||
if (condition) guards.push(condition)
|
if (condition) guards.push(condition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
FaSlidersH,
|
FaSlidersH,
|
||||||
FaSync,
|
FaSync,
|
||||||
FaTabletAlt,
|
FaTabletAlt,
|
||||||
|
FaTimes,
|
||||||
FaTrash,
|
FaTrash,
|
||||||
FaUndo,
|
FaUndo,
|
||||||
} from 'react-icons/fa'
|
} from 'react-icons/fa'
|
||||||
|
|
@ -39,6 +40,11 @@ import { developerKitService } from '@/services/developerKit.service'
|
||||||
import { Button, Notification, toast } from '@/components/ui'
|
import { Button, Notification, toast } from '@/components/ui'
|
||||||
import StyleModal from '@/components/codeLayout/StyleModal'
|
import StyleModal from '@/components/codeLayout/StyleModal'
|
||||||
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
|
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
|
||||||
|
import {
|
||||||
|
beginDesignerDragSoon,
|
||||||
|
endDesignerDrag,
|
||||||
|
useDesignerDrag,
|
||||||
|
} from '@/components/visualDesigner/designerDrag'
|
||||||
import {
|
import {
|
||||||
DESIGNER_PRIMARY_EVENTS,
|
DESIGNER_PRIMARY_EVENTS,
|
||||||
DESIGNER_SCRIPT_RECIPES,
|
DESIGNER_SCRIPT_RECIPES,
|
||||||
|
|
@ -47,8 +53,10 @@ import {
|
||||||
getDesignerEventSnippet,
|
getDesignerEventSnippet,
|
||||||
} from '@/components/visualDesigner/catalog'
|
} from '@/components/visualDesigner/catalog'
|
||||||
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
||||||
|
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
|
||||||
import {
|
import {
|
||||||
collectDesignerRefs,
|
collectDesignerRefs,
|
||||||
|
getSqlDataSourceEvent,
|
||||||
createDesignerId,
|
createDesignerId,
|
||||||
createEmptyDesignerDocument,
|
createEmptyDesignerDocument,
|
||||||
findDesignerNode,
|
findDesignerNode,
|
||||||
|
|
@ -56,6 +64,7 @@ import {
|
||||||
renameDesignerRefInNodes,
|
renameDesignerRefInNodes,
|
||||||
renameDesignerRefInScript,
|
renameDesignerRefInScript,
|
||||||
withDesignerRefs,
|
withDesignerRefs,
|
||||||
|
getDesignerBindingColumns,
|
||||||
getDesignerCollectionProperty,
|
getDesignerCollectionProperty,
|
||||||
getDesignerValueByPath,
|
getDesignerValueByPath,
|
||||||
getSqlDataSourceEndpointId,
|
getSqlDataSourceEndpointId,
|
||||||
|
|
@ -1171,6 +1180,13 @@ const VisualComponentDesigner = () => {
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(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 [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
|
||||||
const undoStack = useRef<DesignerDocument[]>([])
|
const undoStack = useRef<DesignerDocument[]>([])
|
||||||
const redoStack = useRef<DesignerDocument[]>([])
|
const redoStack = useRef<DesignerDocument[]>([])
|
||||||
|
|
@ -2341,6 +2357,7 @@ const VisualComponentDesigner = () => {
|
||||||
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
|
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
|
||||||
? selectedNode?.bindings?.[optionDataProperty]
|
? selectedNode?.bindings?.[optionDataProperty]
|
||||||
: undefined
|
: undefined
|
||||||
|
const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding)
|
||||||
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
|
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
|
||||||
? selectedNode?.bindings?.items
|
? selectedNode?.bindings?.items
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -2882,6 +2899,69 @@ const VisualComponentDesigner = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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('…')
|
||||||
|
</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('sutun')</code>{' '}
|
||||||
|
ile okunur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectCollectionSample &&
|
{selectCollectionSample &&
|
||||||
selectColumnFields.length > 0 &&
|
selectColumnFields.length > 0 &&
|
||||||
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
|
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
|
||||||
|
|
@ -3668,6 +3748,27 @@ const VisualComponentDesigner = () => {
|
||||||
current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet,
|
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
|
const endpointResultText = endpointResultModal
|
||||||
? typeof endpointResultModal.result === 'string'
|
? typeof endpointResultModal.result === 'string'
|
||||||
? endpointResultModal.result
|
? endpointResultModal.result
|
||||||
|
|
@ -3817,8 +3918,16 @@ const VisualComponentDesigner = () => {
|
||||||
<button
|
<button
|
||||||
key={`${definition.toolboxGroup}-${definition.name}`}
|
key={`${definition.toolboxGroup}-${definition.name}`}
|
||||||
draggable
|
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"
|
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 ${
|
||||||
title={definition.description}
|
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"
|
type="button"
|
||||||
onClick={() => addComponent(definition.name, null)}
|
onClick={() => addComponent(definition.name, null)}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
|
|
@ -3829,7 +3938,13 @@ const VisualComponentDesigner = () => {
|
||||||
event.dataTransfer.effectAllowed = 'copy'
|
event.dataTransfer.effectAllowed = 'copy'
|
||||||
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
|
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
|
||||||
event.dataTransfer.setData('text/plain', 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">
|
<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()}
|
{definition.name.slice(0, 2).toUpperCase()}
|
||||||
|
|
@ -4123,11 +4238,35 @@ const VisualComponentDesigner = () => {
|
||||||
onFocus={() => setFocusedEventName(eventName)}
|
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">
|
<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
|
||||||
<span className="flex items-center gap-2">
|
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">
|
<span className="text-[9px] font-normal text-slate-400">
|
||||||
{activeJavaScriptTarget.node.type}
|
{activeJavaScriptTarget.node.type}
|
||||||
</span>
|
</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
|
<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"
|
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(
|
disabled={Boolean(
|
||||||
|
|
@ -4138,9 +4277,11 @@ const VisualComponentDesigner = () => {
|
||||||
updateNodeEvent(
|
updateNodeEvent(
|
||||||
activeJavaScriptTarget.node.id,
|
activeJavaScriptTarget.node.id,
|
||||||
eventName,
|
eventName,
|
||||||
getDesignerEventSnippet(
|
resolveRecipeCode(
|
||||||
activeJavaScriptTarget.node.type,
|
getDesignerEventSnippet(
|
||||||
eventName,
|
activeJavaScriptTarget.node.type,
|
||||||
|
eventName,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -4719,6 +4860,23 @@ const VisualComponentDesigner = () => {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue