You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
245 lines
10 KiB
245 lines
10 KiB
<script lang="ts">
|
|
/**
|
|
* CONDITION BUILDER
|
|
* Trabaja directamente con la API del ConditionBuilderStore (árbol de nodos).
|
|
* Sigue el patrón visual del ConditionBuilder.svelte de referencia.
|
|
*/
|
|
import type { ConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts'
|
|
import type { ConditionNode, LogicOp, CompareOp, Operand } from '@/vcen-edit/lib/condition-editor.ts'
|
|
|
|
let {
|
|
store,
|
|
onchange,
|
|
}: {
|
|
store: ConditionBuilderStore
|
|
onchange: () => void
|
|
} = $props()
|
|
|
|
let rawMode = $state(false)
|
|
let rawValue = $state('')
|
|
|
|
function emit() { onchange() }
|
|
|
|
// Renderiza un nodo de forma recursiva
|
|
// Los nodos son: { kind: 'logic', op, children } | { kind: 'compare', op, left, right }
|
|
|
|
function nodeIsEmpty(node: ConditionNode): boolean {
|
|
if (node.kind === 'logic') return node.children.length === 0
|
|
return false
|
|
}
|
|
|
|
const isEmpty = $derived(
|
|
store.root.kind === 'logic' && store.root.children.length === 0
|
|
)
|
|
|
|
// Scope para los selects de atributo
|
|
const scopeAttrs = $derived(
|
|
store.scope.map(e => ({ id: e.path, label: e.label }))
|
|
)
|
|
|
|
function getLeftPath(node: ConditionNode): string {
|
|
if (node.kind !== 'compare') return ''
|
|
const left = node.left
|
|
if (left && typeof left === 'object' && 'kind' in left && (left as any).kind === 'var') return (left as any).path
|
|
return ''
|
|
}
|
|
|
|
function setLeft(nodeId: string, path: string) {
|
|
store.setLeft(nodeId, { kind: 'var', path } as Operand)
|
|
emit()
|
|
}
|
|
|
|
function setOp(nodeId: string, op: CompareOp) {
|
|
store.setOperator(nodeId, op)
|
|
emit()
|
|
}
|
|
|
|
function setRightLiteral(nodeId: string, value: unknown) {
|
|
store.setRight(nodeId, { kind: 'literal', value } as Operand)
|
|
emit()
|
|
}
|
|
|
|
function toggleOptRight(node: ConditionNode, optId: string, multi: boolean) {
|
|
if (node.kind !== 'compare') return
|
|
const current = Array.isArray(node.right) ? node.right : (node.right && typeof node.right === 'object' && 'kind' in (node.right as any) && (node.right as any).kind === 'literal' ? [(node.right as any)] : [])
|
|
const currentIds = current.map((o: any) => o?.value ?? o)
|
|
const next = multi
|
|
? (currentIds.includes(optId) ? currentIds.filter((v: string) => v !== optId) : [...currentIds, optId])
|
|
: [optId]
|
|
if (multi) {
|
|
store.setRight(node.id, next.map(v => ({ kind: 'literal', value: v })) as any)
|
|
} else {
|
|
store.setRight(node.id, { kind: 'literal', value: next[0] } as Operand)
|
|
}
|
|
emit()
|
|
}
|
|
|
|
function isOptActive(node: ConditionNode, optId: string): boolean {
|
|
if (node.kind !== 'compare') return false
|
|
const r = node.right
|
|
if (!r) return false
|
|
if (Array.isArray(r)) return r.some((o: any) => o?.value === optId || o === optId)
|
|
if (typeof r === 'object' && 'kind' in r && (r as any).kind === 'literal') return (r as any).value === optId
|
|
return false
|
|
}
|
|
|
|
const OPS_LABELS: Record<string, string> = {
|
|
'==': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤',
|
|
'in': 'es alguno de', '!in': 'no es ninguno de',
|
|
'contains': 'contiene', 'startsWith': 'empieza por',
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
|
|
<!-- Header con toggle raw -->
|
|
<div class="flex items-center justify-between min-h-5">
|
|
<span class="text-xs text-gray-600 uppercase tracking-wider">Condición</span>
|
|
<div class="flex items-center gap-2">
|
|
{#if !rawMode && isEmpty}
|
|
<span class="text-xs text-gray-700 italic">siempre activa</span>
|
|
{/if}
|
|
{#if store.errors.length > 0 && !rawMode}
|
|
<span class="text-xs text-amber-700 italic">{store.errors.length} error{store.errors.length > 1 ? 'es' : ''}</span>
|
|
{/if}
|
|
<button
|
|
onclick={() => {
|
|
if (!rawMode) rawValue = store.compiled ? JSON.stringify(store.compiled, null, 2) : ''
|
|
rawMode = !rawMode
|
|
}}
|
|
class="text-xs text-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
|
|
>{rawMode ? 'visual' : 'raw'}</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if rawMode}
|
|
<textarea rows="4"
|
|
placeholder={'{\n "==": [{"var": "att/color"}, "rojo"]\n}'}
|
|
bind:value={rawValue}
|
|
oninput={() => {
|
|
try {
|
|
const parsed = rawValue.trim() ? JSON.parse(rawValue) : undefined
|
|
if (parsed !== undefined) store.load(parsed)
|
|
else store.reset()
|
|
emit()
|
|
} catch { /* esperar JSON válido */ }
|
|
}}
|
|
class="field-input font-mono text-xs resize-none"
|
|
spellcheck={false}
|
|
></textarea>
|
|
<p class="text-xs text-gray-700">JsonLogic. Vacío = siempre activa.</p>
|
|
|
|
{:else}
|
|
|
|
<!-- Lógica raíz AND/OR -->
|
|
{#if store.root.kind === 'logic'}
|
|
{@const root = store.root}
|
|
|
|
{#if root.children.length > 1}
|
|
<div class="flex items-center gap-1.5 text-xs">
|
|
<span class="text-gray-600 shrink-0">Combinar con</span>
|
|
{#each (['and', 'or'] as const) as l}
|
|
<button
|
|
onclick={() => { store.setLogicOp(root.id, l); emit(); }}
|
|
class="px-2 py-0.5 rounded border text-xs transition-colors
|
|
{root.op === l
|
|
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
|
: 'border-gray-700 text-gray-500 hover:border-gray-600'}"
|
|
>{l.toUpperCase()}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#each root.children as node (node.id)}
|
|
{#if node.kind === 'compare'}
|
|
{@const leftPath = getLeftPath(node)}
|
|
{@const ops = leftPath ? store.validOperatorsFor(leftPath) : []}
|
|
{@const opts = leftPath ? store.optsFor(leftPath) : []}
|
|
{@const isMulti = node.op === 'in' || node.op === '!in'}
|
|
|
|
<div class="flex flex-col gap-1.5 rounded border border-gray-800 bg-gray-900/40 p-2">
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
|
|
<!-- Atributo -->
|
|
<select
|
|
value={leftPath}
|
|
onchange={e => setLeft(node.id, e.currentTarget.value)}
|
|
class="flex-1 min-w-0 text-xs rounded border border-gray-700 bg-gray-800
|
|
text-gray-300 px-1.5 py-1 focus:outline-none focus:border-indigo-500"
|
|
>
|
|
<option value="">— atributo —</option>
|
|
{#each scopeAttrs as a}
|
|
<option value={a.id}>{a.label}</option>
|
|
{/each}
|
|
</select>
|
|
|
|
<!-- Operador -->
|
|
{#if leftPath && ops.length > 0}
|
|
<select
|
|
value={node.op}
|
|
onchange={e => setOp(node.id, e.currentTarget.value as CompareOp)}
|
|
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
|
|
text-gray-300 px-1.5 py-1 focus:outline-none focus:border-indigo-500"
|
|
>
|
|
{#each ops as op}
|
|
<option value={op}>{OPS_LABELS[op] ?? op}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
|
|
<!-- Valor (si no hay opts chips) -->
|
|
{#if leftPath && opts.length === 0}
|
|
<input type="text"
|
|
value={node.right && typeof node.right === 'object' && 'kind' in node.right && (node.right as any).kind === 'literal'
|
|
? String((node.right as any).value ?? '') : ''}
|
|
oninput={e => setRightLiteral(node.id, e.currentTarget.value)}
|
|
placeholder="valor..."
|
|
class="w-24 shrink-0 text-xs rounded border border-gray-700 bg-gray-800
|
|
text-gray-300 px-1.5 py-1" />
|
|
{/if}
|
|
|
|
<button
|
|
onclick={() => { store.remove(node.id); emit(); }}
|
|
class="shrink-0 text-gray-600 hover:text-red-400 transition-colors text-xs px-1"
|
|
>✕</button>
|
|
</div>
|
|
|
|
<!-- Chips de opciones -->
|
|
{#if opts.length > 0}
|
|
<div class="flex flex-wrap gap-1 pt-0.5 pl-0.5">
|
|
{#each opts as opt}
|
|
{@const active = isOptActive(node, opt.optId)}
|
|
<button
|
|
onclick={() => toggleOptRight(node, opt.optId, isMulti)}
|
|
class="text-xs px-2 py-0.5 rounded-full border transition-colors
|
|
{active
|
|
? 'bg-indigo-600/30 border-indigo-500 text-indigo-300'
|
|
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
|
|
>{opt.value ?? opt.optId}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
|
|
{/if}
|
|
|
|
<!-- Añadir cláusula -->
|
|
<button
|
|
onclick={() => { store.addCompare(store.root.id); emit(); }}
|
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded border border-dashed
|
|
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
|
|
hover:border-indigo-700 transition-colors"
|
|
>+ Añadir cláusula</button>
|
|
|
|
{#if store.tautology.kind !== 'unknown'}
|
|
<p class="text-xs text-amber-700 italic">
|
|
⚠ {store.tautology.kind === 'always-true' ? 'Siempre verdadera' : 'Siempre falsa'}
|
|
</p>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
</div>
|