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.
v/src/vcen-edit/comps/ConditionBuilder.svelte

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>

Powered by TurnKey Linux.