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`.
|
||||
|
||||
## Communication Rules (apply to every response)
|
||||
|
||||
1. **Never introduce or summarize the platform.** Assume the user knows what this
|
||||
application is, how it is built, and which technologies it uses. Do not open a response
|
||||
with "Sozsoft Platform is a multi-tenant low-code engine…" or any equivalent framing.
|
||||
2. **Answer the request, nothing more.** No unsolicited architecture overviews, no
|
||||
restating the decision order, no re-explaining ListForm/DeveloperKit concepts unless
|
||||
the user actually asked about them.
|
||||
3. **No preamble, no epilogue.** Skip "Great question", "I reviewed the codebase",
|
||||
summaries of what you are about to do, and closing recaps of what you just did.
|
||||
4. **Match the question's size.** A yes/no question gets a sentence. A config question
|
||||
gets the config. Only a full feature request gets a full proposal.
|
||||
5. **Respond in the language the user writes in** (Turkish → Turkish).
|
||||
6. Mention a rule from this file only when it changes the answer — never as boilerplate.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Maximize delivery through runtime configuration.
|
||||
|
|
@ -17,7 +32,9 @@ Primary principle: Configuration first, code last.
|
|||
|
||||
## Mandatory Decision Order
|
||||
|
||||
For every request, evaluate and propose in this order:
|
||||
For every request, evaluate in this order — internally. State the chosen step only when the
|
||||
user is asking how to build something, and state it in one line, not as a walkthrough of the
|
||||
options you rejected.
|
||||
|
||||
1. Dynamic configuration with existing ListForm ecosystem
|
||||
2. SQL Query Manager + Custom Endpoint
|
||||
|
|
@ -28,14 +45,15 @@ For every request, evaluate and propose in this order:
|
|||
|
||||
1. Do not propose new custom React component/page development for standard feature requests.
|
||||
2. Build new screens using platform configuration mechanisms.
|
||||
3. Every proposal must include tenant and permission design.
|
||||
3. Every implementation proposal must include tenant and permission design.
|
||||
4. Never bypass platform authorization patterns.
|
||||
5. Never hardcode secrets, tenant IDs, or connection strings.
|
||||
|
||||
Exception:
|
||||
|
||||
- Custom React/backend code is allowed only when the user explicitly requests implementation and configuration is insufficient.
|
||||
- In such cases, explain why configuration-first options are not enough.
|
||||
- Custom React/backend code is allowed only when the user explicitly requests implementation
|
||||
and configuration is insufficient.
|
||||
- In such cases, explain why configuration-first options are not enough — in a sentence or two.
|
||||
|
||||
## Architecture Guardrails
|
||||
|
||||
|
|
@ -58,7 +76,12 @@ Exception:
|
|||
|
||||
## Response Contract
|
||||
|
||||
When producing an implementation proposal, include:
|
||||
The list below applies **only** to a full implementation proposal for a new screen, module,
|
||||
or integration. It is not a template for questions, debugging, code review, refactors,
|
||||
explanations, or small changes.
|
||||
|
||||
Even for a full proposal: include only the items that carry real content for that request,
|
||||
and drop the rest. An empty or obvious heading is noise.
|
||||
|
||||
1. Goal
|
||||
2. Decision flow result (which step used)
|
||||
|
|
|
|||
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 { formatLocaleValue } from '@/utils/localeFormat'
|
||||
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
|
||||
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
|
||||
import {
|
||||
fromDesignerDate,
|
||||
getDesignerBindingColumns,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerTabSlot,
|
||||
getDesignerValueByPath,
|
||||
|
|
@ -68,6 +70,52 @@ const acceptDesignerDrag = (event: React.DragEvent<HTMLElement>) => {
|
|||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* "Sürüklenen düğümün içindeyiz" bilgisi ağaçtan aşağı akar. Bir bileşen kendi
|
||||
* alt ağacının içine taşınamaz; onun dışındaki her hedef — kardeşler, üst
|
||||
* konteynerler, kök — geçerlidir ve toolbox'tan bırakmayla birebir aynı geri
|
||||
* bildirimi alır.
|
||||
*/
|
||||
const DesignerDraggedSubtreeContext = React.createContext(false)
|
||||
|
||||
/**
|
||||
* 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) =>
|
||||
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
|
||||
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
|
||||
|
|
@ -204,13 +252,70 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
|
|||
...(isSqlDataSourceNode(node.type)
|
||||
? {
|
||||
getRecord: () => ({}),
|
||||
setRecord: designTimeOnly('setRecord'),
|
||||
getField: () => undefined,
|
||||
setField: designTimeOnly('setField'),
|
||||
setFields: designTimeOnly('setFields'),
|
||||
clearFields: designTimeOnly('clearFields'),
|
||||
getOriginal: () => ({}),
|
||||
getChanges: () => ({}),
|
||||
hasChanges: () => false,
|
||||
getKey: () => undefined,
|
||||
getKeyField: () => getSqlDataSourceKeyField(node),
|
||||
getRows: () => [],
|
||||
getRowCount: () => 0,
|
||||
getIndex: () => 0,
|
||||
getMode: () => 'edit',
|
||||
isNew: () => false,
|
||||
isBusy: () => false,
|
||||
getError: () => '',
|
||||
setError: designTimeOnly('setError'),
|
||||
goToRow: designTimeOnly('goToRow'),
|
||||
nextRow: designTimeOnly('nextRow'),
|
||||
prevRow: designTimeOnly('prevRow'),
|
||||
firstRow: designTimeOnly('firstRow'),
|
||||
lastRow: designTimeOnly('lastRow'),
|
||||
reload: designTimeOnly('reload'),
|
||||
newRecord: designTimeOnly('newRecord'),
|
||||
save: designTimeOnly('save'),
|
||||
remove: designTimeOnly('remove'),
|
||||
}
|
||||
: {}),
|
||||
// Select/AutoComplete/Menu: seçilen kaydın görünmeyen sütunları. Tasarım
|
||||
// ekranında yalnızca statik seçenekler ve önizleme değeri elde olduğu için
|
||||
// erişimciler bunlarla çalışır; çalışma zamanında aynı isimler endpoint
|
||||
// sonucundaki tüm sütunları okur.
|
||||
...(isDesignerOptionComponent(node.type)
|
||||
? (() => {
|
||||
const currentOption = () => {
|
||||
const value = overrideProps()[valueProperty] ?? node.props[valueProperty]
|
||||
if (value && typeof value === 'object') return value as Record<string, unknown>
|
||||
const options = node.props[getDesignerCollectionProperty(node.type)]
|
||||
if (!Array.isArray(options)) return null
|
||||
return (
|
||||
(options.find(
|
||||
(option) =>
|
||||
option &&
|
||||
typeof option === 'object' &&
|
||||
(option as Record<string, unknown>).value === value,
|
||||
) as Record<string, unknown>) ?? null
|
||||
)
|
||||
}
|
||||
return {
|
||||
getOptions: () => {
|
||||
const options = node.props[getDesignerCollectionProperty(node.type)]
|
||||
return Array.isArray(options) ? options : []
|
||||
},
|
||||
getSelectedOption: () => currentOption(),
|
||||
getColumn: (path: string) => getDesignerValueByPath(currentOption(), String(path || '')),
|
||||
getLabel: () => String(currentOption()?.label ?? ''),
|
||||
getColumnNames: () =>
|
||||
getDesignerBindingColumns(
|
||||
node.bindings?.[getDesignerCollectionProperty(node.type)],
|
||||
),
|
||||
}
|
||||
})()
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
return refs
|
||||
|
|
@ -1216,6 +1321,95 @@ const NodeView = ({
|
|||
'Tabs',
|
||||
'SqlDataSource',
|
||||
].includes(node.type)
|
||||
|
||||
/* ---- Sürükleme geri bildirimi ---------------------------------- */
|
||||
const drag = useDesignerDrag()
|
||||
const [dropHint, setDropHint] = React.useState<'before' | 'inside' | 'after' | null>(null)
|
||||
/** Tabs sekmesi / tablo hücresi gibi slot hedeflerinden hangisinin üzerindeyiz. */
|
||||
const [overSlot, setOverSlot] = React.useState<string | null>(null)
|
||||
React.useEffect(() => {
|
||||
if (drag) return
|
||||
setDropHint(null)
|
||||
setOverSlot(null)
|
||||
}, [drag])
|
||||
/** Slot hedeflerinin ortak sürükleme davranışı: kabul et ve kendini işaretle. */
|
||||
const slotDropProps = (slot: string) => ({
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => {
|
||||
if (!interactive) return
|
||||
acceptDesignerDrag(event)
|
||||
setOverSlot(slot)
|
||||
},
|
||||
onDragLeave: () => setOverSlot((current) => (current === slot ? null : current)),
|
||||
})
|
||||
const dragging = interactive && Boolean(drag)
|
||||
const isDragSource = drag?.nodeId === node.id
|
||||
// Kendisi ve alt ağacı dışında her hedef geçerli: kardeşinin yanına, bir üst
|
||||
// konteynerin içine ya da köke taşımak hep anlamlı işlemler.
|
||||
const insideDraggedSubtree = React.useContext(DesignerDraggedSubtreeContext) || isDragSource
|
||||
const canDropInside = acceptsDroppedChildren && !insideDraggedSubtree
|
||||
const canDropBeside = !insideDraggedSubtree
|
||||
const dragLabel = drag?.label || drag?.name || 'Bileşen'
|
||||
/**
|
||||
* Taşımayı başlatan ortak kod. Hem düğüm gövdesi hem de tutamak bunu kullanır:
|
||||
* Input/Select/Textarea gibi canlı önizleme kontrolleri Chrome'da üst öğenin
|
||||
* sürüklenmesini başlatmadığı için, gövdesi tamamen bir kontrolle kaplı bir
|
||||
* bileşenin tutulabileceği tek yer tutamaktır.
|
||||
*/
|
||||
const startNodeDrag = (event: React.DragEvent<HTMLElement>) => {
|
||||
event.stopPropagation()
|
||||
const payload = JSON.stringify({ source: 'canvas', nodeId: node.id })
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
|
||||
event.dataTransfer.setData('text/plain', payload)
|
||||
// `dragstart` içinde senkron bir state güncellemesi sürüklenen düğümü hemen
|
||||
// yeniden render eder ve tarayıcı sürüklemeyi iptal eder. Vurguları bir tık
|
||||
// sonraya bırakınca sürükleme sağlam başlar, geri bildirim de kaybolmaz.
|
||||
beginDesignerDragSoon({ source: 'canvas', nodeId: node.id, label: node.ref || node.type })
|
||||
}
|
||||
/** Pointer'ın dikey konumu bırakmanın anlamını belirler. */
|
||||
const resolveDropHint = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const offset = event.clientY - bounds.top
|
||||
// Konteynerlerde orta bant "içine", dış çeyrekler "yanına" demektir.
|
||||
if (canDropInside && offset > bounds.height * 0.25 && offset < bounds.height * 0.75) {
|
||||
return 'inside' as const
|
||||
}
|
||||
return offset < bounds.height / 2 ? ('before' as const) : ('after' as const)
|
||||
}
|
||||
/** Bırakmanın karşılığı olan ağaç işlemi. Kenar şeritleri de bunu kullanır. */
|
||||
const applyDrop = (payload: DesignerDragPayload, hint: 'before' | 'inside' | 'after') => {
|
||||
const placement = hint === 'before' ? 'before' : 'after'
|
||||
if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
|
||||
if (hint === 'inside') onMoveIntoContainer?.(payload.nodeId, node.id)
|
||||
else onReorder?.(payload.nodeId, node.id, placement)
|
||||
} else if (payload.source === 'library' && payload.name) {
|
||||
if (hint === 'inside') onDropComponent?.(payload.name, node.id)
|
||||
else onDropComponentBeside?.(payload.name, node.id, placement)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Düğümler arasındaki boşluk hiçbir düğüme ait değildi; oraya bırakılan bileşen
|
||||
* canvas'ın köküne, yani sayfanın en altına gidiyordu. Sürükleme sürerken her
|
||||
* düğüm kendi üst ve alt boşluğunu kaplayan görünmez birer şerit açar, böylece
|
||||
* "araya bırak" tam olarak nişan alınan yere düşer.
|
||||
*/
|
||||
const edgeStripProps = (placement: 'before' | 'after') => ({
|
||||
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!interactive) return
|
||||
acceptDesignerDrag(event)
|
||||
setDropHint(canDropBeside ? placement : null)
|
||||
},
|
||||
onDragLeave: () => setDropHint(null),
|
||||
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDropHint(null)
|
||||
endDesignerDrag()
|
||||
const payload = readDesignerDragPayload(event)
|
||||
if (payload) applyDrop(payload, placement)
|
||||
},
|
||||
})
|
||||
const staticChildren = node.props.children
|
||||
const hasStaticChildren =
|
||||
staticChildren !== undefined &&
|
||||
|
|
@ -1317,31 +1511,26 @@ const NodeView = ({
|
|||
return (
|
||||
<UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
|
||||
<div
|
||||
className="min-h-14 py-2"
|
||||
onDragOver={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect =
|
||||
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
||||
}}
|
||||
className={`min-h-14 rounded py-2 transition ${
|
||||
overSlot === slot
|
||||
? 'bg-emerald-50 outline outline-2 outline-emerald-500 dark:bg-emerald-950/40'
|
||||
: dragging
|
||||
? 'outline-dashed outline-1 outline-sky-400/70'
|
||||
: ''
|
||||
}`}
|
||||
{...slotDropProps(slot)}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, node.id, slot)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||
}
|
||||
} catch {
|
||||
// Ignore payloads that do not belong to the visual designer.
|
||||
setOverSlot(null)
|
||||
endDesignerDrag()
|
||||
const payload = readDesignerDragPayload(event)
|
||||
if (!payload) return
|
||||
if (payload.source === 'library' && payload.name) {
|
||||
onDropComponent?.(payload.name, node.id, slot)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -1414,38 +1603,29 @@ const NodeView = ({
|
|||
<td
|
||||
key={slot}
|
||||
className={
|
||||
interactive
|
||||
? 'border border-slate-200 align-top dark:border-slate-700'
|
||||
: 'align-top'
|
||||
overSlot === slot
|
||||
? 'border-2 border-emerald-500 bg-emerald-50 align-top dark:bg-emerald-950/40'
|
||||
: dragging
|
||||
? 'border border-dashed border-sky-400 align-top'
|
||||
: interactive
|
||||
? 'border border-slate-200 align-top dark:border-slate-700'
|
||||
: 'align-top'
|
||||
}
|
||||
onDragOver={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Must match the source's effectAllowed, otherwise the
|
||||
// browser rejects the drop and never fires onDrop.
|
||||
event.dataTransfer.dropEffect =
|
||||
event.dataTransfer.effectAllowed === 'move' ? 'move' : 'copy'
|
||||
}}
|
||||
{...slotDropProps(slot)}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, node.id, slot)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
// Lets a component be dragged back into a cell, from
|
||||
// another cell or from anywhere else on the canvas.
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||
}
|
||||
} catch {
|
||||
// Ignore payloads that do not belong to the visual designer.
|
||||
setOverSlot(null)
|
||||
endDesignerDrag()
|
||||
const payload = readDesignerDragPayload(event)
|
||||
if (!payload) return
|
||||
if (payload.source === 'library' && payload.name) {
|
||||
onDropComponent?.(payload.name, node.id, slot)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
// Lets a component be dragged back into a cell, from
|
||||
// another cell or from anywhere else on the canvas.
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id, slot)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -1571,22 +1751,13 @@ const NodeView = ({
|
|||
: children
|
||||
// An empty container's placeholder is an explicit "inside" target, so it never
|
||||
// depends on where the pointer happens to sit within the node.
|
||||
const containerDropZoneProps = {
|
||||
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (interactive) acceptDesignerDrag(event)
|
||||
},
|
||||
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const payload = readDesignerDragPayload(event)
|
||||
if (!payload) return
|
||||
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
|
||||
else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id)
|
||||
}
|
||||
},
|
||||
}
|
||||
const containerDropZone = useDesignerDropZone(interactive, (payload) => {
|
||||
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
|
||||
else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
|
||||
onMoveIntoContainer?.(payload.nodeId, node.id)
|
||||
}
|
||||
})
|
||||
const containerDropZoneProps = containerDropZone.props
|
||||
// While a Card holds no child node the drop target stays visible below its own
|
||||
// content — otherwise a Card filled by its `children` text looks like it takes
|
||||
// no components. renderElement keeps that text above whatever is passed here.
|
||||
|
|
@ -1595,12 +1766,16 @@ const NodeView = ({
|
|||
? [
|
||||
<div
|
||||
key={`card_drop_${node.id}`}
|
||||
className={`rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700 ${
|
||||
hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''
|
||||
}`}
|
||||
className={`rounded border border-dashed px-3 py-4 text-center text-xs transition ${
|
||||
containerDropZone.over
|
||||
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
|
||||
: containerDropZone.dragging
|
||||
? 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
|
||||
: 'border-slate-300 text-slate-400 dark:border-slate-700'
|
||||
} ${hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''}`}
|
||||
{...containerDropZoneProps}
|
||||
>
|
||||
Bileşeni buraya bırakın
|
||||
{containerDropZone.over ? `${dragLabel} buraya bırakılacak` : 'Bileşeni buraya bırakın'}
|
||||
</div>,
|
||||
]
|
||||
: renderedChildren
|
||||
|
|
@ -1610,13 +1785,23 @@ const NodeView = ({
|
|||
if (refHidden && !interactive) return null
|
||||
|
||||
return (
|
||||
<DesignerDraggedSubtreeContext.Provider value={insideDraggedSubtree}>
|
||||
<div
|
||||
className={`group/node relative min-h-[28px] rounded-md ${refHidden ? 'opacity-40' : ''} ${
|
||||
interactive
|
||||
? selected
|
||||
? 'z-10 outline outline-2 outline-sky-500 outline-offset-2'
|
||||
: 'outline outline-1 outline-transparent hover:outline-sky-300'
|
||||
: ''
|
||||
// Sürüklenen düğümün kendisi soluklaşır; nereye gittiği hedeften okunur.
|
||||
isDragSource ? 'opacity-40' : ''
|
||||
} ${
|
||||
dropHint === 'inside'
|
||||
? 'z-10 bg-emerald-500/10 outline outline-2 outline-emerald-500 outline-offset-2'
|
||||
: // Sürükleme sürerken içine bırakılabilen her konteyner işaretlenir, böylece
|
||||
// kullanıcı hedefe varmadan önce nereye bırakabileceğini görür.
|
||||
dragging && canDropInside
|
||||
? 'outline-dashed outline-1 outline-sky-400/70 outline-offset-2'
|
||||
: interactive
|
||||
? selected
|
||||
? 'z-10 outline outline-2 outline-sky-500 outline-offset-2'
|
||||
: 'outline outline-1 outline-transparent hover:outline-sky-300'
|
||||
: ''
|
||||
}`}
|
||||
data-designer-node={node.id}
|
||||
draggable={interactive}
|
||||
|
|
@ -1630,12 +1815,9 @@ const NodeView = ({
|
|||
}}
|
||||
onDragStart={(event) => {
|
||||
if (!interactive) return
|
||||
event.stopPropagation()
|
||||
const payload = JSON.stringify({ source: 'canvas', nodeId: node.id })
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
|
||||
event.dataTransfer.setData('text/plain', payload)
|
||||
startNodeDrag(event)
|
||||
}}
|
||||
onDragEnd={() => endDesignerDrag()}
|
||||
onDragOver={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
|
|
@ -1644,37 +1826,79 @@ const NodeView = ({
|
|||
// the drop and never fires onDrop.
|
||||
event.dataTransfer.dropEffect =
|
||||
event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
|
||||
setDropHint(canDropBeside || canDropInside ? resolveDropHint(event) : null)
|
||||
}}
|
||||
onDragLeave={() => setDropHint(null)}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDropHint(null)
|
||||
endDesignerDrag()
|
||||
const payload = readDesignerDragPayload(event)
|
||||
if (!payload) return
|
||||
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const offset = event.clientY - bounds.top
|
||||
// A container claims its middle band as "drop inside"; the outer quarters
|
||||
// stay reserved for placing the node next to it, which is the only
|
||||
// meaningful option on a component that cannot host children.
|
||||
const inside =
|
||||
acceptsDroppedChildren && offset > bounds.height * 0.25 && offset < bounds.height * 0.75
|
||||
const placement = offset < bounds.height / 2 ? 'before' : 'after'
|
||||
|
||||
if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
|
||||
if (inside) onMoveIntoContainer?.(payload.nodeId, node.id)
|
||||
else onReorder?.(payload.nodeId, node.id, placement)
|
||||
} else if (payload.source === 'library' && payload.name) {
|
||||
if (inside) onDropComponent?.(payload.name, node.id)
|
||||
else onDropComponentBeside?.(payload.name, node.id, placement)
|
||||
}
|
||||
// Bırakma noktası vurguyla birebir aynı kuraldan hesaplanır; kullanıcı
|
||||
// ekranda ne gördüyse o olur.
|
||||
applyDrop(payload, resolveDropHint(event))
|
||||
}}
|
||||
>
|
||||
{/* Üst ve alt boşluğu kaplayan görünmez "araya bırak" şeritleri. */}
|
||||
{dragging && canDropBeside && (
|
||||
<>
|
||||
<div className="absolute inset-x-0 -top-2 z-20 h-2" {...edgeStripProps('before')} />
|
||||
<div className="absolute inset-x-0 -bottom-2 z-20 h-2" {...edgeStripProps('after')} />
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen kontrolle
|
||||
kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini başlatmaz; tutamak
|
||||
her bileşen için garanti bir tutma noktası verir. Sürükleme sürerken
|
||||
gizlenir, aksi halde komşu düğümün bırakma alanını kapatırdı.
|
||||
*/}
|
||||
{/* Yanına bırakma çizgisi: bileşen tam olarak buraya girer. */}
|
||||
{dropHint === 'before' && (
|
||||
<div className="pointer-events-none absolute inset-x-0 -top-1 z-30 flex items-center gap-1">
|
||||
<span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
|
||||
{dragLabel} üstüne
|
||||
</span>
|
||||
<span className="h-[3px] flex-1 rounded bg-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
{dropHint === 'after' && (
|
||||
<div className="pointer-events-none absolute inset-x-0 -bottom-1 z-30 flex items-center gap-1">
|
||||
<span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
|
||||
{dragLabel} altına
|
||||
</span>
|
||||
<span className="h-[3px] flex-1 rounded bg-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
{dropHint === 'inside' && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-30 rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
|
||||
{node.type} içine
|
||||
</span>
|
||||
)}
|
||||
{/* Sürükleme sürerken bırakılamayan hedef açıkça söylenir. */}
|
||||
{dragging && insideDraggedSubtree && !isDragSource && (
|
||||
<span className="pointer-events-none absolute right-1 top-1 z-30 rounded bg-slate-500 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
|
||||
buraya taşınamaz
|
||||
</span>
|
||||
)}
|
||||
{interactive && (
|
||||
<div
|
||||
className={`absolute right-1 top-1 z-20 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-lg ${selected ? 'flex' : 'hidden'}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 px-2 text-[10px] font-semibold">
|
||||
{/*
|
||||
Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen canlı
|
||||
kontrolle kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini
|
||||
başlatmaz; bu rozet her bileşen için garanti bir tutma noktası verir.
|
||||
*/}
|
||||
<span
|
||||
draggable
|
||||
className="flex cursor-grab items-center gap-1 px-2 text-[10px] font-semibold active:cursor-grabbing"
|
||||
title={`${node.ref || node.type} — sürükleyerek taşı`}
|
||||
onDragStart={startNodeDrag}
|
||||
onDragEnd={() => endDesignerDrag()}
|
||||
>
|
||||
<FaGripVertical /> {node.type}
|
||||
</span>
|
||||
<button
|
||||
|
|
@ -1765,6 +1989,7 @@ const NodeView = ({
|
|||
)}
|
||||
<span className="hidden">{index}</span>
|
||||
</div>
|
||||
</DesignerDraggedSubtreeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1801,6 +2026,26 @@ const VisualCanvas = ({
|
|||
// leaves whatever the last event script set in place.
|
||||
const refFingerprint = Object.keys(nodesByRef).sort().join('|')
|
||||
React.useEffect(() => setRefState({}), [refFingerprint])
|
||||
const drag = useDesignerDrag()
|
||||
// Sürükleme canvas dışında bırakılırsa da vurgular temizlenmeli.
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const clear = () => endDesignerDrag()
|
||||
window.addEventListener('dragend', clear)
|
||||
window.addEventListener('drop', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear)
|
||||
window.removeEventListener('drop', clear)
|
||||
}
|
||||
}, [])
|
||||
const rootDropZone = useDesignerDropZone(interactive, (payload) => {
|
||||
if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, null)
|
||||
// Kök alana bırakmak düğümü içinde bulunduğu konteynerden çıkarır.
|
||||
else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
onMoveIntoContainer?.(payload.nodeId, null)
|
||||
}
|
||||
})
|
||||
|
||||
const refStore = React.useMemo<DesignerRefStore>(
|
||||
() => ({
|
||||
state: refState,
|
||||
|
|
@ -1825,12 +2070,15 @@ const VisualCanvas = ({
|
|||
return (
|
||||
<DesignerRefContext.Provider value={refStore}>
|
||||
<div
|
||||
className="min-h-full p-8 text-slate-900 dark:text-slate-100"
|
||||
className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${
|
||||
drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : ''
|
||||
}`}
|
||||
onClick={() => interactive && onSelect?.('')}
|
||||
onDragOver={(event) => interactive && event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
endDesignerDrag()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
|
|
@ -1843,6 +2091,14 @@ const VisualCanvas = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{drag && interactive && (
|
||||
<div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
|
||||
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
|
||||
<b>{drag.label || drag.name}</b> sürükleniyor · kesikli çerçeveli alanlar içine alır,
|
||||
yeşil çizgi araya yerleştirir
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nodes.length ? (
|
||||
<div className="space-y-4">
|
||||
{nodes.map((node, index) => (
|
||||
|
|
@ -1867,9 +2123,32 @@ const VisualCanvas = ({
|
|||
onNodePropChange={onNodePropChange}
|
||||
/>
|
||||
))}
|
||||
{/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
|
||||
konteynerin dışına çıkmanın görünür bir yolu yok. */}
|
||||
{drag && interactive && (
|
||||
<div
|
||||
className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
|
||||
rootDropZone.over
|
||||
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
|
||||
: 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
|
||||
}`}
|
||||
{...rootDropZone.props}
|
||||
>
|
||||
Sayfanın en altına bırak
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-xl border-slate-300 bg-white/70 text-center dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div
|
||||
className={`flex min-h-[520px] items-center justify-center rounded-xl border bg-white/70 text-center transition dark:bg-slate-900/70 ${
|
||||
rootDropZone.over
|
||||
? 'border-2 border-dashed border-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40'
|
||||
: drag && interactive
|
||||
? 'border-2 border-dashed border-sky-400'
|
||||
: 'border-slate-300 dark:border-slate-700'
|
||||
}`}
|
||||
{...(interactive ? rootDropZone.props : {})}
|
||||
>
|
||||
<div className="w-full max-w-2xl px-6">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
|
||||
+
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
|
|||
import generatedComponentProps from './generated/componentProps.json'
|
||||
import {
|
||||
DESIGNER_DATA_COMPONENT_NAMES,
|
||||
SQL_DATA_SOURCE_EVENTS,
|
||||
SQL_DATA_SOURCE_TYPE,
|
||||
getDesignerCollectionProperty,
|
||||
isDesignerOptionComponent,
|
||||
|
|
@ -133,6 +134,41 @@ const selectedValue = event?.value ?? null
|
|||
const selectedLabel = event?.label ?? ''
|
||||
console.log({ selectedValue, selectedLabel })`,
|
||||
},
|
||||
[SQL_DATA_SOURCE_TYPE]: {
|
||||
onLoad: `// Select cevabı forma yerleştikten sonra çalışır.
|
||||
// event: { rows, count, record, index }
|
||||
if (!event.count) notify('Kayıt bulunamadı', 'warning')`,
|
||||
onRecordChange: `// Aktif kayıt her değiştiğinde çalışır.
|
||||
// event: { record, index, rows, count }
|
||||
console.log('Aktif kayıt:', event.record)`,
|
||||
onFieldChange: `// Kayda değer yazıldığında çalışır.
|
||||
// event: { field, value, previous, record }
|
||||
if (event.field === 'Quantity' || event.field === 'UnitPrice') {
|
||||
const total = Number(event.record.Quantity || 0) * Number(event.record.UnitPrice || 0)
|
||||
refs.{{sql}}.setField('TotalAmount', Math.round(total * 100) / 100)
|
||||
}`,
|
||||
onNewRecord: `// Yeni kayıt moduna geçildiğinde varsayılanları yaz.
|
||||
refs.{{sql}}.setField('Status', 'Taslak')`,
|
||||
onModeChange: `// event: { mode, record }
|
||||
console.log('Mod:', event.mode)`,
|
||||
onBeforeSave: `// false döndürülürse kayıt iptal edilir.
|
||||
// event: { record, original, payload, mode, isNew }
|
||||
if (!event.record.Name) {
|
||||
notify('Ad alanı zorunludur', 'warning')
|
||||
return false
|
||||
}`,
|
||||
onAfterSave: `// event: { record, response, mode, isNew }
|
||||
notify(event.isNew ? 'Kayıt eklendi' : 'Kayıt güncellendi', 'success')`,
|
||||
onBeforeDelete: `// false döndürülürse silme iptal edilir.
|
||||
// event: { record, key }
|
||||
if (event.record.Status === 'Onaylı') {
|
||||
notify('Onaylı kayıt silinemez', 'danger')
|
||||
return false
|
||||
}`,
|
||||
onAfterDelete: `notify('Kayıt silindi', 'success')`,
|
||||
onError: `// event: { message, action, error, record }
|
||||
notify(event.message, 'danger')`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -272,6 +308,23 @@ try {
|
|||
params: { skipCount: 0 },
|
||||
})
|
||||
console.log(result)`,
|
||||
},
|
||||
{
|
||||
group: 'Komponent erişimi',
|
||||
title: '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',
|
||||
|
|
@ -787,6 +840,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
|||
category: 'styling',
|
||||
},
|
||||
{ name: 'className', type: 'string', value: '', category: 'styling' },
|
||||
// Record lifecycle. `category: 'events'` keeps them out of the Properties
|
||||
// panel and moves them into the JavaScript tab, where they are edited as
|
||||
// scripts rather than as values.
|
||||
...SQL_DATA_SOURCE_EVENTS.map((event) => ({
|
||||
name: event.name,
|
||||
type: 'function' as const,
|
||||
value: '',
|
||||
category: 'events' as const,
|
||||
description: event.cancelable ? `${event.description} (false → iptal)` : event.description,
|
||||
})),
|
||||
],
|
||||
hooks: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import {
|
||||
getDesignerBindingColumns,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerTabSlotValue,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceKeyField,
|
||||
getSqlDataSourceKeyParam,
|
||||
getSqlDataSourceKeySource,
|
||||
getSqlDataSourceScriptedEvents,
|
||||
hasSqlDataSourceUrlParams,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateOnlyComponent,
|
||||
|
|
@ -316,10 +318,47 @@ const sqlIdentifiers = (node: DesignerNode) => {
|
|||
remove: `sqlDelete_${identifier}`,
|
||||
previous: `sqlPrev_${identifier}`,
|
||||
next: `sqlNext_${identifier}`,
|
||||
// Mirrors of the record/mode state, so an event handler declared further down
|
||||
// the component body reads the current value instead of a closed over one.
|
||||
recordRef: `sqlRecordRef_${identifier}`,
|
||||
modeRef: `sqlModeRef_${identifier}`,
|
||||
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */
|
||||
const sqlEventHandler = (node: DesignerNode, eventName: string) =>
|
||||
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : ''
|
||||
|
||||
const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0
|
||||
|
||||
/**
|
||||
* `await`ed call of an event script, emitted only when the event carries one.
|
||||
* `payload` is the object literal the script sees as `event`.
|
||||
*/
|
||||
const sqlEventCall = (node: DesignerNode, eventName: string, payload: string) => {
|
||||
const handler = sqlEventHandler(node, eventName)
|
||||
return handler ? `await ${handler}(${payload})` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelable variant: the command stops when the script returns exactly `false`,
|
||||
* so a script that returns nothing (the common case) never blocks anything.
|
||||
*/
|
||||
const sqlCancelableEventCall = (node: DesignerNode, eventName: string, payload: string) => {
|
||||
const handler = sqlEventHandler(node, eventName)
|
||||
return handler ? `if ((await ${handler}(${payload})) === false) return` : ''
|
||||
}
|
||||
|
||||
/** Same, but inside a `try` that already owns the busy flag — needs a reset. */
|
||||
const sqlErrorEventCall = (node: DesignerNode, action: string) => {
|
||||
const handler = sqlEventHandler(node, 'onError')
|
||||
const names = sqlIdentifiers(node)
|
||||
return handler
|
||||
? `\n await ${handler}({ message: toSqlErrorMessage(error), action: ${JSON.stringify(action)}, error, record: ${names.recordRef}.current })`
|
||||
: ''
|
||||
}
|
||||
|
||||
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
|
||||
const sqlSlotLiteral = (
|
||||
node: DesignerNode,
|
||||
|
|
@ -364,7 +403,16 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
|||
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
||||
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
||||
const [${names.busy}, ${names.setBusy}] = React.useState(false)
|
||||
const [${names.error}, ${names.setError}] = React.useState("")
|
||||
const [${names.error}, ${names.setError}] = React.useState("")${
|
||||
hasSqlEvents(node)
|
||||
? `
|
||||
// Event scripts are declared after these hooks, so they read the record and the
|
||||
// mode through refs rather than through the closure they were created in.
|
||||
const ${names.recordRef} = React.useRef(${names.record})
|
||||
${names.recordRef}.current = ${names.record}
|
||||
const ${names.modeRef} = React.useRef(${names.mode})`
|
||||
: ''
|
||||
}
|
||||
// The Select key can come from the page URL, which is how a detail page reads
|
||||
// /api/app/orders/{id} or /api/app/orders?id=… for a single record.
|
||||
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
|
||||
|
|
@ -385,19 +433,54 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
|||
// unsaved draft is not overwritten by a re-render.
|
||||
React.useEffect(() => {
|
||||
${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0))
|
||||
}, [${names.rows}])
|
||||
}, [${names.rows}])${
|
||||
sqlEventHandler(node, 'onLoad')
|
||||
? `
|
||||
// Fires once the Select result has settled, whether it carries rows or not —
|
||||
// an empty result is exactly the case a script wants to react to.
|
||||
React.useEffect(() => {
|
||||
if (${selectData} === null || ${selectData} === undefined) return
|
||||
void ${sqlEventHandler(node, 'onLoad')}({ rows: ${names.rows}, count: ${names.rows}.length, record: ${names.rows}[${names.index}] ?? {}, index: ${names.index} })
|
||||
}, [${names.rows}])`
|
||||
: ''
|
||||
}
|
||||
React.useEffect(() => {
|
||||
const row = ${names.rows}[${names.index}]
|
||||
if (!row) return
|
||||
${names.setRecord}(row)
|
||||
${names.setOriginal}(row)
|
||||
${names.setMode}("edit")
|
||||
}, [${names.rows}, ${names.index}])
|
||||
${names.setMode}("edit")${
|
||||
sqlEventHandler(node, 'onRecordChange')
|
||||
? `
|
||||
void ${sqlEventHandler(node, 'onRecordChange')}({ record: row, index: ${names.index}, rows: ${names.rows}, count: ${names.rows}.length })`
|
||||
: ''
|
||||
}
|
||||
}, [${names.rows}, ${names.index}])${
|
||||
sqlEventHandler(node, 'onModeChange')
|
||||
? `
|
||||
// The ref starts at the initial mode, so the first render is not reported as a
|
||||
// change; only real new/edit transitions reach the script.
|
||||
React.useEffect(() => {
|
||||
if (${names.modeRef}.current === ${names.mode}) return
|
||||
${names.modeRef}.current = ${names.mode}
|
||||
void ${sqlEventHandler(node, 'onModeChange')}({ mode: ${names.mode}, record: ${names.recordRef}.current })
|
||||
}, [${names.mode}])`
|
||||
: ''
|
||||
}
|
||||
const ${names.hasKey} = (() => {
|
||||
const value = readSqlField(${names.record}, ${names.key})
|
||||
return value !== undefined && value !== null && value !== ""
|
||||
})()
|
||||
const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), [])
|
||||
const ${names.setField} = React.useCallback(${
|
||||
sqlEventHandler(node, 'onFieldChange')
|
||||
? `(path, value) => {
|
||||
${names.setRecord}((current) => setSqlField(current, path, value))
|
||||
// The script is handed the record with the write already applied, so a
|
||||
// calculation can read the sibling columns without re-deriving the change.
|
||||
void ${sqlEventHandler(node, 'onFieldChange')}({ field: path, value, previous: getByPath(${names.recordRef}.current, path), record: setSqlField(${names.recordRef}.current, path, value) })
|
||||
}`
|
||||
: `(path, value) => ${names.setRecord}((current) => setSqlField(current, path, value))`
|
||||
}, [])
|
||||
const ${names.reload} = React.useCallback(async () => {${
|
||||
setSelectData
|
||||
? `
|
||||
|
|
@ -420,16 +503,21 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
|||
try {
|
||||
await ${names.reload}()
|
||||
} catch (error) {
|
||||
${names.setError}(toSqlErrorMessage(error))
|
||||
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'reload')}
|
||||
} finally {
|
||||
${names.setBusy}(false)
|
||||
}
|
||||
}, [${names.reload}])
|
||||
const ${names.create} = React.useCallback(() => {
|
||||
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
||||
${names.setError}("")
|
||||
${names.setRecord}({})
|
||||
${names.setOriginal}({})
|
||||
${names.setMode}("new")
|
||||
${names.setMode}("new")${
|
||||
sqlEventHandler(node, 'onNewRecord')
|
||||
? `
|
||||
${sqlEventCall(node, 'onNewRecord', '{ record: {} }')}`
|
||||
: ''
|
||||
}
|
||||
}, [])
|
||||
const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), [])
|
||||
const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}])
|
||||
|
|
@ -445,6 +533,13 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
|||
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
|
||||
${names.setError}("Kaydedilecek bir değişiklik yok.")
|
||||
return
|
||||
}${
|
||||
sqlEventHandler(node, 'onBeforeSave')
|
||||
? `
|
||||
// Runs before the busy flag is raised: a vetoed save must leave the form in
|
||||
// exactly the state the user left it in.
|
||||
${sqlCancelableEventCall(node, 'onBeforeSave', `{ record: ${names.record}, original: ${names.original}, payload, mode: ${names.mode}, isNew }`)}`
|
||||
: ''
|
||||
}
|
||||
${names.setBusy}(true)
|
||||
${names.setError}("")
|
||||
|
|
@ -461,28 +556,50 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
|||
${names.setOriginal}(saved)
|
||||
}
|
||||
}
|
||||
${names.setMode}("edit")
|
||||
${names.setMode}("edit")${
|
||||
sqlEventHandler(node, 'onAfterSave')
|
||||
? `
|
||||
${sqlEventCall(node, 'onAfterSave', `{ record: ${names.record}, response: response?.data, mode: ${names.mode}, isNew }`)}`
|
||||
: ''
|
||||
}
|
||||
} catch (error) {
|
||||
${names.setError}(toSqlErrorMessage(error))
|
||||
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'save')}
|
||||
} finally {
|
||||
${names.setBusy}(false)
|
||||
}
|
||||
}
|
||||
const ${names.remove} = async () => {
|
||||
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return
|
||||
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
|
||||
sqlEventHandler(node, 'onBeforeDelete')
|
||||
? `
|
||||
// Asked before the confirm dialog, so a script can block the delete without
|
||||
// the user ever being prompted.
|
||||
${sqlCancelableEventCall(node, 'onBeforeDelete', `{ record: ${names.record}, key: readSqlField(${names.record}, ${names.key}) }`)}`
|
||||
: ''
|
||||
}
|
||||
// Deleting cannot be undone from the form, so it always asks first.
|
||||
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
|
||||
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
|
||||
${names.setBusy}(true)
|
||||
${names.setError}("")
|
||||
try {
|
||||
try {${
|
||||
sqlEventHandler(node, 'onAfterDelete')
|
||||
? `
|
||||
const deleted = ${names.record}`
|
||||
: ''
|
||||
}
|
||||
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
|
||||
${names.setRecord}({})
|
||||
${names.setOriginal}({})
|
||||
${names.setMode}("new")
|
||||
await ${names.reload}()
|
||||
await ${names.reload}()${
|
||||
sqlEventHandler(node, 'onAfterDelete')
|
||||
? `
|
||||
${sqlEventCall(node, 'onAfterDelete', '{ record: deleted }')}`
|
||||
: ''
|
||||
}
|
||||
} catch (error) {
|
||||
${names.setError}(toSqlErrorMessage(error))
|
||||
${names.setError}(toSqlErrorMessage(error))${sqlErrorEventCall(node, 'delete')}
|
||||
} finally {
|
||||
${names.setBusy}(false)
|
||||
}
|
||||
|
|
@ -940,12 +1057,31 @@ const refTargetFor = (
|
|||
const names = sqlIdentifiers(node)
|
||||
extras.push(
|
||||
` getRecord: () => ${names.record}`,
|
||||
` setRecord: (record) => ${names.setRecord}(record || {})`,
|
||||
` getField: (path) => getByPath(${names.record}, path)`,
|
||||
` setField: (path, value) => ${names.setField}(path, value)`,
|
||||
// A patch is the readable way to write several columns at once; each write
|
||||
// still goes through setField so onFieldChange fires for every column.
|
||||
` setFields: (patch) => Object.entries(patch || {}).forEach(([path, value]) => ${names.setField}(path, value))`,
|
||||
` clearFields: (...paths) => paths.flat().forEach((path) => ${names.setField}(path, null))`,
|
||||
` getOriginal: () => ${names.original}`,
|
||||
` getChanges: () => toSqlChanges(${names.original}, ${names.record}, ${names.key})`,
|
||||
` hasChanges: () => Object.keys(toSqlChanges(${names.original}, ${names.record}, ${names.key})).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length > 0`,
|
||||
` getKey: () => readSqlField(${names.record}, ${names.key})`,
|
||||
` getKeyField: () => ${names.key}`,
|
||||
` getRows: () => ${names.rows}`,
|
||||
` getRowCount: () => ${names.rows}.length`,
|
||||
` getIndex: () => ${names.index}`,
|
||||
` getMode: () => ${names.mode}`,
|
||||
` isNew: () => ${names.mode} === "new"`,
|
||||
` isBusy: () => ${names.busy}`,
|
||||
` getError: () => ${names.error}`,
|
||||
` setError: (message) => ${names.setError}(message == null ? "" : String(message))`,
|
||||
` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`,
|
||||
` nextRow: () => ${names.next}()`,
|
||||
` prevRow: () => ${names.previous}()`,
|
||||
` firstRow: () => ${names.setIndex}(0)`,
|
||||
` lastRow: () => ${names.setIndex}(Math.max(0, ${names.rows}.length - 1))`,
|
||||
` reload: () => ${names.refresh}()`,
|
||||
` newRecord: () => ${names.create}()`,
|
||||
` save: () => ${names.save}()`,
|
||||
|
|
@ -958,30 +1094,42 @@ const refTargetFor = (
|
|||
extras,
|
||||
}
|
||||
}
|
||||
if (formField) {
|
||||
return {
|
||||
node,
|
||||
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
|
||||
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
|
||||
extras,
|
||||
}
|
||||
}
|
||||
if (runtimeState) {
|
||||
return {
|
||||
node,
|
||||
getExpression: runtimeState.stateName,
|
||||
setStatement: `${runtimeState.setterName}(value)`,
|
||||
extras,
|
||||
}
|
||||
}
|
||||
const valueProperty =
|
||||
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
|
||||
return {
|
||||
node,
|
||||
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
|
||||
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
|
||||
extras,
|
||||
const base = formField
|
||||
? {
|
||||
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
|
||||
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
|
||||
}
|
||||
: runtimeState
|
||||
? {
|
||||
getExpression: runtimeState.stateName,
|
||||
setStatement: `${runtimeState.setterName}(value)`,
|
||||
}
|
||||
: {
|
||||
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
|
||||
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
|
||||
}
|
||||
|
||||
// Select/AutoComplete/Menu ekranda yalnızca Label gösterir, ama seçenek nesnesi
|
||||
// kaynak satırın bütün sütunlarını taşır. Bu erişimciler sayesinde bir script
|
||||
// görünmeyen sütunları okuyup başka bir komponente ya da kayda yazabilir.
|
||||
if (isDesignerOptionComponent(node.type)) {
|
||||
const optionsExpression = optionCollectionExpression(node)
|
||||
const selected = `findDesignerOption(${optionsExpression}, ${base.getExpression})`
|
||||
const columns = getDesignerBindingColumns(node.bindings?.[getDesignerCollectionProperty(node.type)])
|
||||
extras.push(
|
||||
` getOptions: () => ${optionsExpression}`,
|
||||
` getSelectedOption: () => ${selected}`,
|
||||
` getColumn: (path) => readDesignerOptionColumn(${selected}, path)`,
|
||||
` getLabel: () => readDesignerOptionColumn(${selected}, "label") ?? ""`,
|
||||
// Tasarımda bildirilen ek sütunlar; bir script hangi sütunların garanti
|
||||
// altında olduğunu buradan öğrenebilir.
|
||||
` getColumnNames: () => ${JSON.stringify(columns)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { node, ...base, extras }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1018,7 +1166,26 @@ const refRuntimeCode = (targets: RefTarget[]) => {
|
|||
})
|
||||
.join(',\n')
|
||||
|
||||
return ` // Component references. Every node on the canvas is addressable by its ref,
|
||||
// Only emitted when an option component is actually addressable by ref.
|
||||
const optionHelpers = targets.some((target) => isDesignerOptionComponent(target.node.type))
|
||||
? ` // Seçili option nesnesi: bir Select değeri kimi zaman option'ın kendisi, kimi
|
||||
// zaman kayda yazılan ham value olur; ikisi de aynı nesneye çözülür.
|
||||
const findDesignerOption = (options, value) => {
|
||||
const list = Array.isArray(options) ? options : []
|
||||
if (Array.isArray(value)) return value.map((item) => findDesignerOption(list, item)).filter(Boolean)
|
||||
if (value && typeof value === "object") return value
|
||||
return list.find((option) => option?.value === value)
|
||||
?? list.find((option) => String(option?.value ?? "") === String(value ?? ""))
|
||||
?? null
|
||||
}
|
||||
const readDesignerOptionColumn = (option, path) => {
|
||||
if (Array.isArray(option)) return option.map((item) => readDesignerOptionColumn(item, path))
|
||||
return String(path || "").split(".").filter(Boolean).reduce((current, key) => current?.[key], option)
|
||||
}
|
||||
`
|
||||
: ''
|
||||
|
||||
return `${optionHelpers} // Component references. Every node on the canvas is addressable by its ref,
|
||||
// so an event script can read or change another component without prop drilling.
|
||||
const [designerRefState, setDesignerRefState] = React.useState({})
|
||||
const designerRefProps = (name) => designerRefState[name]?.props || {}
|
||||
|
|
@ -1110,6 +1277,19 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
|
||||
)
|
||||
}
|
||||
// A SqlDataSource event is not a React prop: it is called by the container's
|
||||
// own runtime with a single payload object, and always awaited so a script
|
||||
// can veto a command or finish an API call before the save continues.
|
||||
if (isSqlDataSourceNode(node.type)) {
|
||||
getSqlDataSourceScriptedEvents(node).forEach((event) => {
|
||||
const script = node.events[event.name].trim()
|
||||
handlers.push(
|
||||
` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`,
|
||||
)
|
||||
})
|
||||
visit(node.children, { sourceId: node.id, setterName: sqlIdentifiers(node).setField })
|
||||
return
|
||||
}
|
||||
const eventNames = new Set(
|
||||
Object.entries(node.events)
|
||||
.filter(([, script]) => script.trim())
|
||||
|
|
|
|||
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
|
||||
labelPath?: string
|
||||
valuePath?: string
|
||||
/**
|
||||
* Label ve Value dışında, seçilen kaydın script ile okunabilmesi istenen
|
||||
* sütunlar. Ekranda hiçbiri görünmez; `refs.<ad>.getColumn('sutun')` ile
|
||||
* okunup başka bir komponentin değerine ya da SqlDataSource kaydına yazılır.
|
||||
*/
|
||||
columns?: string[]
|
||||
}
|
||||
|
||||
/** Bir option bağlantısının bildirilmiş ek sütunları. */
|
||||
export const getDesignerBindingColumns = (binding?: DesignerBinding): string[] =>
|
||||
Array.isArray(binding?.columns)
|
||||
? binding.columns.filter((column): column is string => typeof column === 'string' && !!column)
|
||||
: []
|
||||
|
||||
/**
|
||||
* Data sources are no longer read-only: an endpoint can also be attached as the
|
||||
* insert/update/delete command of a SqlDataSource, mirroring ASP.NET's
|
||||
|
|
@ -457,6 +469,136 @@ export const SQL_DATA_SOURCE_SLOTS: readonly SqlDataSourceSlot[] = [
|
|||
export const getSqlDataSourceSlot = (property: string) =>
|
||||
SQL_DATA_SOURCE_SLOTS.find((slot) => slot.property === property)
|
||||
|
||||
/**
|
||||
* Lifecycle of the record the container owns. Every entry is emitted by the code
|
||||
* generator as a handler that receives a single `event` object, so a script never
|
||||
* has to know which piece of React state actually fired it.
|
||||
*
|
||||
* A `cancelable` event stops the command when the script returns `false` — that
|
||||
* is the only way a Save or a Delete can be vetoed from configuration.
|
||||
*/
|
||||
export interface SqlDataSourceEvent {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
cancelable?: boolean
|
||||
/** Fields of the `event` object; drives the builder's condition/param pickers. */
|
||||
payload: { key: string; description: string }[]
|
||||
}
|
||||
|
||||
export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [
|
||||
{
|
||||
name: 'onLoad',
|
||||
label: 'Select tamamlandı',
|
||||
description: 'Select endpointi cevap verdiğinde, satırlar forma yerleştikten sonra çalışır.',
|
||||
payload: [
|
||||
{ key: 'rows', description: 'Select sonucundaki tüm satırlar' },
|
||||
{ key: 'count', description: 'Satır sayısı' },
|
||||
{ key: 'record', description: 'Aktif satır' },
|
||||
{ key: 'index', description: 'Aktif satırın sırası' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onRecordChange',
|
||||
label: 'Aktif kayıt değişti',
|
||||
description: 'Satırlar arasında gezinildiğinde veya yeni kayıt yüklendiğinde çalışır.',
|
||||
payload: [
|
||||
{ key: 'record', description: 'Yeni aktif kayıt' },
|
||||
{ key: 'index', description: 'Satır sırası' },
|
||||
{ key: 'rows', description: 'Tüm satırlar' },
|
||||
{ key: 'count', description: 'Satır sayısı' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onFieldChange',
|
||||
label: 'Alan değişti',
|
||||
description: 'İçerideki bir komponent kayda değer yazdığında çalışır; hesaplama için idealdir.',
|
||||
payload: [
|
||||
{ key: 'field', description: 'Değişen sütun adı' },
|
||||
{ key: 'value', description: 'Yeni değer' },
|
||||
{ key: 'previous', description: 'Önceki değer' },
|
||||
{ key: 'record', description: 'Değişiklik uygulanmış kayıt' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onNewRecord',
|
||||
label: 'Yeni kayıt',
|
||||
description: 'Yeni butonuyla boş kayda geçildiğinde çalışır; varsayılan değer yazmak için.',
|
||||
payload: [{ key: 'record', description: 'Boş kayıt' }],
|
||||
},
|
||||
{
|
||||
name: 'onModeChange',
|
||||
label: 'Mod değişti',
|
||||
description: 'Kayıt new/edit modları arasında geçtiğinde çalışır.',
|
||||
payload: [
|
||||
{ key: 'mode', description: '"new" veya "edit"' },
|
||||
{ key: 'record', description: 'Güncel kayıt' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onBeforeSave',
|
||||
label: 'Kaydetmeden önce',
|
||||
description: 'Save çağrılmadan önce çalışır. Script false döndürürse kayıt iptal edilir.',
|
||||
cancelable: true,
|
||||
payload: [
|
||||
{ key: 'record', description: 'Kaydedilecek kayıt' },
|
||||
{ key: 'original', description: 'Yüklendiği andaki hali' },
|
||||
{ key: 'payload', description: 'Endpointe gidecek gövde (Update ise sadece değişenler)' },
|
||||
{ key: 'mode', description: '"new" veya "edit"' },
|
||||
{ key: 'isNew', description: 'Insert mi çalışacak?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onAfterSave',
|
||||
label: 'Kaydettikten sonra',
|
||||
description: 'Insert/Update başarıyla tamamlandığında çalışır.',
|
||||
payload: [
|
||||
{ key: 'record', description: 'Gönderilen kayıt' },
|
||||
{ key: 'response', description: 'Endpoint cevabı' },
|
||||
{ key: 'mode', description: 'Kaydetme anındaki mod' },
|
||||
{ key: 'isNew', description: 'Insert mi çalıştı?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onBeforeDelete',
|
||||
label: 'Silmeden önce',
|
||||
description: 'Onay kutusundan önce çalışır. Script false döndürürse silme iptal edilir.',
|
||||
cancelable: true,
|
||||
payload: [
|
||||
{ key: 'record', description: 'Silinecek kayıt' },
|
||||
{ key: 'key', description: 'Key sütunundaki değer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'onAfterDelete',
|
||||
label: 'Sildikten sonra',
|
||||
description: 'Delete endpointi başarıyla tamamlandığında çalışır.',
|
||||
payload: [{ key: 'record', description: 'Silinen kayıt' }],
|
||||
},
|
||||
{
|
||||
name: 'onError',
|
||||
label: 'Hata oluştu',
|
||||
description: 'Select/Insert/Update/Delete sırasında hata alındığında çalışır.',
|
||||
payload: [
|
||||
{ key: 'message', description: 'Kullanıcıya gösterilen hata metni' },
|
||||
{ key: 'action', description: '"reload" | "save" | "delete"' },
|
||||
{ key: 'error', description: 'Ham hata nesnesi' },
|
||||
{ key: 'record', description: 'Hata anındaki kayıt' },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export const SQL_DATA_SOURCE_EVENT_NAMES: readonly string[] = SQL_DATA_SOURCE_EVENTS.map(
|
||||
(event) => event.name,
|
||||
)
|
||||
|
||||
export const getSqlDataSourceEvent = (name: string) =>
|
||||
SQL_DATA_SOURCE_EVENTS.find((event) => event.name === name)
|
||||
|
||||
/** Scripted events of a SqlDataSource node, in lifecycle order. */
|
||||
export const getSqlDataSourceScriptedEvents = (node: DesignerNode) =>
|
||||
SQL_DATA_SOURCE_EVENTS.filter((event) => Boolean(node.events?.[event.name]?.trim()))
|
||||
|
||||
export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) =>
|
||||
String(node.props?.[property] ?? '').trim()
|
||||
|
||||
|
|
|
|||
|
|
@ -91,159 +91,159 @@ export const ROUTES_ENUM = {
|
|||
|
||||
accessDenied: '/admin/access-denied',
|
||||
|
||||
coordinator: {
|
||||
videoroom: {
|
||||
dashboard: '/admin/coordinator/videoroom/dashboard',
|
||||
roomList: '/admin/coordinator/videoroom/rooms',
|
||||
roomDetail: '/admin/coordinator/videoroom/room/:id',
|
||||
planning: '/admin/coordinator/videoroom/planning/:id',
|
||||
},
|
||||
exams: '/admin/coordinator/exams',
|
||||
examDetail: '/admin/coordinator/exam/:id',
|
||||
assignments: '/admin/coordinator/assignments',
|
||||
assignmentDetail: '/admin/coordinator/assignment/:id',
|
||||
tests: '/admin/coordinator/tests',
|
||||
testDetail: '/admin/coordinator/test/:id',
|
||||
},
|
||||
// coordinator: {
|
||||
// videoroom: {
|
||||
// dashboard: '/admin/coordinator/videoroom/dashboard',
|
||||
// roomList: '/admin/coordinator/videoroom/rooms',
|
||||
// roomDetail: '/admin/coordinator/videoroom/room/:id',
|
||||
// planning: '/admin/coordinator/videoroom/planning/:id',
|
||||
// },
|
||||
// exams: '/admin/coordinator/exams',
|
||||
// examDetail: '/admin/coordinator/exam/:id',
|
||||
// assignments: '/admin/coordinator/assignments',
|
||||
// assignmentDetail: '/admin/coordinator/assignment/:id',
|
||||
// tests: '/admin/coordinator/tests',
|
||||
// testDetail: '/admin/coordinator/test/:id',
|
||||
// },
|
||||
|
||||
supplychain: {
|
||||
materialTypes: '/admin/supplychain/materials/types',
|
||||
materialGroups: '/admin/supplychain/materials/groups',
|
||||
materials: '/admin/supplychain/materials',
|
||||
materialsNew: '/admin/supplychain/materials/new',
|
||||
materialsEdit: '/admin/supplychain/materials/edit/:id',
|
||||
materialsDetail: '/admin/supplychain/materials/detail/:id',
|
||||
suppliers: '/admin/supplychain/suppliers',
|
||||
suppliersNew: '/admin/supplychain/suppliers/new',
|
||||
suppliersEdit: '/admin/supplychain/suppliers/edit/:id',
|
||||
suppliersDetail: '/admin/supplychain/suppliers/:id',
|
||||
requests: '/admin/supplychain/requests',
|
||||
requestsNew: '/admin/supplychain/requests/new',
|
||||
requestsEdit: '/admin/supplychain/requests/edit/:id',
|
||||
requestsView: '/admin/supplychain/requests/view/:id',
|
||||
requisitions: '/admin/supplychain/requisitions',
|
||||
requisitionsNew: '/admin/supplychain/requisitions/new',
|
||||
requisitionsEdit: '/admin/supplychain/requisitions/edit/:id',
|
||||
requisitionsDetail: '/admin/supplychain/requisitions/:id',
|
||||
quotations: '/admin/supplychain/quotations',
|
||||
quotationsNew: '/admin/supplychain/quotations/new',
|
||||
quotationsEdit: '/admin/supplychain/quotations/edit/:id',
|
||||
quotationsView: '/admin/supplychain/quotations/view/:id',
|
||||
approvals: '/admin/supplychain/approvals',
|
||||
orders: '/admin/supplychain/orders',
|
||||
ordersNew: '/admin/supplychain/orders/new',
|
||||
ordersEdit: '/admin/supplychain/orders/edit/:id',
|
||||
ordersView: '/admin/supplychain/orders/view/:id',
|
||||
delivery: '/admin/supplychain/delivery',
|
||||
},
|
||||
// supplychain: {
|
||||
// materialTypes: '/admin/supplychain/materials/types',
|
||||
// materialGroups: '/admin/supplychain/materials/groups',
|
||||
// materials: '/admin/supplychain/materials',
|
||||
// materialsNew: '/admin/supplychain/materials/new',
|
||||
// materialsEdit: '/admin/supplychain/materials/edit/:id',
|
||||
// materialsDetail: '/admin/supplychain/materials/detail/:id',
|
||||
// suppliers: '/admin/supplychain/suppliers',
|
||||
// suppliersNew: '/admin/supplychain/suppliers/new',
|
||||
// suppliersEdit: '/admin/supplychain/suppliers/edit/:id',
|
||||
// suppliersDetail: '/admin/supplychain/suppliers/:id',
|
||||
// requests: '/admin/supplychain/requests',
|
||||
// requestsNew: '/admin/supplychain/requests/new',
|
||||
// requestsEdit: '/admin/supplychain/requests/edit/:id',
|
||||
// requestsView: '/admin/supplychain/requests/view/:id',
|
||||
// requisitions: '/admin/supplychain/requisitions',
|
||||
// requisitionsNew: '/admin/supplychain/requisitions/new',
|
||||
// requisitionsEdit: '/admin/supplychain/requisitions/edit/:id',
|
||||
// requisitionsDetail: '/admin/supplychain/requisitions/:id',
|
||||
// quotations: '/admin/supplychain/quotations',
|
||||
// quotationsNew: '/admin/supplychain/quotations/new',
|
||||
// quotationsEdit: '/admin/supplychain/quotations/edit/:id',
|
||||
// quotationsView: '/admin/supplychain/quotations/view/:id',
|
||||
// approvals: '/admin/supplychain/approvals',
|
||||
// orders: '/admin/supplychain/orders',
|
||||
// ordersNew: '/admin/supplychain/orders/new',
|
||||
// ordersEdit: '/admin/supplychain/orders/edit/:id',
|
||||
// ordersView: '/admin/supplychain/orders/view/:id',
|
||||
// delivery: '/admin/supplychain/delivery',
|
||||
// },
|
||||
|
||||
maintenance: {
|
||||
equipment: '/admin/maintenance/equipment',
|
||||
equipmentNew: '/admin/maintenance/equipment/new',
|
||||
equipmentEdit: '/admin/maintenance/equipment/edit/:id',
|
||||
equipmentDetail: '/admin/maintenance/equipment/:id',
|
||||
workcenters: '/admin/maintenance/workcenters',
|
||||
plans: '/admin/maintenance/plans',
|
||||
calendar: '/admin/maintenance/calendar',
|
||||
teams: '/admin/maintenance/teams',
|
||||
faults: '/admin/maintenance/faults',
|
||||
workorders: '/admin/maintenance/workorders',
|
||||
workordersNew: '/admin/maintenance/workorders/new',
|
||||
workordersDetail: '/admin/maintenance/workorders/:id',
|
||||
},
|
||||
// maintenance: {
|
||||
// equipment: '/admin/maintenance/equipment',
|
||||
// equipmentNew: '/admin/maintenance/equipment/new',
|
||||
// equipmentEdit: '/admin/maintenance/equipment/edit/:id',
|
||||
// equipmentDetail: '/admin/maintenance/equipment/:id',
|
||||
// workcenters: '/admin/maintenance/workcenters',
|
||||
// plans: '/admin/maintenance/plans',
|
||||
// calendar: '/admin/maintenance/calendar',
|
||||
// teams: '/admin/maintenance/teams',
|
||||
// faults: '/admin/maintenance/faults',
|
||||
// workorders: '/admin/maintenance/workorders',
|
||||
// workordersNew: '/admin/maintenance/workorders/new',
|
||||
// workordersDetail: '/admin/maintenance/workorders/:id',
|
||||
// },
|
||||
|
||||
warehouse: {
|
||||
definitions: '/admin/warehouse/definitions',
|
||||
tracking: '/admin/warehouse/tracking',
|
||||
putaway: '/admin/warehouse/putaway',
|
||||
receipt: '/admin/warehouse/receipt',
|
||||
issue: '/admin/warehouse/issue',
|
||||
transfer: '/admin/warehouse/transfer',
|
||||
inventory: '/admin/warehouse/inventory',
|
||||
movements: '/admin/warehouse/movements',
|
||||
movementDetail: '/admin/warehouse/movements/:id',
|
||||
stocklevel: '/admin/warehouse/stocklevel',
|
||||
warehouses: '/admin/warehouse/warehouses',
|
||||
warehouseNew: '/admin/warehouse/new',
|
||||
warehouseEdit: '/admin/warehouse/edit/:id',
|
||||
warehouseDetail: '/admin/warehouse/warehouses/:id',
|
||||
},
|
||||
// warehouse: {
|
||||
// definitions: '/admin/warehouse/definitions',
|
||||
// tracking: '/admin/warehouse/tracking',
|
||||
// putaway: '/admin/warehouse/putaway',
|
||||
// receipt: '/admin/warehouse/receipt',
|
||||
// issue: '/admin/warehouse/issue',
|
||||
// transfer: '/admin/warehouse/transfer',
|
||||
// inventory: '/admin/warehouse/inventory',
|
||||
// movements: '/admin/warehouse/movements',
|
||||
// movementDetail: '/admin/warehouse/movements/:id',
|
||||
// stocklevel: '/admin/warehouse/stocklevel',
|
||||
// warehouses: '/admin/warehouse/warehouses',
|
||||
// warehouseNew: '/admin/warehouse/new',
|
||||
// warehouseEdit: '/admin/warehouse/edit/:id',
|
||||
// warehouseDetail: '/admin/warehouse/warehouses/:id',
|
||||
// },
|
||||
|
||||
projects: {
|
||||
list: '/admin/projects',
|
||||
new: '/admin/projects/new',
|
||||
edit: '/admin/projects/edit/:id',
|
||||
detail: '/admin/projects/:id',
|
||||
tasks: '/admin/projects/tasks',
|
||||
phases: '/admin/projects/phases',
|
||||
activities: '/admin/projects/activities',
|
||||
workload: '/admin/projects/workload',
|
||||
costTracking: '/admin/projects/cost-tracking',
|
||||
dailyUpdates: '/admin/projects/daily-updates',
|
||||
},
|
||||
// projects: {
|
||||
// list: '/admin/projects',
|
||||
// new: '/admin/projects/new',
|
||||
// edit: '/admin/projects/edit/:id',
|
||||
// detail: '/admin/projects/:id',
|
||||
// tasks: '/admin/projects/tasks',
|
||||
// phases: '/admin/projects/phases',
|
||||
// activities: '/admin/projects/activities',
|
||||
// workload: '/admin/projects/workload',
|
||||
// costTracking: '/admin/projects/cost-tracking',
|
||||
// dailyUpdates: '/admin/projects/daily-updates',
|
||||
// },
|
||||
|
||||
hr: {
|
||||
employees: '/admin/hr/employees',
|
||||
employeesNew: '/admin/hr/employees/new',
|
||||
employeesEdit: '/admin/hr/employees/edit/:id',
|
||||
employeesDetail: '/admin/hr/employees/:id',
|
||||
departments: '/admin/hr/departments',
|
||||
jobPositions: '/admin/hr/job-positions',
|
||||
employmentTypes: '/admin/hr/employment-types',
|
||||
organization: '/admin/hr/organization',
|
||||
badges: '/admin/hr/badges',
|
||||
leaveManagement: '/admin/hr/leave-management',
|
||||
overtimes: '/admin/hr/overtimes-management',
|
||||
payroll: '/admin/hr/payroll',
|
||||
costCenters: '/admin/hr/cost-centers',
|
||||
evaluationTemplates: '/admin/hr/360-templates',
|
||||
evaluation: '/admin/hr/360-evaluation',
|
||||
},
|
||||
// hr: {
|
||||
// employees: '/admin/hr/employees',
|
||||
// employeesNew: '/admin/hr/employees/new',
|
||||
// employeesEdit: '/admin/hr/employees/edit/:id',
|
||||
// employeesDetail: '/admin/hr/employees/:id',
|
||||
// departments: '/admin/hr/departments',
|
||||
// jobPositions: '/admin/hr/job-positions',
|
||||
// employmentTypes: '/admin/hr/employment-types',
|
||||
// organization: '/admin/hr/organization',
|
||||
// badges: '/admin/hr/badges',
|
||||
// leaveManagement: '/admin/hr/leave-management',
|
||||
// overtimes: '/admin/hr/overtimes-management',
|
||||
// payroll: '/admin/hr/payroll',
|
||||
// costCenters: '/admin/hr/cost-centers',
|
||||
// evaluationTemplates: '/admin/hr/360-templates',
|
||||
// evaluation: '/admin/hr/360-evaluation',
|
||||
// },
|
||||
|
||||
crm: {
|
||||
customers: '/admin/crm/customers',
|
||||
customersNew: '/admin/crm/customers/new',
|
||||
customersEdit: '/admin/crm/customers/edit/:id',
|
||||
customersDetail: '/admin/crm/customers/:id',
|
||||
salesTeams: '/admin/crm/sales-teams',
|
||||
salesTeamsNew: '/admin/crm/sales-teams/new',
|
||||
salesTeamsEdit: '/admin/crm/sales-teams/edit/:id',
|
||||
salesTeamsDetail: '/admin/crm/sales-teams/:id',
|
||||
lossReasons: '/admin/crm/loss-reasons',
|
||||
opportunities: '/admin/crm/opportunities',
|
||||
opportunitiesNew: '/admin/crm/opportunities/new',
|
||||
opportunitiesEdit: '/admin/crm/opportunities/edit/:id',
|
||||
opportunitiesDetail: '/admin/crm/opportunities/:id',
|
||||
activities: '/admin/crm/activities',
|
||||
salesOrders: '/admin/crm/sales-orders',
|
||||
salesOrdersNew: '/admin/crm/sales-orders/new',
|
||||
salesOrdersEdit: '/admin/crm/sales-orders/edit/:id',
|
||||
salesOrdersDetail: '/admin/crm/sales-orders/:id',
|
||||
},
|
||||
// crm: {
|
||||
// customers: '/admin/crm/customers',
|
||||
// customersNew: '/admin/crm/customers/new',
|
||||
// customersEdit: '/admin/crm/customers/edit/:id',
|
||||
// customersDetail: '/admin/crm/customers/:id',
|
||||
// salesTeams: '/admin/crm/sales-teams',
|
||||
// salesTeamsNew: '/admin/crm/sales-teams/new',
|
||||
// salesTeamsEdit: '/admin/crm/sales-teams/edit/:id',
|
||||
// salesTeamsDetail: '/admin/crm/sales-teams/:id',
|
||||
// lossReasons: '/admin/crm/loss-reasons',
|
||||
// opportunities: '/admin/crm/opportunities',
|
||||
// opportunitiesNew: '/admin/crm/opportunities/new',
|
||||
// opportunitiesEdit: '/admin/crm/opportunities/edit/:id',
|
||||
// opportunitiesDetail: '/admin/crm/opportunities/:id',
|
||||
// activities: '/admin/crm/activities',
|
||||
// salesOrders: '/admin/crm/sales-orders',
|
||||
// salesOrdersNew: '/admin/crm/sales-orders/new',
|
||||
// salesOrdersEdit: '/admin/crm/sales-orders/edit/:id',
|
||||
// salesOrdersDetail: '/admin/crm/sales-orders/:id',
|
||||
// },
|
||||
|
||||
mrp: {
|
||||
operationTypes: '/admin/mrp/operation-types',
|
||||
workcenters: '/admin/mrp/workcenters',
|
||||
operations: '/admin/mrp/operations',
|
||||
bom: '/admin/mrp/bom',
|
||||
productionOrders: '/admin/mrp/production-orders',
|
||||
productionOrderNew: '/admin/mrp/production-orders/new',
|
||||
productionOrderEdit: '/admin/mrp/production-orders/:id/edit',
|
||||
productionOrderDetail: '/admin/mrp/production-orders/:id',
|
||||
workOrders: '/admin/mrp/work-orders',
|
||||
demandPlanning: '/admin/mrp/demand-planning',
|
||||
materialRequirements: '/admin/mrp/material-requirements',
|
||||
planningGantt: '/admin/mrp/planning-gantt',
|
||||
},
|
||||
// mrp: {
|
||||
// operationTypes: '/admin/mrp/operation-types',
|
||||
// workcenters: '/admin/mrp/workcenters',
|
||||
// operations: '/admin/mrp/operations',
|
||||
// bom: '/admin/mrp/bom',
|
||||
// productionOrders: '/admin/mrp/production-orders',
|
||||
// productionOrderNew: '/admin/mrp/production-orders/new',
|
||||
// productionOrderEdit: '/admin/mrp/production-orders/:id/edit',
|
||||
// productionOrderDetail: '/admin/mrp/production-orders/:id',
|
||||
// workOrders: '/admin/mrp/work-orders',
|
||||
// demandPlanning: '/admin/mrp/demand-planning',
|
||||
// materialRequirements: '/admin/mrp/material-requirements',
|
||||
// planningGantt: '/admin/mrp/planning-gantt',
|
||||
// },
|
||||
|
||||
accounting: {
|
||||
currentAccounts: '/admin/accounting/current-accounts',
|
||||
waybills: '/admin/accounting/waybills',
|
||||
invoices: '/admin/accounting/invoices',
|
||||
invoicesNew: '/admin/accounting/invoices/new',
|
||||
cash: '/admin/accounting/cash',
|
||||
bank: '/admin/accounting/bank',
|
||||
checkNote: '/admin/accounting/check-note',
|
||||
},
|
||||
// accounting: {
|
||||
// currentAccounts: '/admin/accounting/current-accounts',
|
||||
// waybills: '/admin/accounting/waybills',
|
||||
// invoices: '/admin/accounting/invoices',
|
||||
// invoicesNew: '/admin/accounting/invoices/new',
|
||||
// cash: '/admin/accounting/cash',
|
||||
// bank: '/admin/accounting/bank',
|
||||
// checkNote: '/admin/accounting/check-note',
|
||||
// },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ function EditorOptionsBuilderDialog({
|
|||
`height` yok sayılıyor. `!flex-none` ile flex boyutlandırmasını kapatıp
|
||||
yüksekliği doğrudan veriyoruz.
|
||||
*/}
|
||||
<Dialog.Body className="flex h-[calc(100vh-16rem)] max-h-[calc(90vh-7rem)] min-h-[320px] !flex-none flex-col gap-3 !overflow-hidden">
|
||||
<Dialog.Body className="flex h-[calc(100vh-16rem)] max-h-[calc(90vh-7rem)] min-h-[320px] !flex-none flex-col gap-3 !overflow-hidden mt-5">
|
||||
{/* ── Başlık ─────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
|
||||
<FaMagic className="text-indigo-500" />
|
||||
|
|
|
|||
|
|
@ -1,49 +1,8 @@
|
|||
import { Button, Dialog } from '@/components/ui'
|
||||
import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog'
|
||||
import { SelectBoxOption } from '@/types/shared'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { IconType } from 'react-icons'
|
||||
import {
|
||||
FaArrowDown,
|
||||
FaArrowUp,
|
||||
FaBell,
|
||||
FaBook,
|
||||
FaCalculator,
|
||||
FaCheck,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaCode,
|
||||
FaCopy,
|
||||
FaDatabase,
|
||||
FaExclamationTriangle,
|
||||
FaEye,
|
||||
FaMagic,
|
||||
FaPlug,
|
||||
FaPlus,
|
||||
FaTimes,
|
||||
FaTrash,
|
||||
} from 'react-icons/fa'
|
||||
import {
|
||||
buildRuleLine,
|
||||
buildScriptFromRules,
|
||||
createRule,
|
||||
describeCondition,
|
||||
effectiveTrigger,
|
||||
isRuleComplete,
|
||||
parseScript,
|
||||
recipeGroups,
|
||||
recipes,
|
||||
recipesById,
|
||||
runtimeApiReference,
|
||||
scriptRunsOnOpen,
|
||||
triggerLabels,
|
||||
type ConditionOperator,
|
||||
type RecipeGroupKey,
|
||||
type RecipeParam,
|
||||
type RuleTrigger,
|
||||
type ScriptRule,
|
||||
} from './editor-script/scriptRecipes'
|
||||
import { useMemo } from 'react'
|
||||
import { createFormScriptDialect } from './editor-script/formScriptDialect'
|
||||
|
||||
type EditorScriptBuilderDialogProps = {
|
||||
isOpen: boolean
|
||||
|
|
@ -54,36 +13,10 @@ type EditorScriptBuilderDialogProps = {
|
|||
onApply: (value: string) => void
|
||||
}
|
||||
|
||||
const controlClass =
|
||||
'w-full min-w-0 h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400'
|
||||
|
||||
const cardClass =
|
||||
'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900'
|
||||
|
||||
const groupIcons: Record<RecipeGroupKey, IconType> = {
|
||||
calc: FaCalculator,
|
||||
data: FaDatabase,
|
||||
view: FaEye,
|
||||
interaction: FaBell,
|
||||
integration: FaPlug,
|
||||
}
|
||||
|
||||
const operatorLabels: { value: ConditionOperator; label: string }[] = [
|
||||
{ value: 'always', label: 'Her zaman' },
|
||||
{ value: 'equals', label: 'Eşitse' },
|
||||
{ value: 'notEquals', label: 'Eşit değilse' },
|
||||
{ value: 'contains', label: 'İçeriyorsa' },
|
||||
{ value: 'empty', label: 'Boşsa' },
|
||||
{ value: 'notEmpty', label: 'Doluysa' },
|
||||
{ value: 'greaterThan', label: 'Büyükse' },
|
||||
{ value: 'lessThan', label: 'Küçükse' },
|
||||
{ value: 'isTrue', label: 'İşaretliyse' },
|
||||
{ value: 'isFalse', label: 'İşaretli değilse' },
|
||||
]
|
||||
|
||||
const operatorNeedsValue = (operator: ConditionOperator) =>
|
||||
['equals', 'notEquals', 'contains', 'greaterThan', 'lessThan'].includes(operator)
|
||||
|
||||
/**
|
||||
* ListForm editor script'inin düzenleyicisi. Ortak Script Builder'ı form
|
||||
* lehçesiyle kurar; kural motoru ve üretilen script değişmedi.
|
||||
*/
|
||||
function EditorScriptBuilderDialog({
|
||||
isOpen,
|
||||
value,
|
||||
|
|
@ -94,624 +27,24 @@ function EditorScriptBuilderDialog({
|
|||
}: EditorScriptBuilderDialogProps) {
|
||||
const { translate } = useLocalization()
|
||||
|
||||
const availableFields = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(fields.map((field) => String(field.value || field.label || '')).filter(Boolean)),
|
||||
).sort((left, right) => left.localeCompare(right)),
|
||||
[fields],
|
||||
// Çağıran her render'da yeni bir dizi verebilir; lehçe alan adlarına göre
|
||||
// sabitlenmezse editör yazarken sıfırlanırdı.
|
||||
const fieldKey = fields.map((field) => String(field.value || field.label || '')).join('|')
|
||||
const dialect = useMemo(
|
||||
() => createFormScriptDialect({ currentField, fields: fieldKey.split('|') }),
|
||||
[currentField, fieldKey],
|
||||
)
|
||||
|
||||
const [rules, setRules] = useState<ScriptRule[]>([])
|
||||
const [manualScript, setManualScript] = useState('')
|
||||
const [mode, setMode] = useState<'rules' | 'manual'>('rules')
|
||||
const [openRules, setOpenRules] = useState<Record<string, boolean>>({})
|
||||
const [paletteOpen, setPaletteOpen] = useState(true)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const parsed = parseScript(value)
|
||||
const existing = value?.trim() ?? ''
|
||||
|
||||
setRules(parsed.rules)
|
||||
setManualScript(existing)
|
||||
setCopied(false)
|
||||
setHelpOpen(false)
|
||||
setPaletteOpen(parsed.rules.length === 0)
|
||||
setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false])))
|
||||
// Elle yazılmış ya da builder dışında değiştirilmiş script'i kurallara
|
||||
// zorlamıyoruz; kullanıcı kendisi geçmedikçe olduğu gibi korunur.
|
||||
setMode(!existing || parsed.inSync ? 'rules' : 'manual')
|
||||
}, [isOpen, value])
|
||||
|
||||
const generatedScript = useMemo(() => buildScriptFromRules(rules), [rules])
|
||||
const runsOnOpen = useMemo(() => scriptRunsOnOpen(rules.filter(isRuleComplete)), [rules])
|
||||
const activeScript = mode === 'rules' ? generatedScript : manualScript
|
||||
const completeRuleCount = rules.filter(isRuleComplete).length
|
||||
const manualDiffers = manualScript.trim() !== '' && manualScript.trim() !== generatedScript.trim()
|
||||
|
||||
const updateRule = (id: string, patch: Partial<ScriptRule>) =>
|
||||
setRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)))
|
||||
|
||||
const updateParam = (id: string, key: string, next: string) =>
|
||||
setRules((current) =>
|
||||
current.map((rule) =>
|
||||
rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule,
|
||||
),
|
||||
)
|
||||
|
||||
const moveRule = (index: number, delta: number) =>
|
||||
setRules((current) => {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= current.length) return current
|
||||
const next = [...current]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
})
|
||||
|
||||
const addRule = (recipeId: ScriptRule['recipe']) => {
|
||||
const rule = createRule(recipeId)
|
||||
setRules((current) => [...current, rule])
|
||||
setOpenRules((current) => ({ ...current, [rule.id]: true }))
|
||||
}
|
||||
|
||||
const copyScript = () => {
|
||||
navigator.clipboard?.writeText(activeScript)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
const renderFieldSelect = (
|
||||
selectedValue: string,
|
||||
onChange: (next: string) => void,
|
||||
placeholder = 'Alan seç',
|
||||
) => (
|
||||
<select
|
||||
className={controlClass}
|
||||
value={selectedValue}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{availableFields.map((fieldName) => (
|
||||
<option key={fieldName} value={fieldName}>
|
||||
{fieldName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
|
||||
const renderParam = (rule: ScriptRule, param: RecipeParam) => {
|
||||
if (param.type === 'fieldList') {
|
||||
const selectedFields = rule.fields ?? []
|
||||
return (
|
||||
<div key={param.key} className="col-span-12 min-w-0">
|
||||
<span className="mb-1 block text-xs text-gray-500">{param.label}</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{selectedFields.map((fieldName) => (
|
||||
<span
|
||||
key={fieldName}
|
||||
className="flex items-center gap-1 rounded-full bg-indigo-100 px-2 py-[2px] text-[11px] text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
|
||||
>
|
||||
{fieldName}
|
||||
<button
|
||||
type="button"
|
||||
className="text-indigo-400 hover:text-red-500"
|
||||
onClick={() =>
|
||||
updateRule(rule.id, {
|
||||
fields: selectedFields.filter((item) => item !== fieldName),
|
||||
})
|
||||
}
|
||||
>
|
||||
<FaTimes size={9} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<select
|
||||
className={`${controlClass} !h-8 !w-44`}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
if (!next || selectedFields.includes(next)) return
|
||||
updateRule(rule.id, { fields: [...selectedFields, next] })
|
||||
}}
|
||||
>
|
||||
<option value="">+ alan ekle</option>
|
||||
{availableFields
|
||||
.filter((fieldName) => !selectedFields.includes(fieldName))
|
||||
.map((fieldName) => (
|
||||
<option key={fieldName} value={fieldName}>
|
||||
{fieldName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentValue = rule.params[param.key] ?? ''
|
||||
|
||||
return (
|
||||
<label key={param.key} className="col-span-12 min-w-0 md:col-span-4">
|
||||
<span className="mb-1 block truncate text-xs text-gray-500" title={param.help}>
|
||||
{param.label}
|
||||
{param.optional && <span className="ml-1 text-gray-300">(ops.)</span>}
|
||||
</span>
|
||||
{param.type === 'field' && renderFieldSelect(currentValue, (next) =>
|
||||
updateParam(rule.id, param.key, next),
|
||||
)}
|
||||
{param.type === 'select' && (
|
||||
<select
|
||||
className={controlClass}
|
||||
value={currentValue}
|
||||
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
|
||||
>
|
||||
{param.choices?.map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{(param.type === 'text' || param.type === 'number') && (
|
||||
<input
|
||||
className={controlClass}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
value={currentValue}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{param.help && (
|
||||
<span className="mt-1 block truncate text-[11px] text-gray-400" title={param.help}>
|
||||
{param.help}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const renderRule = (rule: ScriptRule, index: number) => {
|
||||
const recipe = recipesById.get(rule.recipe)
|
||||
if (!recipe) return null
|
||||
|
||||
const open = openRules[rule.id] ?? true
|
||||
const complete = isRuleComplete(rule)
|
||||
const Icon = groupIcons[recipe.group]
|
||||
|
||||
return (
|
||||
<div key={rule.id} className={cardClass}>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-gray-400"
|
||||
onClick={() => setOpenRules((current) => ({ ...current, [rule.id]: !open }))}
|
||||
>
|
||||
{open ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
|
||||
</button>
|
||||
<Icon className={complete ? 'text-indigo-500' : 'text-gray-300'} size={13} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">
|
||||
{index + 1}. {recipe.label}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400" title={recipe.describe(rule)}>
|
||||
{recipe.conditionIsArgument
|
||||
? recipe.describe(rule)
|
||||
: `${describeCondition(rule.condition)} → ${recipe.describe(rule)}`}
|
||||
</div>
|
||||
</div>
|
||||
{effectiveTrigger(rule) !== 'change' && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-sky-100 px-2 py-[1px] text-[11px] text-sky-700 dark:bg-sky-900 dark:text-sky-200"
|
||||
title="Bu kural form açılırken de çalışır."
|
||||
>
|
||||
{effectiveTrigger(rule) === 'open' ? 'açılışta' : 'açılış + değişim'}
|
||||
</span>
|
||||
)}
|
||||
{!complete && (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-[1px] text-[11px] text-amber-700 dark:bg-amber-900 dark:text-amber-200"
|
||||
title="Zorunlu alanlar dolmadığı için bu kural script'e eklenmez."
|
||||
>
|
||||
<FaExclamationTriangle size={9} /> eksik
|
||||
</span>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="plain"
|
||||
title="Yukarı taşı"
|
||||
icon={<FaArrowUp />}
|
||||
onClick={() => moveRule(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="plain"
|
||||
title="Aşağı taşı"
|
||||
icon={<FaArrowDown />}
|
||||
onClick={() => moveRule(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="plain"
|
||||
title="Kuralı sil"
|
||||
icon={<FaTrash />}
|
||||
onClick={() => setRules((current) => current.filter((item) => item.id !== rule.id))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gray-100 px-3 py-3 dark:border-gray-800">
|
||||
<p className="mb-3 text-[11px] text-gray-400">{recipe.summary}</p>
|
||||
|
||||
<div className="mb-3 grid grid-cols-12 items-end gap-2 rounded-md bg-gray-50 p-2 dark:bg-gray-800/40">
|
||||
<label className="col-span-12 min-w-0 md:col-span-3">
|
||||
<span
|
||||
className="mb-1 block text-xs text-gray-500"
|
||||
title="Form açılırken çalıştırmak, varsayılan değer üretmek için kullanılır."
|
||||
>
|
||||
Ne zaman
|
||||
</span>
|
||||
<select
|
||||
className={controlClass}
|
||||
value={effectiveTrigger(rule)}
|
||||
onChange={(event) =>
|
||||
updateRule(rule.id, { trigger: event.target.value as RuleTrigger })
|
||||
}
|
||||
>
|
||||
{triggerLabels.map((trigger) => (
|
||||
<option key={trigger.value} value={trigger.value} title={trigger.help}>
|
||||
{trigger.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="col-span-12 min-w-0 md:col-span-3">
|
||||
<span className="mb-1 block text-xs text-gray-500">
|
||||
{recipe.conditionIsArgument ? 'Kilit koşulu' : 'Koşul'}
|
||||
</span>
|
||||
<select
|
||||
className={controlClass}
|
||||
value={rule.condition.operator}
|
||||
onChange={(event) =>
|
||||
updateRule(rule.id, {
|
||||
condition: {
|
||||
...rule.condition,
|
||||
operator: event.target.value as ConditionOperator,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{operatorLabels.map((operator) => (
|
||||
<option key={operator.value} value={operator.value}>
|
||||
{operator.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{rule.condition.operator !== 'always' && (
|
||||
<label className="col-span-12 min-w-0 md:col-span-3">
|
||||
<span className="mb-1 block text-xs text-gray-500">Koşul alanı</span>
|
||||
{renderFieldSelect(rule.condition.source, (next) =>
|
||||
updateRule(rule.id, { condition: { ...rule.condition, source: next } }),
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{operatorNeedsValue(rule.condition.operator) && (
|
||||
<label className="col-span-12 min-w-0 md:col-span-3">
|
||||
<span className="mb-1 block text-xs text-gray-500">Karşılaştırma değeri</span>
|
||||
<input
|
||||
className={controlClass}
|
||||
value={rule.condition.value}
|
||||
placeholder="Aktif / 1000"
|
||||
onChange={(event) =>
|
||||
updateRule(rule.id, {
|
||||
condition: { ...rule.condition, value: event.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-3">
|
||||
{recipe.params.map((param) => renderParam(rule, param))}
|
||||
</div>
|
||||
|
||||
<pre className="mt-3 overflow-x-auto rounded border border-gray-100 bg-gray-950 px-3 py-2 font-mono text-[11px] text-emerald-100 dark:border-gray-800">
|
||||
{buildRuleLine(rule, runsOnOpen) || recipe.example}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
preventScroll
|
||||
<ScriptBuilderDialog
|
||||
cancelLabel={translate('::Cancel')}
|
||||
dialect={dialect}
|
||||
isOpen={isOpen}
|
||||
width={1360}
|
||||
contentClassName="flex flex-col"
|
||||
style={{ overlay: { zIndex: 1300 } }}
|
||||
saveLabel={translate('::Save')}
|
||||
value={value}
|
||||
onApply={onApply}
|
||||
onClose={onClose}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{/*
|
||||
Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca
|
||||
react-modal içeriğine uygulanıyor (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.' },
|
||||
]
|
||||
|
||||
/** Ek koşullar nasıl birleşecek. */
|
||||
export type ConditionJoin = 'and' | 'or'
|
||||
|
||||
export type ScriptRule = {
|
||||
id: string
|
||||
recipe: RecipeId
|
||||
condition: RuleCondition
|
||||
/**
|
||||
* İkinci ve sonraki koşullar. Tek koşullu kurallar bu alanı hiç taşımaz —
|
||||
* böylece eskiden kaydedilmiş script'ler birebir aynı JSON'a ve aynı koda
|
||||
* üretilmeye devam eder.
|
||||
*/
|
||||
conditions?: RuleCondition[]
|
||||
join?: ConditionJoin
|
||||
/** Tarife göre anlamı değişen serbest parametreler. */
|
||||
params: Record<string, string>
|
||||
/** Çoklu alan alan tarifler için (sum, clear). */
|
||||
|
|
@ -55,6 +65,12 @@ export type ScriptRule = {
|
|||
trigger?: RuleTrigger
|
||||
}
|
||||
|
||||
/** Kuralın bütün koşulları, ilki her zaman `condition`. */
|
||||
export const ruleConditions = (rule: ScriptRule): RuleCondition[] => [
|
||||
rule.condition,
|
||||
...(rule.conditions ?? []),
|
||||
]
|
||||
|
||||
export type RecipeParam = {
|
||||
key: string
|
||||
label: string
|
||||
|
|
@ -83,8 +99,11 @@ export type Recipe = {
|
|||
|
||||
export type RecipeId =
|
||||
| 'multiply'
|
||||
| 'subtract'
|
||||
| 'percent'
|
||||
| 'sum'
|
||||
| 'formula'
|
||||
| 'today'
|
||||
| 'days'
|
||||
| 'hours'
|
||||
| 'copy'
|
||||
|
|
@ -142,6 +161,17 @@ export const buildConditionExpression = (condition: RuleCondition): string => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kuralın tüm koşullarını tek ifadeye çevirir. Tek koşulda çıktı eskisiyle
|
||||
* birebir aynıdır; birden fazlasında her parça parantezlenip `&&` / `||` ile
|
||||
* birleştirilir.
|
||||
*/
|
||||
export const buildRuleConditionExpression = (rule: ScriptRule): string => {
|
||||
const parts = ruleConditions(rule).map(buildConditionExpression).filter(Boolean)
|
||||
if (parts.length <= 1) return parts[0] ?? ''
|
||||
return parts.map((part) => `(${part})`).join(rule.join === 'or' ? ' || ' : ' && ')
|
||||
}
|
||||
|
||||
export const describeCondition = (condition: RuleCondition): string => {
|
||||
if (condition.operator === 'always' || !condition.source) return 'Her zaman'
|
||||
const source = label(condition.source, 'alan')
|
||||
|
|
@ -203,6 +233,104 @@ export const recipes: Recipe[] = [
|
|||
'hedef',
|
||||
)} alanına yaz.`,
|
||||
},
|
||||
{
|
||||
id: 'subtract',
|
||||
label: 'Fark (brüt − indirim)',
|
||||
group: 'calc',
|
||||
summary: 'Birinci alandan ikinciyi çıkarır, yuvarlar ve hedefe yazar.',
|
||||
example: "set('Net', round(num('Gross') - num('Discount'), 2))",
|
||||
params: [
|
||||
{ key: 'left', label: 'Çıkarılan alan', type: 'field' },
|
||||
{ key: 'right', label: 'Çıkan alan', type: 'field' },
|
||||
{ key: 'target', label: 'Sonuç alanı', type: 'field' },
|
||||
{ key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' },
|
||||
],
|
||||
build: ({ params }) =>
|
||||
`set(${q(params.target)}, round(${numberOrField(params.left)} - ${numberOrField(
|
||||
params.right,
|
||||
)}, ${params.digits || 2}))`,
|
||||
describe: ({ params }) =>
|
||||
`${label(params.left, 'alan')} − ${label(params.right, 'alan')} sonucunu ${label(
|
||||
params.target,
|
||||
'hedef',
|
||||
)} alanına yaz.`,
|
||||
},
|
||||
{
|
||||
id: 'percent',
|
||||
label: 'Yüzde (KDV, iskonto)',
|
||||
group: 'calc',
|
||||
summary: 'Tutarın belirtilen yüzdesini hesaplar; oran alan ya da sabit olabilir.',
|
||||
example: "set('Kdv', round(num('Tutar') * num('KdvOrani') / 100, 2))",
|
||||
params: [
|
||||
{ key: 'amount', label: 'Tutar alanı', type: 'field' },
|
||||
{
|
||||
key: 'rate',
|
||||
label: 'Oran',
|
||||
type: 'text',
|
||||
placeholder: '20 veya KdvOrani',
|
||||
help: 'Sayı yazarsan sabit oran, alan adı yazarsan o alandan okunur.',
|
||||
},
|
||||
{ key: 'target', label: 'Sonuç alanı', type: 'field' },
|
||||
{ key: 'digits', label: 'Ondalık', type: 'number', optional: true, placeholder: '2' },
|
||||
{
|
||||
key: 'mode',
|
||||
label: 'Sonuç',
|
||||
type: 'select',
|
||||
optional: true,
|
||||
choices: [
|
||||
{ value: '', label: 'Yalnızca yüzde tutarı' },
|
||||
{ value: 'add', label: 'Tutar + yüzde' },
|
||||
{ value: 'subtract', label: 'Tutar − yüzde' },
|
||||
],
|
||||
},
|
||||
],
|
||||
build: ({ params }) => {
|
||||
const amount = numberOrField(params.amount)
|
||||
const portion = `${amount} * ${numberOrField(params.rate)} / 100`
|
||||
const expression =
|
||||
params.mode === 'add'
|
||||
? `${amount} + ${portion}`
|
||||
: params.mode === 'subtract'
|
||||
? `${amount} - ${portion}`
|
||||
: portion
|
||||
return `set(${q(params.target)}, round(${expression}, ${params.digits || 2}))`
|
||||
},
|
||||
describe: ({ params }) =>
|
||||
`${label(params.amount, 'tutar')} üzerinden %${label(params.rate, 'oran')} hesabını ${label(
|
||||
params.target,
|
||||
'hedef',
|
||||
)} alanına yaz.`,
|
||||
},
|
||||
{
|
||||
id: 'today',
|
||||
label: 'Bugünün tarihi',
|
||||
group: 'calc',
|
||||
summary: 'Bugünü (veya kaç gün sonrasını) tarih alanına yazar.',
|
||||
example: "set('Tarih', new Date().toISOString().slice(0, 10))",
|
||||
params: [
|
||||
{ key: 'target', label: 'Hedef alan', type: 'field' },
|
||||
{
|
||||
key: 'offset',
|
||||
label: 'Gün ekle',
|
||||
type: 'number',
|
||||
optional: true,
|
||||
placeholder: '0',
|
||||
help: 'Negatif yazarsan geçmiş bir tarih üretir.',
|
||||
},
|
||||
],
|
||||
build: ({ params }) => {
|
||||
const offset = Number(params.offset) || 0
|
||||
const base = offset
|
||||
? `new Date(Date.now() + ${offset} * 86400000)`
|
||||
: 'new Date()'
|
||||
return `set(${q(params.target)}, ${base}.toISOString().slice(0, 10))`
|
||||
},
|
||||
describe: ({ params }) => {
|
||||
const offset = Number(params.offset) || 0
|
||||
const when = offset > 0 ? `${offset} gün sonrasını` : offset < 0 ? `${-offset} gün öncesini` : 'bugünü'
|
||||
return `${when} ${label(params.target, 'hedef')} alanına yaz.`
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sum',
|
||||
label: 'Toplama',
|
||||
|
|
@ -355,10 +483,10 @@ export const recipes: Recipe[] = [
|
|||
],
|
||||
},
|
||||
],
|
||||
build: ({ params, condition }) => {
|
||||
const expression = buildConditionExpression(condition) || 'true'
|
||||
const flag = params.invert === 'true' ? `!(${expression})` : expression
|
||||
return `readOnly(${q(params.target)}, ${flag})`
|
||||
build: (rule) => {
|
||||
const expression = buildRuleConditionExpression(rule) || 'true'
|
||||
const flag = rule.params.invert === 'true' ? `!(${expression})` : expression
|
||||
return `readOnly(${q(rule.params.target)}, ${flag})`
|
||||
},
|
||||
describe: ({ params, condition }) =>
|
||||
`${describeCondition(condition)} ${label(params.target, 'alan')} alanını ${
|
||||
|
|
@ -505,11 +633,13 @@ export const isRuleComplete = (rule: ScriptRule): boolean => {
|
|||
const recipe = recipesById.get(rule.recipe)
|
||||
if (!recipe) return false
|
||||
|
||||
const conditionReady =
|
||||
rule.condition.operator === 'always' ||
|
||||
(!!rule.condition.source &&
|
||||
(['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(rule.condition.operator) ||
|
||||
rule.condition.value.trim() !== ''))
|
||||
const conditionReady = ruleConditions(rule).every(
|
||||
(condition) =>
|
||||
condition.operator === 'always' ||
|
||||
(!!condition.source &&
|
||||
(['empty', 'notEmpty', 'isTrue', 'isFalse'].includes(condition.operator) ||
|
||||
condition.value.trim() !== '')),
|
||||
)
|
||||
if (!conditionReady) return false
|
||||
|
||||
return recipe.params.every((param) => {
|
||||
|
|
@ -543,7 +673,7 @@ export const buildRuleLine = (rule: ScriptRule, runsOnOpen = false): string => {
|
|||
}
|
||||
|
||||
if (!recipe.conditionIsArgument) {
|
||||
const condition = buildConditionExpression(rule.condition)
|
||||
const condition = buildRuleConditionExpression(rule)
|
||||
if (condition) guards.push(condition)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
FaSlidersH,
|
||||
FaSync,
|
||||
FaTabletAlt,
|
||||
FaTimes,
|
||||
FaTrash,
|
||||
FaUndo,
|
||||
} from 'react-icons/fa'
|
||||
|
|
@ -39,6 +40,11 @@ import { developerKitService } from '@/services/developerKit.service'
|
|||
import { Button, Notification, toast } from '@/components/ui'
|
||||
import StyleModal from '@/components/codeLayout/StyleModal'
|
||||
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
|
||||
import {
|
||||
beginDesignerDragSoon,
|
||||
endDesignerDrag,
|
||||
useDesignerDrag,
|
||||
} from '@/components/visualDesigner/designerDrag'
|
||||
import {
|
||||
DESIGNER_PRIMARY_EVENTS,
|
||||
DESIGNER_SCRIPT_RECIPES,
|
||||
|
|
@ -47,8 +53,10 @@ import {
|
|||
getDesignerEventSnippet,
|
||||
} from '@/components/visualDesigner/catalog'
|
||||
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
||||
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
|
||||
import {
|
||||
collectDesignerRefs,
|
||||
getSqlDataSourceEvent,
|
||||
createDesignerId,
|
||||
createEmptyDesignerDocument,
|
||||
findDesignerNode,
|
||||
|
|
@ -56,6 +64,7 @@ import {
|
|||
renameDesignerRefInNodes,
|
||||
renameDesignerRefInScript,
|
||||
withDesignerRefs,
|
||||
getDesignerBindingColumns,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerValueByPath,
|
||||
getSqlDataSourceEndpointId,
|
||||
|
|
@ -1171,6 +1180,13 @@ const VisualComponentDesigner = () => {
|
|||
null,
|
||||
)
|
||||
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
|
||||
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
|
||||
const activeDrag = useDesignerDrag()
|
||||
/** Kural tabanlı script builder'ın düzenlediği event. */
|
||||
const [scriptBuilderTarget, setScriptBuilderTarget] = useState<{
|
||||
nodeId: string
|
||||
eventName: string
|
||||
} | null>(null)
|
||||
const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
|
||||
const undoStack = useRef<DesignerDocument[]>([])
|
||||
const redoStack = useRef<DesignerDocument[]>([])
|
||||
|
|
@ -2341,6 +2357,7 @@ const VisualComponentDesigner = () => {
|
|||
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
|
||||
? selectedNode?.bindings?.[optionDataProperty]
|
||||
: undefined
|
||||
const selectOptionColumns = getDesignerBindingColumns(selectOptionsBinding)
|
||||
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
|
||||
? selectedNode?.bindings?.items
|
||||
: undefined
|
||||
|
|
@ -2882,6 +2899,69 @@ const VisualComponentDesigner = () => {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
|
||||
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
|
||||
başka bir komponente ya da SqlDataSource kaydına yazabilir.
|
||||
*/}
|
||||
{selectCollectionSample && selectColumnFields.length > 0 && (
|
||||
<div>
|
||||
<span className="mb-1 flex items-center justify-between text-[10px] font-semibold text-slate-500">
|
||||
Ek sütunlar
|
||||
<code className="font-normal normal-case text-sky-600">
|
||||
refs.{selectedNode?.ref}.getColumn('…')
|
||||
</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 &&
|
||||
selectColumnFields.length > 0 &&
|
||||
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
|
||||
|
|
@ -3668,6 +3748,27 @@ const VisualComponentDesigner = () => {
|
|||
current.trim() ? `${current.replace(/\s+$/, '')}\n\n${snippet}` : snippet,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Builder context of the event being edited: the SqlDataSource that owns the
|
||||
* record (the node itself, or the nearest one above it) plus the columns and
|
||||
* ref names its rules can address.
|
||||
*/
|
||||
const scriptBuilderNode = scriptBuilderTarget
|
||||
? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId)
|
||||
: null
|
||||
const scriptBuilderSqlNode = scriptBuilderNode
|
||||
? isSqlDataSourceNode(scriptBuilderNode.type)
|
||||
? scriptBuilderNode
|
||||
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
|
||||
.reverse()
|
||||
.find((node) => isSqlDataSourceNode(node.type))
|
||||
: undefined
|
||||
const scriptBuilderRecordFields = scriptBuilderSqlNode
|
||||
? Object.keys(
|
||||
(sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {},
|
||||
)
|
||||
: []
|
||||
|
||||
const endpointResultText = endpointResultModal
|
||||
? typeof endpointResultModal.result === 'string'
|
||||
? endpointResultModal.result
|
||||
|
|
@ -3817,8 +3918,16 @@ const VisualComponentDesigner = () => {
|
|||
<button
|
||||
key={`${definition.toolboxGroup}-${definition.name}`}
|
||||
draggable
|
||||
className="group min-w-0 rounded-lg border border-slate-200 bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950"
|
||||
title={definition.description}
|
||||
className={`group min-w-0 rounded-lg border bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950 ${
|
||||
activeDrag?.source === 'library' && activeDrag.name === definition.name
|
||||
? 'border-sky-500 ring-2 ring-sky-300 dark:border-sky-500'
|
||||
: 'border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
title={
|
||||
definition.acceptsChildren
|
||||
? `${definition.description}\n\nİçine başka komponent alabilir.`
|
||||
: definition.description
|
||||
}
|
||||
type="button"
|
||||
onClick={() => addComponent(definition.name, null)}
|
||||
onDragStart={(event) => {
|
||||
|
|
@ -3829,7 +3938,13 @@ const VisualComponentDesigner = () => {
|
|||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
|
||||
event.dataTransfer.setData('text/plain', payload)
|
||||
// Canvas bırakma hedeflerini vurgulayabilmek için
|
||||
// sürüklemenin başladığını duyuruyoruz — senkron bir
|
||||
// state güncellemesi sürüklemeyi iptal ettirebildiği
|
||||
// için bir tık sonraya bırakarak.
|
||||
beginDesignerDragSoon({ source: 'library', name: definition.name })
|
||||
}}
|
||||
onDragEnd={() => endDesignerDrag()}
|
||||
>
|
||||
<div className="mb-1 flex h-6 w-6 items-center justify-center rounded bg-slate-100 text-[10px] font-bold text-slate-500 group-hover:bg-sky-100 group-hover:text-sky-600 dark:bg-slate-800">
|
||||
{definition.name.slice(0, 2).toUpperCase()}
|
||||
|
|
@ -4123,11 +4238,35 @@ const VisualComponentDesigner = () => {
|
|||
onFocus={() => setFocusedEventName(eventName)}
|
||||
>
|
||||
<span className="flex items-center justify-between bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-700 dark:bg-slate-950 dark:text-slate-200">
|
||||
<code>{eventName}(event)</code>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
title={getSqlDataSourceEvent(eventName)?.description}
|
||||
>
|
||||
<code>{eventName}(event)</code>
|
||||
{getSqlDataSourceEvent(eventName) &&
|
||||
isSqlDataSourceNode(activeJavaScriptTarget.node.type) && (
|
||||
<span className="ml-2 font-normal text-slate-400">
|
||||
{getSqlDataSourceEvent(eventName)?.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-[9px] font-normal text-slate-400">
|
||||
{activeJavaScriptTarget.node.type}
|
||||
</span>
|
||||
<button
|
||||
className="rounded bg-indigo-100 px-2 py-1 text-[9px] font-semibold text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950 dark:text-indigo-300"
|
||||
title="Kural tabanlı script builder"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setScriptBuilderTarget({
|
||||
nodeId: activeJavaScriptTarget.node.id,
|
||||
eventName,
|
||||
})
|
||||
}
|
||||
>
|
||||
Builder
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-sky-950 dark:text-sky-300"
|
||||
disabled={Boolean(
|
||||
|
|
@ -4138,9 +4277,11 @@ const VisualComponentDesigner = () => {
|
|||
updateNodeEvent(
|
||||
activeJavaScriptTarget.node.id,
|
||||
eventName,
|
||||
getDesignerEventSnippet(
|
||||
activeJavaScriptTarget.node.type,
|
||||
eventName,
|
||||
resolveRecipeCode(
|
||||
getDesignerEventSnippet(
|
||||
activeJavaScriptTarget.node.type,
|
||||
eventName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -4719,6 +4860,23 @@ const VisualComponentDesigner = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scriptBuilderTarget && scriptBuilderNode && (
|
||||
<DesignerScriptBuilderDialog
|
||||
isOpen
|
||||
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
|
||||
eventInfo={getSqlDataSourceEvent(scriptBuilderTarget.eventName)}
|
||||
eventName={scriptBuilderTarget.eventName}
|
||||
recordFields={scriptBuilderRecordFields}
|
||||
refNames={[...documentRefs.keys()]}
|
||||
sqlRef={scriptBuilderSqlNode?.ref || ''}
|
||||
value={scriptBuilderNode.events[scriptBuilderTarget.eventName] || ''}
|
||||
onApply={(next) =>
|
||||
updateNodeEvent(scriptBuilderTarget.nodeId, scriptBuilderTarget.eventName, next)
|
||||
}
|
||||
onClose={() => setScriptBuilderTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue