master
parent
d1586701a2
commit
dd34cbf9f1
@ -0,0 +1,519 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ConditionBuilder — Demo</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { font-family: 'IBM Plex Sans', sans-serif; }
|
||||
.mono { font-family: 'IBM Plex Mono', monospace; }
|
||||
|
||||
body {
|
||||
background: #0f0f0f;
|
||||
color: #e8e8e8;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.node-logic {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #313158;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.node-compare {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-compare:hover { border-color: #3a3a3a; }
|
||||
|
||||
select, input[type="text"] {
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
color: #e8e8e8;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
select:focus, input:focus { border-color: #5b6af5; }
|
||||
|
||||
select option { background: #1a1a1a; }
|
||||
|
||||
.btn {
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-add { background: #1e2d4a; color: #5b9cf5; }
|
||||
.btn-add:hover { background: #243566; }
|
||||
.btn-group { background: #2a1e4a; color: #9b6af5; }
|
||||
.btn-group:hover { background: #321e5e; }
|
||||
.btn-remove { background: #2a1e1e; color: #f56a6a; }
|
||||
.btn-remove:hover { background: #3d1e1e; }
|
||||
|
||||
.op-badge {
|
||||
background: #252540;
|
||||
border: 1px solid #35356a;
|
||||
color: #8888ff;
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.children-indent {
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid #252540;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.compiled-output {
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tag-domain {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-string { background: #1a2e1a; color: #5fc95f; border: 1px solid #2a4a2a; }
|
||||
.tag-number { background: #1a1e2e; color: #5f8fc9; border: 1px solid #2a304a; }
|
||||
.tag-boolean { background: #2e1a2e; color: #c95fc9; border: 1px solid #4a2a4a; }
|
||||
.tag-date { background: #2e2a1a; color: #c9a05f; border: 1px solid #4a3a2a; }
|
||||
.tag-option { background: #1a2a2e; color: #5fc9c9; border: 1px solid #2a3a4a; }
|
||||
|
||||
.tautology-true { background: #1a2a1a; border: 1px solid #2a4a2a; color: #5fc95f; border-radius: 6px; padding: 8px 12px; font-size: 13px; }
|
||||
.tautology-false { background: #2a1a1a; border: 1px solid #4a2a2a; color: #f56a6a; border-radius: 6px; padding: 8px 12px; font-size: 13px; }
|
||||
.error-msg { color: #f56a6a; font-size: 11px; padding: 2px 4px; }
|
||||
|
||||
.scope-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: #141414;
|
||||
border: 1px solid #222;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.valid-badge { background: #1a2a1a; color: #5fc95f; border: 1px solid #2a4a2a; }
|
||||
.invalid-badge { background: #2a1a1a; color: #f56a6a; border: 1px solid #4a2a2a; }
|
||||
.status-badge { border-radius: 4px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.animate-in { animation: fadeIn 0.15s ease; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="p-6">
|
||||
|
||||
<div class="max-w-5xl mx-auto flex flex-col gap-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">ConditionBuilder</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Editor de condiciones JsonLogic — Demo</p>
|
||||
</div>
|
||||
<div id="status-badge" class="status-badge valid-badge">✓ válida</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr_320px] gap-4">
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="panel p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-300">Condición</span>
|
||||
<button onclick="resetEditor()" class="btn btn-remove text-xs">↺ Reset</button>
|
||||
</div>
|
||||
<div id="tautology-warning"></div>
|
||||
<div id="condition-tree"></div>
|
||||
</div>
|
||||
|
||||
<!-- Panel derecho -->
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Scope -->
|
||||
<div class="panel p-4 flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-gray-300">Variables del scope</span>
|
||||
<div id="scope-list" class="flex flex-col gap-1.5"></div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="panel p-4 flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-gray-300 mono">JsonLogicValue</span>
|
||||
<div id="compiled-output" class="compiled-output mono text-gray-400">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Errores -->
|
||||
<div id="errors-panel" class="panel p-4 flex flex-col gap-2" style="display:none!important">
|
||||
<span class="text-sm font-medium text-red-400">Errores</span>
|
||||
<div id="errors-list" class="flex flex-col gap-1"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── MOCK DATA ────────────────────────────────────────────────────────────────
|
||||
|
||||
const scope = [
|
||||
{ path: 'at_color', domain: { kind: 'option' }, label: 'Color', values: ['rojo','azul','negro','blanco'] },
|
||||
{ path: 'at_acabado', domain: { kind: 'option' }, label: 'Acabado', values: ['base','sport','lujo'] },
|
||||
{ path: 'at_precio', domain: { kind: 'number' }, label: 'Precio', values: [] },
|
||||
{ path: 'at_activo', domain: { kind: 'boolean' }, label: 'Activo', values: ['true','false'] },
|
||||
{ path: 'at_entrega', domain: { kind: 'date' }, label: 'Entrega', values: [] },
|
||||
{ path: 'sc_motor/at_fuel', domain: { kind: 'option' }, label: 'Combustible', values: ['gasolina','diesel','electrico'] },
|
||||
{ path: 'sc_motor/at_cv', domain: { kind: 'number' }, label: 'CV', values: [] },
|
||||
];
|
||||
|
||||
const OPS_BY_DOMAIN = {
|
||||
string: ['==', '!='],
|
||||
number: ['==', '!=', '>', '<', '>=', '<='],
|
||||
boolean: ['==', '!='],
|
||||
option: ['==', '!='],
|
||||
currency:['==', '!=', '>', '<', '>=', '<='],
|
||||
date: ['==', '!=', 'date-before', 'date-after', 'date-between'],
|
||||
time: ['==', '!=', 'date-before', 'date-after', 'date-between'],
|
||||
datetime:['==', '!=', 'date-before', 'date-after', 'date-between'],
|
||||
};
|
||||
|
||||
const OP_LABELS = {
|
||||
'==': 'igual a', '!=': 'distinto de',
|
||||
'>': 'mayor que', '<': 'menor que',
|
||||
'>=': 'mayor o igual que', '<=': 'menor o igual que',
|
||||
'date-before': 'antes de', 'date-after': 'después de', 'date-between': 'entre',
|
||||
};
|
||||
|
||||
const DOMAIN_TAGS = {
|
||||
string: 'tag-string', number: 'tag-number', boolean: 'tag-boolean',
|
||||
date: 'tag-date', option: 'tag-option', currency: 'tag-number',
|
||||
};
|
||||
|
||||
// ─── STATE ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let idCounter = 0;
|
||||
const newId = () => `n${++idCounter}`;
|
||||
|
||||
function makeLogic(op = 'and', children = []) {
|
||||
return { kind: 'logic', id: newId(), op, children };
|
||||
}
|
||||
|
||||
function makeCompare() {
|
||||
return { kind: 'compare', id: newId(), op: '==', left: { kind: 'var', path: '' }, right: { kind: 'literal', value: '' } };
|
||||
}
|
||||
|
||||
let root = makeLogic('and', []);
|
||||
|
||||
// ─── RENDER ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function render() {
|
||||
// Scope list
|
||||
const scopeList = document.getElementById('scope-list');
|
||||
scopeList.innerHTML = scope.map(e => `
|
||||
<div class="scope-item">
|
||||
<span class="mono text-gray-300">${e.path}</span>
|
||||
<span class="tag-domain ${DOMAIN_TAGS[e.domain.kind] || 'tag-string'}">${e.domain.kind}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Tree
|
||||
document.getElementById('condition-tree').innerHTML = renderNode(root, 0);
|
||||
|
||||
// Compiled
|
||||
const compiled = compile(root);
|
||||
const errors = validate(root);
|
||||
const taut = tautology(root);
|
||||
|
||||
document.getElementById('compiled-output').textContent =
|
||||
JSON.stringify(compiled, null, 2);
|
||||
|
||||
// Tautology
|
||||
const tw = document.getElementById('tautology-warning');
|
||||
if (taut === 'always-true') {
|
||||
tw.innerHTML = `<div class="tautology-true animate-in">⚠ Esta condición siempre es <strong>verdadera</strong></div>`;
|
||||
} else if (taut === 'always-false') {
|
||||
tw.innerHTML = `<div class="tautology-false animate-in">⚠ Esta condición siempre es <strong>falsa</strong></div>`;
|
||||
} else {
|
||||
tw.innerHTML = '';
|
||||
}
|
||||
|
||||
// Status
|
||||
const badge = document.getElementById('status-badge');
|
||||
const valid = errors.length === 0;
|
||||
badge.className = `status-badge ${valid ? 'valid-badge' : 'invalid-badge'}`;
|
||||
badge.textContent = valid ? '✓ válida' : `✕ ${errors.length} error${errors.length > 1 ? 'es' : ''}`;
|
||||
|
||||
// Errors panel
|
||||
const ep = document.getElementById('errors-panel');
|
||||
if (errors.length > 0) {
|
||||
ep.style.display = '';
|
||||
document.getElementById('errors-list').innerHTML =
|
||||
errors.map(e => `<p class="error-msg">• ${e.message}</p>`).join('');
|
||||
} else {
|
||||
ep.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderNode(node, depth) {
|
||||
if (node.kind === 'logic') return renderLogic(node, depth);
|
||||
return renderCompare(node);
|
||||
}
|
||||
|
||||
function renderLogic(node, depth) {
|
||||
const children = node.children.map(c => renderNode(c, depth + 1)).join('');
|
||||
const removeBtn = depth > 0
|
||||
? `<button class="btn btn-remove" onclick="removeNode('${node.id}')">✕</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="node-logic animate-in">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:${node.children.length ? '8px' : '0'}">
|
||||
<select class="op-badge" style="border:none;cursor:pointer"
|
||||
onchange="setLogicOp('${node.id}', this.value)">
|
||||
<option value="and" ${node.op === 'and' ? 'selected' : ''}>AND — todas</option>
|
||||
<option value="or" ${node.op === 'or' ? 'selected' : ''}>OR — alguna</option>
|
||||
</select>
|
||||
<span style="color:#555;font-size:12px">se cumplen</span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px">
|
||||
<button class="btn btn-add" onclick="addCompare('${node.id}')">+ Condición</button>
|
||||
<button class="btn btn-group" onclick="addGroup('${node.id}')">+ Grupo</button>
|
||||
${removeBtn}
|
||||
</div>
|
||||
</div>
|
||||
${node.children.length === 0
|
||||
? `<p style="color:#444;font-size:12px;font-style:italic;padding:4px 0">Sin condiciones — añade una arriba</p>`
|
||||
: `<div class="children-indent">${children}</div>`
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCompare(node) {
|
||||
const varOps = node.left.path ? (OPS_BY_DOMAIN[getScopeEntry(node.left.path)?.domain.kind] ?? ['==','!=']) : ['==','!='];
|
||||
const entry = getScopeEntry(node.left.path);
|
||||
const vals = entry?.values ?? [];
|
||||
const error = validateNode(node);
|
||||
|
||||
const varSelect = `
|
||||
<select style="flex:1;min-width:0" onchange="setLeft('${node.id}', this.value)">
|
||||
<option value="" disabled ${!node.left.path ? 'selected' : ''}>— Variable —</option>
|
||||
${scope.map(e => `<option value="${e.path}" ${node.left.path === e.path ? 'selected' : ''}>${e.path}</option>`).join('')}
|
||||
</select>`;
|
||||
|
||||
const opSelect = `
|
||||
<select onchange="setOp('${node.id}', this.value)">
|
||||
${varOps.map(op => `<option value="${op}" ${node.op === op ? 'selected' : ''}>${OP_LABELS[op] ?? op}</option>`).join('')}
|
||||
</select>`;
|
||||
|
||||
const rightInput = node.op === 'date-between'
|
||||
? `<input type="text" placeholder="desde" value="${node.rightLo ?? ''}" style="width:90px"
|
||||
oninput="setRightBetween('${node.id}', 'lo', this.value)">
|
||||
<span style="color:#555;font-size:12px">y</span>
|
||||
<input type="text" placeholder="hasta" value="${node.rightHi ?? ''}" style="width:90px"
|
||||
oninput="setRightBetween('${node.id}', 'hi', this.value)">`
|
||||
: vals.length > 0
|
||||
? `<select onchange="setRight('${node.id}', this.value)">
|
||||
<option value="" disabled ${!node.right.value ? 'selected' : ''}>— Valor —</option>
|
||||
${vals.map(v => `<option value="${v}" ${node.right.value === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>`
|
||||
: `<input type="text" placeholder="valor" value="${node.right.value ?? ''}" style="flex:1;min-width:80px"
|
||||
oninput="setRight('${node.id}', this.value)">`;
|
||||
|
||||
return `
|
||||
<div class="flex flex-col gap-1 animate-in">
|
||||
<div class="node-compare" style="border-color:${error ? '#4a2a2a' : '#2a2a2a'};background:${error ? '#1e1414' : '#1a1a1a'}">
|
||||
${varSelect}
|
||||
${opSelect}
|
||||
${rightInput}
|
||||
<button class="btn btn-remove" style="padding:4px 6px" onclick="removeNode('${node.id}')">✕</button>
|
||||
</div>
|
||||
${error ? `<p class="error-msg" style="padding-left:4px">• ${error}</p>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── MUTATIONS ────────────────────────────────────────────────────────────────
|
||||
|
||||
function addCompare(parentId) {
|
||||
root = addChild(root, parentId, makeCompare());
|
||||
render();
|
||||
}
|
||||
|
||||
function addGroup(parentId) {
|
||||
root = addChild(root, parentId, makeLogic('and', []));
|
||||
render();
|
||||
}
|
||||
|
||||
function removeNode(id) {
|
||||
root = remove(root, id) ?? makeLogic('and', []);
|
||||
render();
|
||||
}
|
||||
|
||||
function setLogicOp(id, op) {
|
||||
root = updateNode(root, id, n => ({ ...n, op }));
|
||||
render();
|
||||
}
|
||||
|
||||
function setLeft(id, path) {
|
||||
root = updateNode(root, id, n => {
|
||||
const ops = OPS_BY_DOMAIN[getScopeEntry(path)?.domain.kind] ?? ['==','!='];
|
||||
const op = ops.includes(n.op) ? n.op : ops[0];
|
||||
return { ...n, op, left: { kind: 'var', path } };
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function setOp(id, op) {
|
||||
root = updateNode(root, id, n => ({ ...n, op }));
|
||||
render();
|
||||
}
|
||||
|
||||
function setRight(id, value) {
|
||||
root = updateNode(root, id, n => ({ ...n, right: { kind: 'literal', value } }));
|
||||
render();
|
||||
}
|
||||
|
||||
function setRightBetween(id, side, value) {
|
||||
root = updateNode(root, id, n => ({
|
||||
...n,
|
||||
rightLo: side === 'lo' ? value : (n.rightLo ?? ''),
|
||||
rightHi: side === 'hi' ? value : (n.rightHi ?? ''),
|
||||
}));
|
||||
render();
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
root = makeLogic('and', []);
|
||||
render();
|
||||
}
|
||||
|
||||
// ─── TREE HELPERS ─────────────────────────────────────────────────────────────
|
||||
|
||||
function addChild(node, parentId, child) {
|
||||
if (node.id === parentId && node.kind === 'logic')
|
||||
return { ...node, children: [...node.children, child] };
|
||||
if (node.kind === 'logic')
|
||||
return { ...node, children: node.children.map(c => addChild(c, parentId, child)) };
|
||||
return node;
|
||||
}
|
||||
|
||||
function remove(node, id) {
|
||||
if (node.id === id) return null;
|
||||
if (node.kind === 'logic') {
|
||||
const children = node.children.map(c => remove(c, id)).filter(Boolean);
|
||||
if (children.length === 0 && node !== root) return null;
|
||||
return { ...node, children };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function updateNode(node, id, fn) {
|
||||
if (node.id === id) return fn(node);
|
||||
if (node.kind === 'logic')
|
||||
return { ...node, children: node.children.map(c => updateNode(c, id, fn)) };
|
||||
return node;
|
||||
}
|
||||
|
||||
function getScopeEntry(path) {
|
||||
return scope.find(e => e.path === path);
|
||||
}
|
||||
|
||||
// ─── COMPILE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function compile(node) {
|
||||
if (node.kind === 'logic') {
|
||||
return { [node.op]: node.children.map(compile) };
|
||||
}
|
||||
const left = node.left.path ? { var: node.left.path } : node.left.value;
|
||||
if (node.op === 'date-between') {
|
||||
return { 'date-between': [left, node.rightLo ?? '', node.rightHi ?? ''] };
|
||||
}
|
||||
return { [node.op]: [left, node.right.value] };
|
||||
}
|
||||
|
||||
// ─── VALIDATE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function validateNode(node) {
|
||||
if (node.kind !== 'compare') return null;
|
||||
if (!node.left.path) return 'Selecciona una variable';
|
||||
const entry = getScopeEntry(node.left.path);
|
||||
if (!entry) return `Variable "${node.left.path}" no encontrada`;
|
||||
const ops = OPS_BY_DOMAIN[entry.domain.kind] ?? ['==','!='];
|
||||
if (!ops.includes(node.op)) return `Operador "${node.op}" no válido para ${entry.domain.kind}`;
|
||||
if (node.op !== 'date-between' && node.right.value === '') return 'Introduce un valor';
|
||||
return null;
|
||||
}
|
||||
|
||||
function validate(node) {
|
||||
if (node.kind === 'logic') {
|
||||
if (node.children.length === 0) return [{ message: `Grupo ${node.op.toUpperCase()} sin condiciones` }];
|
||||
return node.children.flatMap(validate);
|
||||
}
|
||||
const err = validateNode(node);
|
||||
return err ? [{ message: err }] : [];
|
||||
}
|
||||
|
||||
// ─── TAUTOLOGY ────────────────────────────────────────────────────────────────
|
||||
|
||||
function tautology(node) {
|
||||
if (node.kind === 'logic') {
|
||||
const results = node.children.map(tautology);
|
||||
if (node.op === 'and') {
|
||||
if (results.some(r => r === 'always-false')) return 'always-false';
|
||||
if (results.every(r => r === 'always-true')) return 'always-true';
|
||||
} else {
|
||||
if (results.some(r => r === 'always-true')) return 'always-true';
|
||||
if (results.every(r => r === 'always-false')) return 'always-false';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
if (node.left.kind !== 'literal') return 'unknown';
|
||||
if (node.right.kind !== 'literal') return 'unknown';
|
||||
const a = node.left.value, b = node.right.value;
|
||||
const result = { '==': a===b, '!=': a!==b, '>': a>b, '<': a<b, '>=': a>=b, '<=': a<=b }[node.op];
|
||||
if (result === undefined) return 'unknown';
|
||||
return result ? 'always-true' : 'always-false';
|
||||
}
|
||||
|
||||
// ─── INIT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
render();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import ConditionBuilderDemo from "@/vcen/editor/comps/demos/ConditionBuilderDemo.svelte";
|
||||
import CatalogDemo from "@/vcen/editor/comps/demos/CatalogDemo.svelte";
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<main>
|
||||
|
||||
<CatalogDemo />
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
/* Smooth scroll para navegación */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts';
|
||||
import type {CompareNode, Operand} from '../lib/condition_editor.ts';
|
||||
|
||||
interface Props {
|
||||
node: CompareNode;
|
||||
store: ConditionBuilderStore;
|
||||
}
|
||||
|
||||
let { node, store }: Props = $props();
|
||||
|
||||
function opLabel(op: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'==': 'igual a', '!=': 'distinto de',
|
||||
'>': 'mayor que', '<': 'menor que',
|
||||
'>=': 'mayor o igual que', '<=': 'menor o igual que',
|
||||
'date-before': 'antes de', 'date-after': 'después de', 'date-between': 'entre',
|
||||
};
|
||||
return labels[op] ?? op;
|
||||
}
|
||||
|
||||
const error = $derived(store.errors.find(e => e.nodeId === node.id));
|
||||
|
||||
const scopeEntry = $derived(
|
||||
node.left.kind === 'var' ? store.scope.find(e => e.path === node.left.path) : undefined
|
||||
);
|
||||
|
||||
const availableOps = $derived(
|
||||
node.left.kind === 'var'
|
||||
? store.validOperatorsFor(node.left.path)
|
||||
: (['==', '!='] as const)
|
||||
);
|
||||
|
||||
// Bindings locales
|
||||
let selectedVar = $state(node.left.kind === 'var' ? node.left.path : '');
|
||||
let selectedOp = $state(node.op);
|
||||
let rightValue = $state(!Array.isArray(node.right) && node.right.kind === 'literal' ? String(node.right.value ?? '') : '');
|
||||
let rightLo = $state('');
|
||||
let rightHi = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (Array.isArray(node.right)) {
|
||||
const [lo, hi] = node.right;
|
||||
rightLo = lo.kind === 'literal' ? String(lo.value) : '';
|
||||
rightHi = hi.kind === 'literal' ? String(hi.value) : '';
|
||||
}
|
||||
});
|
||||
|
||||
function onVarChange() {
|
||||
store.setLeft(node.id, { kind: 'var', path: selectedVar });
|
||||
const ops = store.validOperatorsFor(selectedVar);
|
||||
if (!ops.includes(selectedOp as any)) {
|
||||
selectedOp = ops[0];
|
||||
store.setOperator(node.id, ops[0]);
|
||||
}
|
||||
// Reset right al cambiar variable
|
||||
rightValue = '';
|
||||
store.setRight(node.id, { kind: 'literal', value: '' });
|
||||
}
|
||||
|
||||
function onOpChange() {
|
||||
store.setOperator(node.id, selectedOp as any);
|
||||
}
|
||||
|
||||
let inputError = $state('');
|
||||
|
||||
function onRightChange() {
|
||||
const domain = scopeEntry?.domain;
|
||||
let value: unknown = rightValue;
|
||||
|
||||
if (domain?.kind === 'number' || domain?.kind === 'currency') {
|
||||
if (rightValue === '') { inputError = 'Introduce un n\u00famero'; return; }
|
||||
const n = Number(rightValue);
|
||||
if (isNaN(n)) { inputError = 'Debe ser un n\u00famero v\u00e1lido'; return; }
|
||||
inputError = '';
|
||||
value = n;
|
||||
} else if (domain?.kind === 'boolean') {
|
||||
inputError = '';
|
||||
value = rightValue === 'true';
|
||||
} else if (domain?.kind === 'date' || domain?.kind === 'time' || domain?.kind === 'datetime') {
|
||||
if (rightValue === '') { inputError = 'Selecciona una fecha'; return; }
|
||||
inputError = '';
|
||||
} else {
|
||||
inputError = '';
|
||||
}
|
||||
|
||||
store.setRight(node.id, { kind: 'literal', value });
|
||||
}
|
||||
|
||||
function onBetweenChange() {
|
||||
const lo: Operand = { kind: 'literal', value: rightLo };
|
||||
const hi: Operand = { kind: 'literal', value: rightHi };
|
||||
store.setRight(node.id, [lo, hi]);
|
||||
}
|
||||
|
||||
const baseSelect = `rounded border border-gray-300 bg-white px-2 py-1 text-sm
|
||||
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500`;
|
||||
const baseInput = `rounded border border-gray-300 bg-white px-2 py-1 text-sm
|
||||
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
min-w-0 flex-1`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2 rounded border px-2 py-1.5
|
||||
{error ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-white'}">
|
||||
|
||||
<!-- Variable -->
|
||||
<select bind:value={selectedVar} onchange={onVarChange} class="{baseSelect} flex-1 min-w-0">
|
||||
<option value="" disabled>— Variable —</option>
|
||||
{#each store.scope as entry (entry.path)}
|
||||
<option value={entry.path}>{entry.path}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Operador -->
|
||||
<select bind:value={selectedOp} onchange={onOpChange} class={baseSelect}>
|
||||
{#each availableOps as op}
|
||||
<option value={op}>{opLabel(op)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Valor — renderizado según dominio -->
|
||||
{#if !scopeEntry}
|
||||
<input type="text" disabled placeholder="selecciona variable" class="{baseInput} opacity-40" />
|
||||
|
||||
{:else if node.op === 'date-between'}
|
||||
<input type="date" bind:value={rightLo} oninput={onBetweenChange} class={baseInput} />
|
||||
<span class="text-xs text-gray-400 shrink-0">y</span>
|
||||
<input type="date" bind:value={rightHi} oninput={onBetweenChange} class={baseInput} />
|
||||
|
||||
{:else if scopeEntry.domain.kind === 'option'}
|
||||
{@const opts = (scopeEntry.domain as any).options ?? []}
|
||||
{#if opts.length > 0}
|
||||
<select bind:value={rightValue} onchange={onRightChange} class="{baseSelect} flex-1 min-w-0">
|
||||
<option value="" disabled>— Valor —</option>
|
||||
{#each opts as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input type="text" bind:value={rightValue} oninput={onRightChange}
|
||||
placeholder="valor" class={baseInput} />
|
||||
{/if}
|
||||
|
||||
{:else if scopeEntry.domain.kind === 'boolean'}
|
||||
<select bind:value={rightValue} onchange={onRightChange} class="{baseSelect} flex-1 min-w-0">
|
||||
<option value="" disabled>— Valor —</option>
|
||||
<option value="true">Sí (true)</option>
|
||||
<option value="false">No (false)</option>
|
||||
</select>
|
||||
|
||||
{:else if scopeEntry.domain.kind === 'number' || scopeEntry.domain.kind === 'currency'}
|
||||
<input type="number" bind:value={rightValue} oninput={onRightChange}
|
||||
placeholder="0" class={baseInput} />
|
||||
|
||||
{:else if scopeEntry.domain.kind === 'date'}
|
||||
<input type="date" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
|
||||
|
||||
{:else if scopeEntry.domain.kind === 'time'}
|
||||
<input type="time" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
|
||||
|
||||
{:else if scopeEntry.domain.kind === 'datetime'}
|
||||
<input type="datetime-local" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
|
||||
|
||||
{:else}
|
||||
<input type="text" bind:value={rightValue} oninput={onRightChange}
|
||||
placeholder="valor" class={baseInput} />
|
||||
{/if}
|
||||
|
||||
<!-- Eliminar -->
|
||||
<button onclick={() => store.remove(node.id)}
|
||||
class="shrink-0 rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="Eliminar condición">✕</button>
|
||||
|
||||
</div>
|
||||
|
||||
{#if inputError}
|
||||
<p class="text-xs text-red-500 px-1">{inputError}</p>
|
||||
{:else if error}
|
||||
<p class="text-xs text-red-500 px-1">{error.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts';
|
||||
import ConditionNode from './ConditionNode.svelte';
|
||||
|
||||
interface Props {
|
||||
store: ConditionBuilderStore;
|
||||
onchange?: (compiled: unknown) => void;
|
||||
onvalid?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
let { store, onchange, onvalid }: Props = $props();
|
||||
|
||||
$effect(() => { onchange?.(store.compiled); });
|
||||
$effect(() => { onvalid?.(store.isValid); });
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
{#if store.tautology.kind === 'always-true'}
|
||||
<div class="rounded bg-amber-50 border border-amber-300 px-3 py-1.5 text-sm text-amber-700">
|
||||
⚠️ Esta condición siempre es <strong>verdadera</strong>
|
||||
</div>
|
||||
{:else if store.tautology.kind === 'always-false'}
|
||||
<div class="rounded bg-red-50 border border-red-300 px-3 py-1.5 text-sm text-red-700">
|
||||
⚠️ Esta condición siempre es <strong>falsa</strong>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConditionNode node={store.root} {store} depth={0} />
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte';
|
||||
import type { ConditionNode as ConditionNodeType } from '../lib/condition_editor';
|
||||
import LogicNodeEditor from './LogicNodeEditor.svelte';
|
||||
import CompareNodeEditor from './CompareNodeEditor.svelte';
|
||||
|
||||
interface Props {
|
||||
node: ConditionNodeType;
|
||||
store: ConditionBuilderStore;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
let { node, store, depth }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if node.kind === 'logic'}
|
||||
<LogicNodeEditor {node} {store} {depth} />
|
||||
{:else}
|
||||
<CompareNodeEditor {node} {store} />
|
||||
{/if}
|
||||
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts';
|
||||
import type {LogicNode, LogicOp} from '../lib/condition_editor.ts';
|
||||
import ConditionNode from './ConditionNode.svelte';
|
||||
|
||||
interface Props {
|
||||
node: LogicNode;
|
||||
store: ConditionBuilderStore;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
let { node, store, depth }: Props = $props();
|
||||
|
||||
let selectedOp = $state<LogicOp>(node.op);
|
||||
|
||||
function onOpChange() {
|
||||
store.setLogicOp(node.id, selectedOp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5 rounded border border-gray-200 bg-gray-50 p-2">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<select bind:value={selectedOp} onchange={onOpChange}
|
||||
class="rounded border border-gray-300 bg-white px-2 py-1 text-sm font-medium
|
||||
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="and">Y (AND)</option>
|
||||
<option value="or">O (OR)</option>
|
||||
</select>
|
||||
|
||||
<span class="text-xs text-gray-400">se cumplen</span>
|
||||
|
||||
<div class="ml-auto flex gap-1">
|
||||
<button
|
||||
onclick={() => store.addCompare(node.id)}
|
||||
class="rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 transition-colors"
|
||||
>+ Condición</button>
|
||||
|
||||
<button
|
||||
onclick={() => store.addLogic(node.id, 'and')}
|
||||
class="rounded bg-purple-50 px-2 py-1 text-xs text-purple-600 hover:bg-purple-100 transition-colors"
|
||||
>+ Grupo</button>
|
||||
|
||||
{#if depth > 0}
|
||||
<button
|
||||
onclick={() => store.remove(node.id)}
|
||||
class="rounded bg-red-50 px-2 py-1 text-xs text-red-500 hover:bg-red-100 transition-colors"
|
||||
>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if node.children.length === 0}
|
||||
<p class="text-xs text-gray-400 italic px-1">Sin condiciones — añade una arriba</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1.5 pl-3 border-l-2 border-gray-200">
|
||||
{#each node.children as child (child.id)}
|
||||
<ConditionNode node={child} {store} depth={depth + 1} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore, DomainKind } from './demo.store.svelte';
|
||||
import type { Att } from '@/vcen/lib/types';
|
||||
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte';
|
||||
import ConditionBuilder from '../ConditionBuilder.svelte';
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
let { store }: Props = $props();
|
||||
|
||||
const att = $derived(store.getSelectedAtt());
|
||||
|
||||
const DOMAIN_KINDS: { kind: DomainKind; label: string }[] = [
|
||||
{ kind: 'string', label: 'Texto' },
|
||||
{ kind: 'number', label: 'Número' },
|
||||
{ kind: 'currency', label: 'Moneda' },
|
||||
{ kind: 'boolean', label: 'Booleano' },
|
||||
{ kind: 'option', label: 'Opción' },
|
||||
{ kind: 'date', label: 'Fecha' },
|
||||
{ kind: 'time', label: 'Hora' },
|
||||
{ kind: 'datetime', label: 'Fecha y hora' },
|
||||
];
|
||||
|
||||
// Options input
|
||||
let optionsInput = $state('');
|
||||
$effect(() => {
|
||||
if (att?.value.domain.kind === 'option') {
|
||||
optionsInput = ((att.value.domain as any).options ?? []).join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
function saveOptions() {
|
||||
if (!att) return;
|
||||
const options = optionsInput.split(',').map(s => s.trim()).filter(Boolean);
|
||||
store.updateAttOptions(att.id, options);
|
||||
}
|
||||
|
||||
// Condition builder
|
||||
const obj = $derived(store.getObj());
|
||||
const cbStore = $derived(att ? createConditionBuilderStore(obj) : null);
|
||||
|
||||
$effect(() => {
|
||||
if (!att || !cbStore) return;
|
||||
if (att.rules?.rules?.length) {
|
||||
cbStore.load(att.rules.rules[0].condition);
|
||||
}
|
||||
});
|
||||
|
||||
function saveCondition() {
|
||||
if (!att || !cbStore) return;
|
||||
store.setAttRules(att.id, cbStore.isValid ? [{
|
||||
id: `rl_${att.id}` as any,
|
||||
priority: 10,
|
||||
condition: cbStore.compiled as any,
|
||||
actions: [{ type: 'attribute', target: [], state: 'hidden' }],
|
||||
}] : []);
|
||||
}
|
||||
|
||||
let name = $state('');
|
||||
$effect(() => { if (att) name = att.name; });
|
||||
|
||||
function onNameBlur() {
|
||||
if (att && name !== att.name) store.updateAttName(att.id, name);
|
||||
}
|
||||
|
||||
// Valor por defecto
|
||||
let defaultVal = $state<any>('');
|
||||
$effect(() => { if (att) defaultVal = att.value.value; });
|
||||
|
||||
function onDefaultValueChange() {
|
||||
if (!att) return;
|
||||
const kind = att.value.domain.kind;
|
||||
let coerced: unknown = defaultVal;
|
||||
if (kind === 'number' || kind === 'currency') coerced = Number(defaultVal);
|
||||
if (kind === 'boolean') coerced = defaultVal === 'true' || defaultVal === true;
|
||||
store.updateAttDefaultValue(att.id, coerced);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !att}
|
||||
<div class="flex items-center justify-center h-40 text-sm text-gray-500">
|
||||
Selecciona un atributo para editarlo
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<!-- Nombre -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">Nombre</label>
|
||||
<input type="text" bind:value={name} onblur={onNameBlur}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- Dominio -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">Dominio</label>
|
||||
<select
|
||||
value={att.value.domain.kind}
|
||||
onchange={e => store.updateAttDomain(att!.id, e.currentTarget.value as DomainKind)}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{#each DOMAIN_KINDS as { kind, label }}
|
||||
<option value={kind}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Opciones (solo si kind=option) -->
|
||||
{#if att.value.domain.kind === 'option'}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Opciones <span class="normal-case text-gray-600">(separadas por coma)</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" bind:value={optionsInput}
|
||||
placeholder="rojo, azul, verde"
|
||||
class="flex-1 rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm
|
||||
text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button onclick={saveOptions}
|
||||
class="rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 transition-colors">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each (att.value.domain as any).options ?? [] as opt}
|
||||
<span class="px-2 py-0.5 rounded-full bg-gray-800 text-xs text-gray-300
|
||||
border border-gray-700">{opt}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Valor por defecto -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Valor por defecto
|
||||
</label>
|
||||
|
||||
{#if att.value.domain.kind === 'option'}
|
||||
<select bind:value={defaultVal} onchange={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each (att.value.domain as any).options ?? [] as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{:else if att.value.domain.kind === 'boolean'}
|
||||
<select bind:value={defaultVal} onchange={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value={true}>Sí (true)</option>
|
||||
<option value={false}>No (false)</option>
|
||||
</select>
|
||||
|
||||
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
|
||||
<input type="number" bind:value={defaultVal} onchange={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else if att.value.domain.kind === 'date'}
|
||||
<input type="date" bind:value={defaultVal} onchange={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else if att.value.domain.kind === 'time'}
|
||||
<input type="time" bind:value={defaultVal} onchange={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else if att.value.domain.kind === 'datetime'}
|
||||
<input type="datetime-local" bind:value={defaultVal} onchange={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else}
|
||||
<input type="text" bind:value={defaultVal} onblur={onDefaultValueChange}
|
||||
class="rounded border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-gray-600">
|
||||
Valor inicial cuando se carga el catálogo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Regla de visibilidad -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Visible cuando...
|
||||
</label>
|
||||
{#if att.rules?.rules?.length}
|
||||
<button onclick={() => store.setAttRules(att!.id, [])}
|
||||
class="text-xs text-red-400 hover:text-red-300 transition-colors">
|
||||
✕ Eliminar regla
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if cbStore}
|
||||
<div class="rounded border border-gray-700 bg-gray-900 p-3">
|
||||
<ConditionBuilder store={cbStore} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-gray-500">
|
||||
Si se cumple → atributo <strong class="text-gray-400">visible</strong>
|
||||
</p>
|
||||
<button onclick={saveCondition}
|
||||
class="rounded px-3 py-1.5 text-sm transition-colors
|
||||
{cbStore.isValid
|
||||
? 'bg-green-700 text-white hover:bg-green-600'
|
||||
: 'bg-gray-700 text-gray-500 cursor-not-allowed'}">
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { createDemoStore } from './demo.store.svelte';
|
||||
import AttEditor from './AttEditor.svelte';
|
||||
import RuntimePanel from './RuntimePanel.svelte';
|
||||
import GraphPanel from './GraphPanel.svelte';
|
||||
import type { DomainKind } from './demo.store.svelte';
|
||||
|
||||
const store = createDemoStore();
|
||||
|
||||
const DOMAIN_KINDS: { kind: DomainKind; label: string; color: string }[] = [
|
||||
{ kind: 'string', label: 'Texto', color: 'bg-gray-700' },
|
||||
{ kind: 'number', label: 'Número', color: 'bg-blue-900' },
|
||||
{ kind: 'boolean', label: 'Bool', color: 'bg-purple-900' },
|
||||
{ kind: 'option', label: 'Opción', color: 'bg-green-900' },
|
||||
{ kind: 'date', label: 'Fecha', color: 'bg-amber-900' },
|
||||
{ kind: 'currency', label: 'Moneda', color: 'bg-teal-900' },
|
||||
];
|
||||
|
||||
const PANELS = [
|
||||
{ id: 'editor', label: 'Editor' },
|
||||
{ id: 'runtime', label: 'Runtime' },
|
||||
{ id: 'graph', label: 'Grafo' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100 flex flex-col">
|
||||
|
||||
<!-- Top bar -->
|
||||
<header class="border-b border-gray-800 px-6 py-3 flex items-center gap-4">
|
||||
<h1 class="text-sm font-semibold tracking-tight text-gray-200">
|
||||
Catálogo Demo
|
||||
</h1>
|
||||
<span class="text-gray-700">|</span>
|
||||
<span class="text-xs text-gray-500 font-mono">{store.cat.id}</span>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="ml-auto flex gap-1">
|
||||
{#each PANELS as panel}
|
||||
<button
|
||||
onclick={() => store.activePanel = panel.id}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{store.activePanel === panel.id
|
||||
? 'bg-gray-700 text-gray-100'
|
||||
: 'text-gray-500 hover:text-gray-300'}"
|
||||
>
|
||||
{panel.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
{#if store.session}
|
||||
<span class="text-xs px-2 py-1 rounded border
|
||||
{store.session.isValid()
|
||||
? 'border-green-900 bg-green-950 text-green-400'
|
||||
: 'border-red-900 bg-red-950 text-red-400'}">
|
||||
{store.session.isValid() ? '✓ válido' : '✕ inválido'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs px-2 py-1 rounded border border-red-900 bg-red-950 text-red-400">
|
||||
⚠ error
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
<!-- Sidebar — lista de atributos -->
|
||||
<aside class="w-60 border-r border-gray-800 flex flex-col">
|
||||
|
||||
<div class="px-4 py-3 border-b border-gray-800">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">
|
||||
Atributos
|
||||
</p>
|
||||
<!-- Botones añadir -->
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
{#each DOMAIN_KINDS as { kind, label, color }}
|
||||
<button
|
||||
onclick={() => store.addAtt(kind)}
|
||||
class="rounded px-1.5 py-1 text-xs text-gray-300 transition-colors
|
||||
hover:text-white {color} hover:brightness-125"
|
||||
title="Añadir atributo {label}"
|
||||
>
|
||||
+ {label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
{#each store.getAtts() as att (att.id)}
|
||||
{@const es = store.session?.getEffectiveState([store.cat.id, store.getObj().id, att.id] as any)}
|
||||
<button
|
||||
onclick={() => { store.selectedAttId = att.id; store.activePanel = 'editor'; }}
|
||||
class="w-full text-left px-4 py-2.5 flex items-center justify-between
|
||||
transition-colors hover:bg-gray-800
|
||||
{store.selectedAttId === att.id ? 'bg-gray-800 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm text-gray-200 truncate
|
||||
{es?.state === 'hidden' ? 'line-through text-gray-600' : ''}">
|
||||
{att.name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 font-mono truncate">{att.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0 ml-2">
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
|
||||
{att.value.domain.kind}
|
||||
</span>
|
||||
{#if att.rules?.rules?.length}
|
||||
<span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Botón eliminar -->
|
||||
<div class="px-4 pb-1 flex justify-end">
|
||||
<button
|
||||
onclick={() => store.removeAtt(att.id)}
|
||||
class="text-xs text-gray-700 hover:text-red-400 transition-colors"
|
||||
>
|
||||
eliminar
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-4 py-3 text-xs text-gray-600 italic">
|
||||
Añade atributos con los botones de arriba
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- Panel principal -->
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
|
||||
{#if store.activePanel === 'editor'}
|
||||
<div class="max-w-xl">
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
|
||||
Editor de atributo
|
||||
</h2>
|
||||
<AttEditor {store} />
|
||||
</div>
|
||||
|
||||
{:else if store.activePanel === 'runtime'}
|
||||
<div class="max-w-lg">
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
|
||||
Runtime — prueba el motor
|
||||
</h2>
|
||||
<RuntimePanel {store} />
|
||||
</div>
|
||||
|
||||
{:else if store.activePanel === 'graph'}
|
||||
<div class="max-w-2xl">
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
|
||||
Grafo de dependencias
|
||||
</h2>
|
||||
<GraphPanel {store} />
|
||||
</div>
|
||||
|
||||
{:else if store.activePanel === 'json'}
|
||||
<div class="max-w-2xl">
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
|
||||
Cat JSON
|
||||
</h2>
|
||||
<pre class="rounded border border-gray-800 bg-gray-900 p-4
|
||||
text-xs font-mono text-gray-400 overflow-auto leading-relaxed">
|
||||
{JSON.stringify(store.cat, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte.js';
|
||||
import ConditionBuilder from '../ConditionBuilder.svelte';
|
||||
import type { Obj } from '@/vcen/lib/types';
|
||||
import type { JsonLogicValue } from '@/jslg';
|
||||
|
||||
// ── Mock Obj con atributos de ejemplo ────────────────────────────────────
|
||||
|
||||
const obj: Obj = {
|
||||
id: 'ob_bmw' as Obj['id'],
|
||||
name: 'BMW 430i',
|
||||
atts: [
|
||||
{
|
||||
id: 'at_color' as any,
|
||||
name: 'Color',
|
||||
value: { domain: { kind: 'option', options: ['rojo', 'azul', 'negro', 'blanco'] } as any, value: 'rojo' },
|
||||
},
|
||||
{
|
||||
id: 'at_acabado' as any,
|
||||
name: 'Acabado',
|
||||
value: { domain: { kind: 'option', options: ['base', 'sport', 'lujo'] } as any, value: 'base' },
|
||||
},
|
||||
{
|
||||
id: 'at_precio' as any,
|
||||
name: 'Precio',
|
||||
value: { domain: { kind: 'number' } as any, value: 0 },
|
||||
},
|
||||
{
|
||||
id: 'at_activo' as any,
|
||||
name: 'Activo',
|
||||
value: { domain: { kind: 'boolean' } as any, value: true },
|
||||
},
|
||||
{
|
||||
id: 'at_entrega' as any,
|
||||
name: 'Fecha entrega',
|
||||
value: { domain: { kind: 'date' } as any, value: '' },
|
||||
},
|
||||
],
|
||||
secs: [
|
||||
{
|
||||
id: 'sc_motor' as any,
|
||||
name: 'Motor',
|
||||
atts: [
|
||||
{
|
||||
id: 'at_fuel' as any,
|
||||
name: 'Combustible',
|
||||
value: { domain: { kind: 'option', options: ['gasolina', 'diesel', 'electrico'] } as any, value: 'gasolina' },
|
||||
},
|
||||
{
|
||||
id: 'at_cv' as any,
|
||||
name: 'CV',
|
||||
value: { domain: { kind: 'number' } as any, value: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = createConditionBuilderStore(obj);
|
||||
|
||||
// ── Estado local ──────────────────────────────────────────────────────────
|
||||
|
||||
let compiled = $state<JsonLogicValue>(store.compiled);
|
||||
let isValid = $state(store.isValid);
|
||||
|
||||
function onchange(value: unknown) {
|
||||
compiled = value as JsonLogicValue;
|
||||
}
|
||||
|
||||
function onvalid(valid: boolean) {
|
||||
isValid = valid;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ── Layout ──────────────────────────────────────────────────────────────── -->
|
||||
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100 p-6">
|
||||
<div class="max-w-5xl mx-auto flex flex-col gap-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold tracking-tight">ConditionBuilder</h1>
|
||||
<p class="text-sm text-gray-500">Demo — {obj.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs px-2.5 py-1 rounded font-medium
|
||||
{isValid
|
||||
? 'bg-green-950 text-green-400 border border-green-900'
|
||||
: 'bg-red-950 text-red-400 border border-red-900'}">
|
||||
{isValid ? '✓ válida' : '✕ inválida'}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => store.reset()}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700
|
||||
text-gray-400 hover:border-gray-500 hover:text-gray-200
|
||||
transition-colors"
|
||||
>
|
||||
↺ Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-[1fr_300px] gap-4 items-start">
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
||||
<ConditionBuilder {store} {onchange} {onvalid} />
|
||||
</div>
|
||||
|
||||
<!-- Panel derecho -->
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Scope -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider">Variables</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each store.scope as entry (entry.path)}
|
||||
<div class="flex items-center justify-between px-2 py-1.5
|
||||
rounded bg-gray-800 border border-gray-700">
|
||||
<span class="text-xs font-mono text-gray-300">{entry.path}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded
|
||||
{entry.domain.kind === 'number' ? 'bg-blue-950 text-blue-400' :
|
||||
entry.domain.kind === 'boolean' ? 'bg-purple-950 text-purple-400' :
|
||||
entry.domain.kind === 'date' ? 'bg-amber-950 text-amber-400' :
|
||||
'bg-green-950 text-green-400'}">
|
||||
{entry.domain.kind}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider">JsonLogicValue</p>
|
||||
<pre class="text-xs font-mono text-gray-400 bg-gray-950 rounded p-3
|
||||
overflow-auto max-h-64 leading-relaxed">{JSON.stringify(compiled, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Errores -->
|
||||
{#if store.errors.length > 0}
|
||||
<div class="bg-gray-900 border border-red-900 rounded-lg p-4 flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-red-400 uppercase tracking-wider">Errores</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each store.errors as error}
|
||||
<p class="text-xs text-red-400">• {error.message}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore } from './demo.store.svelte';
|
||||
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph';
|
||||
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
let { store }: Props = $props();
|
||||
|
||||
const graph = $derived(store.graph);
|
||||
const atts = $derived(store.getAtts());
|
||||
const catId = $derived(store.cat.id);
|
||||
const objId = $derived(store.getObj().id);
|
||||
|
||||
function attKey(attId: string) {
|
||||
return `${catId}/${objId}/${attId}`;
|
||||
}
|
||||
|
||||
function getDeps(attId: string) {
|
||||
if (!graph) return [];
|
||||
return getDependencies(graph, [catId, objId, attId] as any)
|
||||
.map(p => p[p.length - 1]); // último segmento = attId
|
||||
}
|
||||
|
||||
function getAffected(attId: string) {
|
||||
if (!graph) return [];
|
||||
return getTransitiveAffected(graph, [catId, objId, attId] as any)
|
||||
.map(p => p[p.length - 1]);
|
||||
}
|
||||
|
||||
function attName(attId: string) {
|
||||
return atts.find(a => a.id === attId)?.name ?? attId;
|
||||
}
|
||||
|
||||
let hoveredId = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
{#if !graph}
|
||||
<div class="rounded border border-red-900 bg-red-950 px-4 py-3 text-sm text-red-400">
|
||||
⚠ {store.graphError ?? 'Grafo no disponible'}
|
||||
</div>
|
||||
{:else if atts.length === 0}
|
||||
<p class="text-sm text-gray-500">Añade atributos para ver el grafo</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
Pasa el cursor sobre un atributo para ver sus dependencias
|
||||
</p>
|
||||
|
||||
{#each atts as att (att.id)}
|
||||
{@const deps = getDeps(att.id)}
|
||||
{@const affected = getAffected(att.id)}
|
||||
{@const isHovered = hoveredId === att.id}
|
||||
{@const isDep = hoveredId ? getDeps(hoveredId).includes(att.id) : false}
|
||||
{@const isAffected = hoveredId ? getAffected(hoveredId).includes(att.id) : false}
|
||||
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onmouseenter={() => hoveredId = att.id}
|
||||
onmouseleave={() => hoveredId = null}
|
||||
class="rounded border p-3 transition-all cursor-default
|
||||
{isHovered ? 'border-blue-600 bg-blue-950' :
|
||||
isDep ? 'border-amber-700 bg-amber-950' :
|
||||
isAffected ? 'border-green-700 bg-green-950' :
|
||||
'border-gray-800 bg-gray-900'}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium
|
||||
{isHovered ? 'text-blue-300' :
|
||||
isDep ? 'text-amber-300' :
|
||||
isAffected ? 'text-green-300' :
|
||||
'text-gray-200'}">
|
||||
{att.name}
|
||||
</span>
|
||||
<span class="font-mono text-xs text-gray-600">{att.id}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if deps.length > 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-amber-950 text-amber-400 border border-amber-900">
|
||||
depende de {deps.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if affected.length > 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-green-950 text-green-400 border border-green-900">
|
||||
afecta a {affected.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if deps.length === 0 && affected.length === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-600 border border-gray-700">
|
||||
independiente
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isHovered}
|
||||
<div class="mt-2 flex flex-col gap-1.5 text-xs">
|
||||
{#if deps.length > 0}
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<span class="text-amber-500">← depende de:</span>
|
||||
{#each deps as depId}
|
||||
<span class="px-1.5 py-0.5 rounded bg-amber-950 text-amber-300 border border-amber-900">
|
||||
{attName(depId)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if affected.length > 0}
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<span class="text-green-500">→ afecta a:</span>
|
||||
{#each affected as affId}
|
||||
<span class="px-1.5 py-0.5 rounded bg-green-950 text-green-300 border border-green-900">
|
||||
{attName(affId)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if att.rules?.rules?.length}
|
||||
<div class="text-gray-500">
|
||||
{att.rules.rules.length} regla{att.rules.rules.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Leyenda -->
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<div class="w-3 h-3 rounded border border-blue-600 bg-blue-950"></div>
|
||||
seleccionado
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<div class="w-3 h-3 rounded border border-amber-700 bg-amber-950"></div>
|
||||
input (del que depende)
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<div class="w-3 h-3 rounded border border-green-700 bg-green-950"></div>
|
||||
afectado
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore } from './demo.store.svelte';
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
let { store }: Props = $props();
|
||||
|
||||
const session = $derived(store.session);
|
||||
const result = $derived(store.result); // ← reactivo via subscribe
|
||||
const atts = $derived(store.getAtts());
|
||||
const catId = $derived(store.cat.id);
|
||||
const objId = $derived(store.getObj().id);
|
||||
|
||||
function attPath(attId: string) {
|
||||
return [catId, objId, attId] as any;
|
||||
}
|
||||
|
||||
function getEs(attId: string) {
|
||||
return result?.effectiveState.get(`${catId}/${objId}/${attId}`);
|
||||
}
|
||||
|
||||
function getValue(attId: string) {
|
||||
const es = getEs(attId);
|
||||
return es?.derivedValue?.value ?? atts.find(a => a.id === attId)?.value.value;
|
||||
}
|
||||
|
||||
function onChange(attId: string, value: unknown) {
|
||||
session?.setValue(attPath(attId), value);
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
return {
|
||||
available: 'bg-green-950 text-green-400 border-green-900',
|
||||
hidden: 'bg-gray-800 text-gray-500 border-gray-700',
|
||||
required: 'bg-blue-950 text-blue-400 border-blue-900',
|
||||
forbidden: 'bg-red-950 text-red-400 border-red-900',
|
||||
}[state] ?? 'bg-gray-800 text-gray-400 border-gray-700';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !session || !result}
|
||||
<div class="rounded border border-red-900 bg-red-950 px-4 py-3 text-sm text-red-400">
|
||||
⚠ {store.graphError ?? 'Error al crear la sesión'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
<!-- Estado global -->
|
||||
<div class="flex items-center justify-between px-3 py-2 rounded border
|
||||
{result.valid
|
||||
? 'border-green-900 bg-green-950'
|
||||
: 'border-red-900 bg-red-950'}">
|
||||
<span class="text-sm font-medium
|
||||
{result.valid ? 'text-green-400' : 'text-red-400'}">
|
||||
{result.valid ? '✓ Configuración válida' : '✕ Configuración inválida'}
|
||||
</span>
|
||||
<button onclick={() => session!.reset()}
|
||||
class="text-xs text-gray-500 hover:text-gray-300 transition-colors">
|
||||
↺ Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Atributos -->
|
||||
{#each atts as att (att.id)}
|
||||
{@const es = getEs(att.id)}
|
||||
{@const currentValue = getValue(att.id)}
|
||||
{@const domain = att.value.domain}
|
||||
|
||||
<div class="rounded border border-gray-800 bg-gray-900 p-3 flex flex-col gap-2
|
||||
{es?.state === 'hidden' ? 'opacity-40' : ''}">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-200">{att.name}</span>
|
||||
<span class="text-xs text-gray-600 font-mono">{att.id}</span>
|
||||
</div>
|
||||
{#if es}
|
||||
<span class="text-xs px-2 py-0.5 rounded border {stateBadge(es.state)}">
|
||||
{es.state}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input según dominio -->
|
||||
{#if es?.state !== 'hidden' && es?.state !== 'forbidden'}
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
{#if domain.kind === 'boolean'}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
checked={currentValue as boolean}
|
||||
onchange={e => onChange(att.id, e.currentTarget.checked)}
|
||||
class="rounded border-gray-600" />
|
||||
<span class="text-sm text-gray-300">
|
||||
{currentValue ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{:else if domain.kind === 'option'}
|
||||
<select
|
||||
value={currentValue as string}
|
||||
onchange={e => onChange(att.id, e.currentTarget.value)}
|
||||
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
|
||||
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{#each (domain as any).options ?? [] as opt}
|
||||
<option value={opt}
|
||||
disabled={es?.forbiddenValues?.includes(opt)}>
|
||||
{opt}{es?.forbiddenValues?.includes(opt) ? ' ✕' : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{:else if domain.kind === 'number' || domain.kind === 'currency'}
|
||||
<input type="number"
|
||||
value={currentValue as number}
|
||||
oninput={e => onChange(att.id, Number(e.currentTarget.value))}
|
||||
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
|
||||
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else if domain.kind === 'date'}
|
||||
<input type="date"
|
||||
value={currentValue as string}
|
||||
oninput={e => onChange(att.id, e.currentTarget.value)}
|
||||
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
|
||||
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else if domain.kind === 'time'}
|
||||
<input type="time"
|
||||
value={currentValue as string}
|
||||
oninput={e => onChange(att.id, e.currentTarget.value)}
|
||||
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
|
||||
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{:else}
|
||||
<input type="text"
|
||||
value={currentValue as string}
|
||||
oninput={e => onChange(att.id, e.currentTarget.value)}
|
||||
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
|
||||
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
{/if}
|
||||
|
||||
{#if es?.derivedValue !== undefined}
|
||||
<span class="text-xs text-amber-400 shrink-0">
|
||||
⟵ derivado: {JSON.stringify(es.derivedValue.value)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{:else if es?.state === 'forbidden'}
|
||||
<p class="text-xs text-red-400">Este atributo no está disponible</p>
|
||||
{/if}
|
||||
|
||||
<!-- Mensajes -->
|
||||
{#if es?.messages?.length}
|
||||
{#each es.messages as msg}
|
||||
<p class="text-xs {msg.level === 'error' ? 'text-red-400' : 'text-amber-400'}">
|
||||
{msg.level === 'error' ? '✕' : '⚠'} {msg.message}
|
||||
</p>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,245 @@
|
||||
import { createSession } from '@/vcen/lib/session';
|
||||
import { buildDependencyGraph } from '@/vcen/lib/graph';
|
||||
import type { Cat, Obj, Att, Sec } from '@/vcen/lib/types';
|
||||
import type { Rule } from '@/vcen/lib/types';
|
||||
import type { DependencyGraph } from '@/vcen/lib/types';
|
||||
import type { Session } from '@/vcen/lib/session';
|
||||
import {evaluateCat} from "@/vcen/lib/engine.ts";
|
||||
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DomainKind = 'string' | 'number' | 'boolean' | 'option' | 'date' | 'time' | 'datetime' | 'currency';
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createDemoStore() {
|
||||
|
||||
// Cat mutable
|
||||
let cat = $state<Cat>(makeFreshCat());
|
||||
|
||||
// Runtime session — se reconstruye cuando cambia el cat
|
||||
let session = $state<Session | null>(null);
|
||||
let graphError = $state<string | null>(null);
|
||||
|
||||
// Grafo para visualización
|
||||
let graph = $state<DependencyGraph | null>(null);
|
||||
|
||||
// Resultado reactivo del motor — se actualiza con cada setValue
|
||||
let result = $state(evaluateCat(cat));
|
||||
|
||||
// Panel activo
|
||||
let activePanel = $state<'editor' | 'runtime' | 'graph' | 'json'>('editor');
|
||||
|
||||
// Att seleccionado en el editor
|
||||
let selectedAttId = $state<string | null>(null);
|
||||
|
||||
// ── Rebuild session ───────────────────────────────────────────────────────
|
||||
|
||||
function rebuildSession() {
|
||||
try {
|
||||
graph = buildDependencyGraph(cat);
|
||||
session = createSession(cat);
|
||||
result = session.getResult();
|
||||
graphError = null;
|
||||
// Suscribir para mantener result reactivo
|
||||
session.subscribe(r => { result = r; });
|
||||
} catch (e: any) {
|
||||
graphError = e.message ?? 'Error en el grafo de dependencias';
|
||||
session = null;
|
||||
graph = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cat mutations ─────────────────────────────────────────────────────────
|
||||
|
||||
function addAtt(kind: DomainKind) {
|
||||
const id = `at_${Date.now()}` as Att['id'];
|
||||
const options: string[] = kind === 'option' ? ['opcion1', 'opcion2'] : [];
|
||||
const att: Att = {
|
||||
id,
|
||||
name: 'Nuevo atributo',
|
||||
value: { domain: makeDomain(kind, options), value: defaultValue(kind, options) },
|
||||
};
|
||||
cat = {
|
||||
...cat,
|
||||
objs: cat.objs.map((o, i) => i === 0
|
||||
? { ...o, atts: [...o.atts, att] }
|
||||
: o
|
||||
),
|
||||
};
|
||||
selectedAttId = id;
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
function removeAtt(attId: string) {
|
||||
cat = {
|
||||
...cat,
|
||||
objs: cat.objs.map(o => ({
|
||||
...o,
|
||||
atts: o.atts.filter(a => a.id !== attId),
|
||||
secs: o.secs.map(s => ({
|
||||
...s,
|
||||
atts: s.atts.filter(a => a.id !== attId),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
if (selectedAttId === attId) selectedAttId = null;
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
function updateAttName(attId: string, name: string) {
|
||||
cat = updateAtt(cat, attId, a => ({ ...a, name }));
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
function updateAttDomain(attId: string, kind: DomainKind) {
|
||||
cat = updateAtt(cat, attId, a => {
|
||||
const options = kind === 'option' ? ((a.value.domain as any).options ?? []) : [];
|
||||
return {
|
||||
...a,
|
||||
value: { domain: makeDomain(kind, options), value: defaultValue(kind, options) },
|
||||
};
|
||||
});
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
function updateAttOptions(attId: string, options: string[]) {
|
||||
cat = updateAtt(cat, attId, a => {
|
||||
const currentVal = a.value.value;
|
||||
const value = options.includes(currentVal as string) ? currentVal : (options[0] ?? '');
|
||||
return {
|
||||
...a,
|
||||
value: {
|
||||
domain: { ...a.value.domain, options } as any,
|
||||
value,
|
||||
},
|
||||
};
|
||||
});
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
function updateAttDefaultValue(attId: string, value: unknown) {
|
||||
cat = updateAtt(cat, attId, a => ({
|
||||
...a,
|
||||
value: { ...a.value, value: value as any },
|
||||
}));
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
function setAttRules(attId: string, rules: Rule[]) {
|
||||
cat = updateAtt(cat, attId, a => ({
|
||||
...a,
|
||||
rules: rules.length > 0 ? { rules } : undefined,
|
||||
}));
|
||||
rebuildSession();
|
||||
}
|
||||
|
||||
// ── Runtime ───────────────────────────────────────────────────────────────
|
||||
|
||||
function setRuntimeValue(attPath: any, value: unknown) {
|
||||
session?.setValue(attPath, value);
|
||||
}
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────
|
||||
|
||||
function getAtts(): Att[] {
|
||||
return cat.objs[0]?.atts ?? [];
|
||||
}
|
||||
|
||||
function getSelectedAtt(): Att | null {
|
||||
if (!selectedAttId) return null;
|
||||
return getAtts().find(a => a.id === selectedAttId) ?? null;
|
||||
}
|
||||
|
||||
function getObj(): Obj {
|
||||
return cat.objs[0]!;
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
rebuildSession();
|
||||
|
||||
return {
|
||||
get cat() { return cat; },
|
||||
get session() { return session; },
|
||||
get result() { return result; },
|
||||
get graph() { return graph; },
|
||||
get graphError() { return graphError; },
|
||||
get activePanel() { return activePanel; },
|
||||
get selectedAttId(){ return selectedAttId; },
|
||||
|
||||
set activePanel(v: typeof activePanel) { activePanel = v; },
|
||||
set selectedAttId(v: string | null) { selectedAttId = v; },
|
||||
|
||||
getAtts,
|
||||
getSelectedAtt,
|
||||
getObj,
|
||||
addAtt,
|
||||
removeAtt,
|
||||
updateAttName,
|
||||
updateAttDomain,
|
||||
updateAttOptions,
|
||||
setAttRules,
|
||||
updateAttDefaultValue,
|
||||
setRuntimeValue,
|
||||
rebuildSession,
|
||||
};
|
||||
}
|
||||
|
||||
export type DemoStore = ReturnType<typeof createDemoStore>;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFreshCat(): Cat {
|
||||
return {
|
||||
id: 'ct_demo' as Cat['id'],
|
||||
name: 'Catálogo demo',
|
||||
atts: [],
|
||||
objs: [{
|
||||
id: 'ob_demo' as Obj['id'],
|
||||
name: 'Objeto demo',
|
||||
atts: [
|
||||
{
|
||||
id: 'at_color' as Att['id'],
|
||||
name: 'Color',
|
||||
value: { domain: { kind: 'option', options: ['rojo', 'azul', 'negro'] } as any, value: 'rojo' },
|
||||
},
|
||||
{
|
||||
id: 'at_precio' as Att['id'],
|
||||
name: 'Precio',
|
||||
value: { domain: { kind: 'number' } as any, value: 0 },
|
||||
},
|
||||
],
|
||||
secs: [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function makeDomain(kind: DomainKind, options: string[] = []): any {
|
||||
if (kind === 'option') return { kind, options };
|
||||
return { kind };
|
||||
}
|
||||
|
||||
function defaultValue(kind: DomainKind, options: string[] = []): any {
|
||||
if (kind === 'number' || kind === 'currency') return 0;
|
||||
if (kind === 'boolean') return false;
|
||||
if (kind === 'option') return options[0] ?? '';
|
||||
if (kind === 'date') return new Date().toISOString().split('T')[0];
|
||||
if (kind === 'time') return '08:00';
|
||||
if (kind === 'datetime') return new Date().toISOString().slice(0, 16);
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateAtt(cat: Cat, attId: string, fn: (a: Att) => Att): Cat {
|
||||
return {
|
||||
...cat,
|
||||
objs: cat.objs.map(o => ({
|
||||
...o,
|
||||
atts: o.atts.map(a => a.id === attId ? fn(a) : a),
|
||||
secs: o.secs.map(s => ({
|
||||
...s,
|
||||
atts: s.atts.map(a => a.id === attId ? fn(a) : a),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,412 @@
|
||||
import type { JsonLogicValue } from '@/jslg';
|
||||
import type { Att, Sec, Obj , AttDomain } from '@/vcen/lib/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONDITION TREE — representación interna del editor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type LogicOp = 'and' | 'or';
|
||||
export type CompareOp = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'date-before' | 'date-after' | 'date-between';
|
||||
|
||||
export interface LogicNode {
|
||||
kind: 'logic';
|
||||
id: string;
|
||||
op: LogicOp;
|
||||
children: ConditionNode[];
|
||||
}
|
||||
|
||||
export interface CompareNode {
|
||||
kind: 'compare';
|
||||
id: string;
|
||||
op: CompareOp;
|
||||
left: Operand;
|
||||
right: Operand | [Operand, Operand]; // date-between necesita dos bounds
|
||||
}
|
||||
|
||||
export type ConditionNode = LogicNode | CompareNode;
|
||||
|
||||
export interface VarOperand {
|
||||
kind: 'var';
|
||||
path: string; // relativo al scope: "at_color", "sc_motor/at_fuel"
|
||||
}
|
||||
|
||||
export interface LiteralOperand {
|
||||
kind: 'literal';
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export type Operand = VarOperand | LiteralOperand;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SCOPE — atts visibles desde la regla
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScopeEntry {
|
||||
path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel"
|
||||
att: Att;
|
||||
domain: AttDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el scope de variables disponibles para una regla.
|
||||
* El scope incluye todos los atts del contenedor y sus secciones hijas.
|
||||
*
|
||||
* @param container - Obj o Sec que contiene la regla
|
||||
*/
|
||||
export function buildScope(container: Obj | Sec): ScopeEntry[] {
|
||||
const entries: ScopeEntry[] = [];
|
||||
|
||||
const atts = 'atts' in container ? container.atts : [];
|
||||
for (const att of atts) {
|
||||
entries.push({ path: att.id, att, domain: att.value.domain });
|
||||
}
|
||||
|
||||
if ('secs' in container) {
|
||||
for (const sec of container.secs) {
|
||||
for (const att of sec.atts) {
|
||||
entries.push({
|
||||
path: `${sec.id}/${att.id}`,
|
||||
att,
|
||||
domain: att.value.domain,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OPERATORS — válidos según el dominio
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPERATORS_BY_DOMAIN: Record<AttDomain['kind'], CompareOp[]> = {
|
||||
string: ['==', '!='],
|
||||
number: ['==', '!=', '>', '<', '>=', '<='],
|
||||
boolean: ['==', '!='],
|
||||
currency: ['==', '!=', '>', '<', '>=', '<='],
|
||||
option: ['==', '!='],
|
||||
date: ['==', '!=', 'date-before', 'date-after', 'date-between'],
|
||||
time: ['==', '!=', 'date-before', 'date-after', 'date-between'],
|
||||
datetime: ['==', '!=', 'date-before', 'date-after', 'date-between'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Operadores válidos para un dominio dado.
|
||||
*/
|
||||
export function getValidOperators(domain: AttDomain): CompareOp[] {
|
||||
return OPERATORS_BY_DOMAIN[domain.kind] ?? ['==', '!='];
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` si el operador es compatible con el dominio.
|
||||
*/
|
||||
export function isOperatorValid(op: CompareOp, domain: AttDomain): boolean {
|
||||
return getValidOperators(domain).includes(op);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TAUTOLOGY DETECTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TautologyResult =
|
||||
| { kind: 'always-true' }
|
||||
| { kind: 'always-false' }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
/**
|
||||
* Detecta si un nodo es una tautología o contradicción evaluable en build time.
|
||||
* Solo aplica cuando ambos operandos son literales.
|
||||
*/
|
||||
export function detectTautology(node: ConditionNode): TautologyResult {
|
||||
if (node.kind === 'logic') {
|
||||
const childResults = node.children.map(detectTautology);
|
||||
|
||||
if (node.op === 'and') {
|
||||
if (childResults.some(r => r.kind === 'always-false'))
|
||||
return { kind: 'always-false' };
|
||||
if (childResults.every(r => r.kind === 'always-true'))
|
||||
return { kind: 'always-true' };
|
||||
}
|
||||
|
||||
if (node.op === 'or') {
|
||||
if (childResults.some(r => r.kind === 'always-true'))
|
||||
return { kind: 'always-true' };
|
||||
if (childResults.every(r => r.kind === 'always-false'))
|
||||
return { kind: 'always-false' };
|
||||
}
|
||||
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
// CompareNode — solo evaluable si ambos son literales
|
||||
if (node.left.kind !== 'literal') return { kind: 'unknown' };
|
||||
|
||||
if (node.op === 'date-between') {
|
||||
if (!Array.isArray(node.right)) return { kind: 'unknown' };
|
||||
if (node.right[0].kind !== 'literal' || node.right[1].kind !== 'literal')
|
||||
return { kind: 'unknown' };
|
||||
const result = evalCompare(node.op, node.left.value, [node.right[0].value, node.right[1].value]);
|
||||
return result ? { kind: 'always-true' } : { kind: 'always-false' };
|
||||
}
|
||||
|
||||
if (Array.isArray(node.right) || node.right.kind !== 'literal')
|
||||
return { kind: 'unknown' };
|
||||
|
||||
const result = evalCompare(node.op, node.left.value, node.right.value);
|
||||
return result ? { kind: 'always-true' } : { kind: 'always-false' };
|
||||
}
|
||||
|
||||
function evalCompare(op: CompareOp, a: unknown, b: unknown | [unknown, unknown]): boolean {
|
||||
switch (op) {
|
||||
case '==': return a === b;
|
||||
case '!=': return a !== b;
|
||||
case '>': return (a as number) > (b as number);
|
||||
case '<': return (a as number) < (b as number);
|
||||
case '>=': return (a as number) >= (b as number);
|
||||
case '<=': return (a as number) <= (b as number);
|
||||
case 'date-before': return String(a) < String(b);
|
||||
case 'date-after': return String(a) > String(b);
|
||||
case 'date-between': {
|
||||
const [start, end] = b as [unknown, unknown];
|
||||
return String(a) >= String(start) && String(a) <= String(end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// COMPILE — árbol → JsonLogicValue
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compila un árbol de condición a `JsonLogicValue`.
|
||||
*/
|
||||
export function compileCondition(node: ConditionNode): JsonLogicValue {
|
||||
if (node.kind === 'logic') {
|
||||
return {
|
||||
[node.op]: node.children.map(compileCondition),
|
||||
} as JsonLogicValue;
|
||||
}
|
||||
|
||||
const left = compileOperand(node.left);
|
||||
|
||||
if (node.op === 'date-between') {
|
||||
if (!Array.isArray(node.right)) throw new Error('date-between requires two bounds');
|
||||
return {
|
||||
'date-between': [left, compileOperand(node.right[0]), compileOperand(node.right[1])],
|
||||
} as JsonLogicValue;
|
||||
}
|
||||
|
||||
const right = Array.isArray(node.right)
|
||||
? compileOperand(node.right[0])
|
||||
: compileOperand(node.right);
|
||||
|
||||
return { [node.op]: [left, right] } as JsonLogicValue;
|
||||
}
|
||||
|
||||
function compileOperand(operand: Operand): JsonLogicValue {
|
||||
if (operand.kind === 'var') return { var: operand.path } as JsonLogicValue;
|
||||
return operand.value as JsonLogicValue;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARSE — JsonLogicValue → árbol
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _idCounter = 0;
|
||||
function newId(): string { return `node_${++_idCounter}`; }
|
||||
|
||||
/**
|
||||
* Parsea un `JsonLogicValue` a árbol de condición editable.
|
||||
* Útil para cargar reglas existentes en el editor.
|
||||
*/
|
||||
export function parseCondition(value: JsonLogicValue): ConditionNode {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
// Literal booleano como condición raíz — envolver en compare
|
||||
return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true });
|
||||
}
|
||||
|
||||
const op = Object.keys(value as object)[0] as string;
|
||||
const args = (value as any)[op];
|
||||
const argArr = Array.isArray(args) ? args : [args];
|
||||
|
||||
if (op === 'and' || op === 'or') {
|
||||
return {
|
||||
kind: 'logic',
|
||||
id: newId(),
|
||||
op: op as LogicOp,
|
||||
children: argArr.map(parseCondition),
|
||||
};
|
||||
}
|
||||
|
||||
if (op === 'date-between') {
|
||||
return {
|
||||
kind: 'compare',
|
||||
id: newId(),
|
||||
op: 'date-between',
|
||||
left: parseOperand(argArr[0]),
|
||||
right: [parseOperand(argArr[1]), parseOperand(argArr[2])],
|
||||
};
|
||||
}
|
||||
|
||||
if (isCompareOp(op)) {
|
||||
return {
|
||||
kind: 'compare',
|
||||
id: newId(),
|
||||
op: op as CompareOp,
|
||||
left: parseOperand(argArr[0]),
|
||||
right: parseOperand(argArr[1]),
|
||||
};
|
||||
}
|
||||
|
||||
// Operador desconocido — tratar como opaco
|
||||
return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true });
|
||||
}
|
||||
|
||||
function parseOperand(value: unknown): Operand {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value) && 'var' in value) {
|
||||
return { kind: 'var', path: (value as any).var as string };
|
||||
}
|
||||
return { kind: 'literal', value };
|
||||
}
|
||||
|
||||
function isCompareOp(op: string): op is CompareOp {
|
||||
return ['==', '!=', '>', '<', '>=', '<=', 'date-before', 'date-after', 'date-between'].includes(op);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BUILDER HELPERS — para crear nodos desde la UI
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function makeLogicNode(op: LogicOp, children: ConditionNode[] = []): LogicNode {
|
||||
return { kind: 'logic', id: newId(), op, children };
|
||||
}
|
||||
|
||||
export function makeCompareNode(
|
||||
op: CompareOp,
|
||||
left: Operand = { kind: 'literal', value: '' },
|
||||
right: Operand | [Operand, Operand] = { kind: 'literal', value: '' },
|
||||
): CompareNode {
|
||||
return { kind: 'compare', id: newId(), op, left, right };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MUTATION HELPERS — operaciones inmutables sobre el árbol
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reemplaza un nodo por `id` en el árbol. Devuelve un nuevo árbol.
|
||||
*/
|
||||
export function replaceNode(
|
||||
root: ConditionNode,
|
||||
id: string,
|
||||
newNode: ConditionNode,
|
||||
): ConditionNode {
|
||||
if (root.id === id) return newNode;
|
||||
|
||||
if (root.kind === 'logic') {
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map(c => replaceNode(c, id, newNode)),
|
||||
};
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un nodo por `id`. Si el padre queda con 0 hijos, también se elimina.
|
||||
*/
|
||||
export function removeNode(root: ConditionNode, id: string): ConditionNode | null {
|
||||
if (root.id === id) return null;
|
||||
|
||||
if (root.kind === 'logic') {
|
||||
const children = root.children
|
||||
.map(c => removeNode(c, id))
|
||||
.filter((c): c is ConditionNode => c !== null);
|
||||
|
||||
if (children.length === 0) return null;
|
||||
return { ...root, children };
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade un nodo hijo a un `LogicNode` por `id`.
|
||||
*/
|
||||
export function addChild(
|
||||
root: ConditionNode,
|
||||
parentId: string,
|
||||
child: ConditionNode,
|
||||
): ConditionNode {
|
||||
if (root.id === parentId && root.kind === 'logic') {
|
||||
return { ...root, children: [...root.children, child] };
|
||||
}
|
||||
|
||||
if (root.kind === 'logic') {
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map(c => addChild(c, parentId, child)),
|
||||
};
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConditionError {
|
||||
nodeId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida el árbol de condición contra el scope disponible.
|
||||
* Devuelve lista de errores (vacía si todo está bien).
|
||||
*/
|
||||
export function validateCondition(
|
||||
node: ConditionNode,
|
||||
scope: ScopeEntry[],
|
||||
): ConditionError[] {
|
||||
const errors: ConditionError[] = [];
|
||||
|
||||
function walk(n: ConditionNode) {
|
||||
if (n.kind === 'logic') {
|
||||
if (n.children.length === 0) {
|
||||
errors.push({ nodeId: n.id, message: `Operador "${n.op}" sin condiciones hijas` });
|
||||
}
|
||||
n.children.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar left
|
||||
if (n.left.kind === 'var') {
|
||||
const left = n.left as VarOperand;
|
||||
const entry = scope.find(e => e.path === left.path);
|
||||
if (!entry) {
|
||||
errors.push({ nodeId: n.id, message: `Variable "${left.path}" no encontrada en el scope` });
|
||||
} else if (!isOperatorValid(n.op, entry.domain)) {
|
||||
errors.push({ nodeId: n.id, message: `Operador "${n.op}" no es válido para el dominio "${entry.domain.kind}"` });
|
||||
}
|
||||
}
|
||||
|
||||
// Validar right (para date-between)
|
||||
if (n.op === 'date-between' && Array.isArray(n.right)) {
|
||||
const [lo, hi] = n.right as [Operand, Operand];
|
||||
if (lo.kind === 'var') {
|
||||
const entry = scope.find(e => e.path === lo.path);
|
||||
if (!entry) errors.push({ nodeId: n.id, message: `Variable bound inferior no encontrada` });
|
||||
}
|
||||
if (hi.kind === 'var') {
|
||||
const entry = scope.find(e => e.path === hi.path);
|
||||
if (!entry) errors.push({ nodeId: n.id, message: `Variable bound superior no encontrada` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(node);
|
||||
return errors;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
});
|
||||
|
||||
|
||||
|
||||
export default app;
|
||||
@ -0,0 +1,194 @@
|
||||
import type { JsonLogicValue } from '@/jslg';
|
||||
import type { Obj, Sec } from '@/vcen/lib/types';
|
||||
import type {
|
||||
ScopeEntry,
|
||||
ConditionNode,
|
||||
ConditionError,
|
||||
CompareOp,
|
||||
LogicOp,
|
||||
Operand
|
||||
} from '../lib/condition_editor';
|
||||
import {
|
||||
buildScope,
|
||||
compileCondition,
|
||||
parseCondition,
|
||||
validateCondition,
|
||||
detectTautology,
|
||||
makeLogicNode,
|
||||
makeCompareNode,
|
||||
replaceNode,
|
||||
removeNode,
|
||||
addChild,
|
||||
getValidOperators,
|
||||
} from '../lib/condition_editor';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adaptador Svelte 5 para el editor de condiciones JsonLogic.
|
||||
*
|
||||
* Usa runas (`$state`, `$derived`) para que los componentes Svelte
|
||||
* reaccionen automáticamente a los cambios del árbol de condición.
|
||||
*
|
||||
* ## Uso
|
||||
*
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { createConditionBuilderStore } from './condition-editor.svelte';
|
||||
*
|
||||
* const store = createConditionBuilderStore(obj, existingCondition);
|
||||
* </script>
|
||||
*
|
||||
* {#if store.tautology.kind === 'always-true'}
|
||||
* <Warning>Esta condición siempre es verdadera</Warning>
|
||||
* {/if}
|
||||
*
|
||||
* <button onclick={() => store.addCompare(store.root.id)}>
|
||||
* + Condición
|
||||
* </button>
|
||||
* ```
|
||||
*/
|
||||
export function createConditionBuilderStore(
|
||||
container: Obj | Sec,
|
||||
initial?: JsonLogicValue,
|
||||
) {
|
||||
// ── Estado reactivo ───────────────────────────────────────────────────────
|
||||
|
||||
let root = $state<ConditionNode>(
|
||||
initial
|
||||
? parseCondition(initial)
|
||||
: makeLogicNode('and', [])
|
||||
);
|
||||
|
||||
const scope = $derived<ScopeEntry[]>(buildScope(container));
|
||||
|
||||
const errors = $derived<ConditionError[]>(
|
||||
validateCondition(root, scope)
|
||||
);
|
||||
|
||||
const tautology = $derived(detectTautology(root));
|
||||
|
||||
const compiled = $derived<JsonLogicValue>(
|
||||
errors.length === 0 ? compileCondition(root) : initial ?? true
|
||||
);
|
||||
|
||||
const isValid = $derived(errors.length === 0);
|
||||
|
||||
// ── Mutaciones ────────────────────────────────────────────────────────────
|
||||
|
||||
function setRoot(node: ConditionNode) {
|
||||
root = node;
|
||||
}
|
||||
|
||||
/** Añade un CompareNode vacío como hijo del LogicNode con `parentId`. */
|
||||
function addCompare(parentId: string) {
|
||||
const child = makeCompareNode('==');
|
||||
root = addChild(root, parentId, child);
|
||||
}
|
||||
|
||||
/** Añade un LogicNode (and/or) como hijo del LogicNode con `parentId`. */
|
||||
function addLogic(parentId: string, op: LogicOp = 'and') {
|
||||
const child = makeLogicNode(op, []);
|
||||
root = addChild(root, parentId, child);
|
||||
}
|
||||
|
||||
/** Elimina el nodo con `id`. Si el árbol queda vacío, resetea a and vacío. */
|
||||
function remove(id: string) {
|
||||
const next = removeNode(root, id);
|
||||
root = next ?? makeLogicNode('and', []);
|
||||
}
|
||||
|
||||
/** Reemplaza el operador de un CompareNode. */
|
||||
function setOperator(id: string, op: CompareOp) {
|
||||
const current = findNode(root, id);
|
||||
if (!current || current.kind !== 'compare') return;
|
||||
root = replaceNode(root, id, { ...current, op });
|
||||
}
|
||||
|
||||
/** Reemplaza el operando izquierdo de un CompareNode. */
|
||||
function setLeft(id: string, operand: Operand) {
|
||||
const current = findNode(root, id);
|
||||
if (!current || current.kind !== 'compare') return;
|
||||
root = replaceNode(root, id, { ...current, left: operand });
|
||||
}
|
||||
|
||||
/** Reemplaza el operando derecho de un CompareNode. */
|
||||
function setRight(id: string, operand: Operand | [Operand, Operand]) {
|
||||
const current = findNode(root, id);
|
||||
if (!current || current.kind !== 'compare') return;
|
||||
root = replaceNode(root, id, { ...current, right: operand });
|
||||
}
|
||||
|
||||
/** Cambia el operador lógico (and ↔ or) de un LogicNode. */
|
||||
function setLogicOp(id: string, op: LogicOp) {
|
||||
const current = findNode(root, id);
|
||||
if (!current || current.kind !== 'logic') return;
|
||||
root = replaceNode(root, id, { ...current, op });
|
||||
}
|
||||
|
||||
/**
|
||||
* Operadores válidos para el att referenciado por `varPath`.
|
||||
* Útil para poblar el selector de operador en la UI.
|
||||
*/
|
||||
function validOperatorsFor(varPath: string): CompareOp[] {
|
||||
const entry = scope.find(e => e.path === varPath);
|
||||
if (!entry) return ['==', '!='];
|
||||
return getValidOperators(entry.domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga una condición existente en el editor (p.ej. al abrir una regla).
|
||||
*/
|
||||
function load(condition: JsonLogicValue) {
|
||||
root = parseCondition(condition);
|
||||
}
|
||||
|
||||
/** Resetea el editor a un LogicNode and vacío. */
|
||||
function reset() {
|
||||
root = makeLogicNode('and', []);
|
||||
}
|
||||
|
||||
// ── API pública ───────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Estado (reactivo)
|
||||
get root() { return root; },
|
||||
get scope() { return scope; },
|
||||
get errors() { return errors; },
|
||||
get tautology() { return tautology; },
|
||||
get compiled() { return compiled; },
|
||||
get isValid() { return isValid; },
|
||||
|
||||
// Mutaciones
|
||||
setRoot,
|
||||
addCompare,
|
||||
addLogic,
|
||||
remove,
|
||||
setOperator,
|
||||
setLeft,
|
||||
setRight,
|
||||
setLogicOp,
|
||||
validOperatorsFor,
|
||||
load,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type ConditionBuilderStore = ReturnType<typeof createConditionBuilderStore>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// INTERNAL HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function findNode(root: ConditionNode, id: string): ConditionNode | null {
|
||||
if (root.id === id) return root;
|
||||
if (root.kind === 'logic') {
|
||||
for (const child of root.children) {
|
||||
const found = findNode(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,426 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {asJsonCondition, type JsonLogicValue} from "@/jslg";
|
||||
import {
|
||||
buildScope,
|
||||
getValidOperators,
|
||||
isOperatorValid,
|
||||
detectTautology,
|
||||
compileCondition,
|
||||
parseCondition,
|
||||
validateCondition,
|
||||
makeLogicNode,
|
||||
makeCompareNode,
|
||||
replaceNode,
|
||||
removeNode,
|
||||
addChild,
|
||||
} from '../lib/condition_editor';
|
||||
import type { Att, Sec, Obj } from '@/vcen/lib/types';
|
||||
import type { ScopeEntry } from '../lib/condition_editor';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FIXTURES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAtt(id: string, kind: string = 'string'): Att {
|
||||
return {
|
||||
id: id as Att['id'],
|
||||
name: id,
|
||||
value: { domain: { kind } as any, value: '' },
|
||||
};
|
||||
}
|
||||
|
||||
function makeObj(id: string, atts: Att[], secs: Sec[] = []): Obj {
|
||||
return { id: id as Obj['id'], name: id, atts, secs };
|
||||
}
|
||||
|
||||
function makeSec(id: string, atts: Att[]): Sec {
|
||||
return { id: id as Sec['id'], name: id, atts };
|
||||
}
|
||||
|
||||
function varOp(path: string) { return { kind: 'var' as const, path }; }
|
||||
function litOp(value: unknown) { return { kind: 'literal' as const, value }; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildScope
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildScope', () => {
|
||||
it('incluye atts directos del Obj', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color'), makeAtt('at_acabado')]);
|
||||
const scope = buildScope(obj);
|
||||
expect(scope.map(e => e.path)).toContain('at_color');
|
||||
expect(scope.map(e => e.path)).toContain('at_acabado');
|
||||
});
|
||||
|
||||
it('incluye atts de secciones hijas con path relativo', () => {
|
||||
const sec = makeSec('sc_motor', [makeAtt('at_fuel'), makeAtt('at_cv')]);
|
||||
const obj = makeObj('ob_bmw', [], [sec]);
|
||||
const scope = buildScope(obj);
|
||||
expect(scope.map(e => e.path)).toContain('sc_motor/at_fuel');
|
||||
expect(scope.map(e => e.path)).toContain('sc_motor/at_cv');
|
||||
});
|
||||
|
||||
it('incluye tanto atts directos como de secciones', () => {
|
||||
const sec = makeSec('sc_motor', [makeAtt('at_fuel')]);
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color')], [sec]);
|
||||
const scope = buildScope(obj);
|
||||
expect(scope).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('scope de Sec solo incluye sus atts directos', () => {
|
||||
const sec = makeSec('sc_motor', [makeAtt('at_fuel'), makeAtt('at_cv')]);
|
||||
const scope = buildScope(sec);
|
||||
expect(scope.map(e => e.path)).toContain('at_fuel');
|
||||
expect(scope.map(e => e.path)).toContain('at_cv');
|
||||
expect(scope).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getValidOperators / isOperatorValid
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getValidOperators', () => {
|
||||
it('string → solo == y !=', () => {
|
||||
const ops = getValidOperators({ kind: 'string' } as any);
|
||||
expect(ops).toContain('==');
|
||||
expect(ops).toContain('!=');
|
||||
expect(ops).not.toContain('>');
|
||||
expect(ops).not.toContain('date-before');
|
||||
});
|
||||
|
||||
it('number → incluye operadores de comparación numérica', () => {
|
||||
const ops = getValidOperators({ kind: 'number' } as any);
|
||||
expect(ops).toContain('>');
|
||||
expect(ops).toContain('<=');
|
||||
expect(ops).not.toContain('date-before');
|
||||
});
|
||||
|
||||
it('date → incluye operadores de fecha', () => {
|
||||
const ops = getValidOperators({ kind: 'date' } as any);
|
||||
expect(ops).toContain('date-before');
|
||||
expect(ops).toContain('date-after');
|
||||
expect(ops).toContain('date-between');
|
||||
expect(ops).not.toContain('>');
|
||||
});
|
||||
|
||||
it('boolean → solo == y !=', () => {
|
||||
const ops = getValidOperators({ kind: 'boolean' } as any);
|
||||
expect(ops).toEqual(['==', '!=']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOperatorValid', () => {
|
||||
it('> es válido para number', () => {
|
||||
expect(isOperatorValid('>', { kind: 'number' } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('> no es válido para string', () => {
|
||||
expect(isOperatorValid('>', { kind: 'string' } as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('date-before es válido para date', () => {
|
||||
expect(isOperatorValid('date-before', { kind: 'date' } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('date-before no es válido para number', () => {
|
||||
expect(isOperatorValid('date-before', { kind: 'number' } as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// detectTautology
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectTautology', () => {
|
||||
it('unknown cuando el left es var', () => {
|
||||
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
expect(detectTautology(node).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
it('always-true cuando 1 == 1', () => {
|
||||
const node = makeCompareNode('==', litOp(1), litOp(1));
|
||||
expect(detectTautology(node).kind).toBe('always-true');
|
||||
});
|
||||
|
||||
it('always-false cuando 1 == 2', () => {
|
||||
const node = makeCompareNode('==', litOp(1), litOp(2));
|
||||
expect(detectTautology(node).kind).toBe('always-false');
|
||||
});
|
||||
|
||||
it('always-true cuando 5 > 3', () => {
|
||||
const node = makeCompareNode('>', litOp(5), litOp(3));
|
||||
expect(detectTautology(node).kind).toBe('always-true');
|
||||
});
|
||||
|
||||
it('always-false cuando 2 > 3', () => {
|
||||
const node = makeCompareNode('>', litOp(2), litOp(3));
|
||||
expect(detectTautology(node).kind).toBe('always-false');
|
||||
});
|
||||
|
||||
it('and: always-false si algún hijo es always-false', () => {
|
||||
const node = makeLogicNode('and', [
|
||||
makeCompareNode('==', litOp(1), litOp(1)), // true
|
||||
makeCompareNode('==', litOp(1), litOp(2)), // false
|
||||
]);
|
||||
expect(detectTautology(node).kind).toBe('always-false');
|
||||
});
|
||||
|
||||
it('and: always-true si todos los hijos son always-true', () => {
|
||||
const node = makeLogicNode('and', [
|
||||
makeCompareNode('==', litOp(1), litOp(1)),
|
||||
makeCompareNode('!=', litOp(1), litOp(2)),
|
||||
]);
|
||||
expect(detectTautology(node).kind).toBe('always-true');
|
||||
});
|
||||
|
||||
it('or: always-true si algún hijo es always-true', () => {
|
||||
const node = makeLogicNode('or', [
|
||||
makeCompareNode('==', litOp(1), litOp(2)), // false
|
||||
makeCompareNode('==', litOp(1), litOp(1)), // true
|
||||
]);
|
||||
expect(detectTautology(node).kind).toBe('always-true');
|
||||
});
|
||||
|
||||
it('or: always-false si todos los hijos son always-false', () => {
|
||||
const node = makeLogicNode('or', [
|
||||
makeCompareNode('==', litOp(1), litOp(2)),
|
||||
makeCompareNode('==', litOp(3), litOp(4)),
|
||||
]);
|
||||
expect(detectTautology(node).kind).toBe('always-false');
|
||||
});
|
||||
|
||||
it('unknown si algún hijo es unknown en and', () => {
|
||||
const node = makeLogicNode('and', [
|
||||
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
|
||||
makeCompareNode('==', litOp(1), litOp(1)),
|
||||
]);
|
||||
expect(detectTautology(node).kind).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// compileCondition
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('compileCondition', () => {
|
||||
it('compila CompareNode con var y literal', () => {
|
||||
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
const result = compileCondition(node);
|
||||
expect(result).toEqual({ '==': [{ var: 'at_color' }, 'rojo'] });
|
||||
});
|
||||
|
||||
it('compila LogicNode and con dos hijos', () => {
|
||||
const node = makeLogicNode('and', [
|
||||
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
|
||||
makeCompareNode('!=', varOp('at_acabado'), litOp('lujo')),
|
||||
]);
|
||||
expect(compileCondition(node)).toEqual({
|
||||
and: [
|
||||
{ '==': [{ var: 'at_color' }, 'rojo'] },
|
||||
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('compila date-between con dos bounds', () => {
|
||||
const node = makeCompareNode('date-between', varOp('at_fecha'), [litOp('2024-01-01'), litOp('2024-12-31')]);
|
||||
expect(compileCondition(node)).toEqual({
|
||||
'date-between': [{ var: 'at_fecha' }, '2024-01-01', '2024-12-31'],
|
||||
});
|
||||
});
|
||||
|
||||
it('compila LogicNode or anidado', () => {
|
||||
const node = makeLogicNode('or', [
|
||||
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
|
||||
makeCompareNode('==', varOp('at_color'), litOp('azul')),
|
||||
]);
|
||||
expect(compileCondition(node)).toEqual({
|
||||
or: [
|
||||
{ '==': [{ var: 'at_color' }, 'rojo'] },
|
||||
{ '==': [{ var: 'at_color' }, 'azul'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// parseCondition
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseCondition', () => {
|
||||
it('parsea { "==": [{ var: "at_color" }, "rojo"] }', () => {
|
||||
const node = parseCondition({ '==': [{ var: 'at_color' }, 'rojo'] });
|
||||
expect(node.kind).toBe('compare');
|
||||
if (node.kind === 'compare') {
|
||||
expect(node.op).toBe('==');
|
||||
expect(node.left).toEqual({ kind: 'var', path: 'at_color' });
|
||||
expect(node.right).toEqual({ kind: 'literal', value: 'rojo' });
|
||||
}
|
||||
});
|
||||
|
||||
it('parsea { and: [...] }', () => {
|
||||
const node = parseCondition({
|
||||
and: [
|
||||
{ '==': [{ var: 'at_color' }, 'rojo'] },
|
||||
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
|
||||
],
|
||||
});
|
||||
expect(node.kind).toBe('logic');
|
||||
if (node.kind === 'logic') {
|
||||
expect(node.op).toBe('and');
|
||||
expect(node.children).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('compile(parse(x)) === x para condición simple', () => {
|
||||
const original = { '==': [{ var: 'at_color' }, 'rojo'] };
|
||||
const node = parseCondition(original);
|
||||
expect(compileCondition(node)).toEqual(original);
|
||||
});
|
||||
|
||||
it('compile(parse(x)) === x para condición compleja', () => {
|
||||
const original = asJsonCondition({
|
||||
and: [
|
||||
{ '==': [{ var: 'at_color' }, 'rojo'] },
|
||||
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
|
||||
],
|
||||
});
|
||||
expect(compileCondition(parseCondition(original))).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// validateCondition
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateCondition', () => {
|
||||
function makeScope(...atts: [string, string][]): ScopeEntry[] {
|
||||
return atts.map(([path, kind]) => ({
|
||||
path,
|
||||
att: makeAtt(path.split('/').pop()!),
|
||||
domain: { kind } as any,
|
||||
}));
|
||||
}
|
||||
|
||||
it('sin errores cuando la condición es válida', () => {
|
||||
const scope = makeScope(['at_color', 'string']);
|
||||
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
expect(validateCondition(node, scope)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('error cuando la variable no está en el scope', () => {
|
||||
const scope = makeScope(['at_color', 'string']);
|
||||
const node = makeCompareNode('==', varOp('at_desconocido'), litOp('rojo'));
|
||||
const errors = validateCondition(node, scope);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].message).toContain('at_desconocido');
|
||||
});
|
||||
|
||||
it('error cuando el operador no es válido para el dominio', () => {
|
||||
const scope = makeScope(['at_color', 'string']);
|
||||
const node = makeCompareNode('>', varOp('at_color'), litOp('rojo'));
|
||||
const errors = validateCondition(node, scope);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].message).toContain('>');
|
||||
});
|
||||
|
||||
it('error cuando LogicNode no tiene hijos', () => {
|
||||
const scope = makeScope(['at_color', 'string']);
|
||||
const node = makeLogicNode('and', []);
|
||||
const errors = validateCondition(node, scope);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].message).toContain('and');
|
||||
});
|
||||
|
||||
it('valida recursivamente hijos de LogicNode', () => {
|
||||
const scope = makeScope(['at_color', 'string']);
|
||||
const node = makeLogicNode('and', [
|
||||
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
|
||||
makeCompareNode('==', varOp('at_desconocido'), litOp('x')),
|
||||
]);
|
||||
const errors = validateCondition(node, scope);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].message).toContain('at_desconocido');
|
||||
});
|
||||
|
||||
it('sin errores con path de sección sc_motor/at_fuel', () => {
|
||||
const scope = makeScope(['sc_motor/at_fuel', 'string']);
|
||||
const node = makeCompareNode('==', varOp('sc_motor/at_fuel'), litOp('gasolina'));
|
||||
expect(validateCondition(node, scope)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// replaceNode / removeNode / addChild
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mutación del árbol', () => {
|
||||
it('replaceNode reemplaza un nodo por id', () => {
|
||||
const child1 = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
const child2 = makeCompareNode('!=', varOp('at_acabado'), litOp('lujo'));
|
||||
const root = makeLogicNode('and', [child1, child2]);
|
||||
|
||||
const newChild = makeCompareNode('==', varOp('at_color'), litOp('azul'));
|
||||
const result = replaceNode(root, child1.id, newChild);
|
||||
|
||||
expect(result.kind).toBe('logic');
|
||||
if (result.kind === 'logic') {
|
||||
expect(result.children[0]).toBe(newChild);
|
||||
expect(result.children[1]).toBe(child2);
|
||||
}
|
||||
});
|
||||
|
||||
it('replaceNode no muta el árbol original', () => {
|
||||
const child = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
const root = makeLogicNode('and', [child]);
|
||||
const newNode = makeCompareNode('==', varOp('at_color'), litOp('azul'));
|
||||
|
||||
replaceNode(root, child.id, newNode);
|
||||
|
||||
expect(root.kind === 'logic' && root.children[0]).toBe(child);
|
||||
});
|
||||
|
||||
it('removeNode elimina un nodo por id', () => {
|
||||
const child1 = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
const child2 = makeCompareNode('!=', varOp('at_acabado'), litOp('lujo'));
|
||||
const root = makeLogicNode('and', [child1, child2]);
|
||||
|
||||
const result = removeNode(root, child1.id);
|
||||
expect(result?.kind).toBe('logic');
|
||||
if (result?.kind === 'logic') {
|
||||
expect(result.children).toHaveLength(1);
|
||||
expect(result.children[0]).toBe(child2);
|
||||
}
|
||||
});
|
||||
|
||||
it('removeNode devuelve null si el LogicNode queda sin hijos', () => {
|
||||
const child = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
const root = makeLogicNode('and', [child]);
|
||||
expect(removeNode(root, child.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('removeNode devuelve null si se elimina el nodo raíz', () => {
|
||||
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
expect(removeNode(node, node.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('addChild añade un hijo a un LogicNode', () => {
|
||||
const root = makeLogicNode('and', []);
|
||||
const newChild = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
const result = addChild(root, root.id, newChild);
|
||||
|
||||
expect(result.kind).toBe('logic');
|
||||
if (result.kind === 'logic') {
|
||||
expect(result.children).toHaveLength(1);
|
||||
expect(result.children[0]).toBe(newChild);
|
||||
}
|
||||
});
|
||||
|
||||
it('addChild no muta el árbol original', () => {
|
||||
const root = makeLogicNode('and', []);
|
||||
const newChild = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
|
||||
addChild(root, root.id, newChild);
|
||||
expect(root.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
// engine.ts
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TYPES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Estado plano del configurador usado por el motor durante la evaluación.
|
||||
* Key = AttPath serializado con separador `/`
|
||||
* Value = valor actual del atributo (unknown — puede venir de fuera del sistema)
|
||||
*/
|
||||
export type ConfigState = Record<string, unknown>;
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type {AttKey, AttPath} from "@/vcen/types/att.ts";
|
||||
import type {Rule, RuleID} from "@/vcen/types/rule.ts";
|
||||
import {serializeAttPath} from "@/vcen/node_util.ts";
|
||||
import type {AttKey, AttPath} from "@/vcen/lib/types/att.ts";
|
||||
import type {Rule, RuleID} from "@/vcen/lib/types/rule.ts";
|
||||
import {serializeAttPath} from "@/vcen/lib/node_util.ts";
|
||||
|
||||
export type GraphNode =
|
||||
| { kind: 'att'; path: AttPath }
|
||||
@ -0,0 +1,16 @@
|
||||
|
||||
|
||||
|
||||
export * from './att.ts';
|
||||
export * from './cat.ts';
|
||||
export * from './graph.ts';
|
||||
export * from './engine.ts';
|
||||
export * from './incremental.ts';
|
||||
export * from './metadata.ts';
|
||||
export * from './node.ts';
|
||||
export * from './obj.ts';
|
||||
export * from './opt.ts';
|
||||
export * from './rule.ts';
|
||||
export * from './sec.ts';
|
||||
export * from './validation.ts'
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
// metadata.ts
|
||||
|
||||
|
||||
/**
|
||||
* Metadata arbitraria — pares clave/valor para datos adicionales
|
||||
* como precios, códigos ERP, imágenes, etc.
|
||||
*/
|
||||
export type Metadata = Record<string, unknown>;
|
||||
@ -1,25 +1,11 @@
|
||||
import type {ID, ISODate, ISODatetime, ISOTime} from "@/tsat/types";
|
||||
import type {CatID} from "@/vcen/lib/types/cat.ts";
|
||||
import type {ObjID} from "@/vcen/lib/types/obj.ts";
|
||||
import type {SecID} from "@/vcen/lib/types/sec.ts";
|
||||
import type {AttID} from "@/vcen/lib/types/att.ts";
|
||||
|
||||
|
||||
|
||||
export const catIDPrefix = 'ct' as const ;
|
||||
export const objIDPrefix = 'ob' as const;
|
||||
export const secIDPrefix = 'sc' as const;
|
||||
export const attIDPrefix = 'at' as const;
|
||||
export const optIDPrefix = 'op' as const;
|
||||
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IDENTITY
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CatID = ID<typeof catIDPrefix>;
|
||||
export type ObjID = ID<typeof objIDPrefix>;
|
||||
export type SecID = ID<typeof secIDPrefix>;
|
||||
export type AttID = ID<typeof attIDPrefix>;
|
||||
export type OptID = ID<typeof optIDPrefix>;
|
||||
|
||||
|
||||
/** Cualquier entidad que puede participar en el grafo o en un path */
|
||||
@ -0,0 +1,77 @@
|
||||
import type {Metadata} from "@/vcen/lib/types/metadata.ts";
|
||||
import type {LingString} from "@/ling";
|
||||
import type { RuleSet} from "@/vcen/lib/types/rule.ts";
|
||||
import type {ID} from "@/tsat";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OPTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CATEGORY OPTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const catOptIDPrefix = 'co' as const;
|
||||
export type OptCatID = ID<typeof catOptIDPrefix>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IDENTITY
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const optIDPrefix = 'op' as const;
|
||||
export type OptID = ID<typeof optIDPrefix>;
|
||||
|
||||
/**
|
||||
* Tipo del valor de una Option.
|
||||
* Una Option puede contener cualquier tipo escalar admitido por el sistema.
|
||||
*/
|
||||
export type OptValue =
|
||||
| { kind: 'string'; value: string }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'currency'; value: number }
|
||||
| { kind: 'boolean'; value: boolean };
|
||||
|
||||
/**
|
||||
* Categoría de Options — agrupa Options relacionadas para facilitar
|
||||
* la selección en la UI (ej: 'Colores', 'Materiales', 'Motorizaciones').
|
||||
*
|
||||
* Las categorías viven en `Cat.optCategories`.
|
||||
* Cada `Opt` referencia opcionalmente su categoría mediante `categoryId`.
|
||||
*
|
||||
* @example
|
||||
* { id: 'oc:colores', name: 'Colores' }
|
||||
*/
|
||||
export interface OptCategory {
|
||||
id: OptCatID;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Option — entidad global del catálogo que representa un valor
|
||||
* seleccionable en atributos de tipo `option`.
|
||||
*
|
||||
* Las Options viven en `Cat.opts` y son referenciadas desde
|
||||
* los dominios de los atributos mediante su `id`.
|
||||
*
|
||||
* Una Option puede tener sus propias `rules`, que determinan
|
||||
* si la opción está disponible en el contexto actual del Obj
|
||||
* que contiene el Att que la referencia.
|
||||
*
|
||||
* @example
|
||||
* {
|
||||
* id: 'op:rojo',
|
||||
* categoryId: 'oc:colores',
|
||||
* value: { kind: 'string', value: 'rojo' },
|
||||
* label: { es: 'Rojo', en: 'Red' },
|
||||
* rules: { rules: [ ruleRojoOnlySport ] }
|
||||
* }
|
||||
*/
|
||||
export interface Opt {
|
||||
id: OptID;
|
||||
categoryId?: OptCatID; // categoría a la que pertenece (opcional)
|
||||
value: OptValue;
|
||||
label?: LingString;
|
||||
metadata?: Metadata;
|
||||
rules?: RuleSet;
|
||||
}
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import type {Att} from "@/vcen/lib/types/att.ts";
|
||||
import type {RuleSet} from "@/vcen/lib/types/rule.ts";
|
||||
import type {ID} from "@/tsat";
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IDENTITY
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const secIDPrefix = 'sc' as const;
|
||||
export type SecID = ID<typeof secIDPrefix>;
|
||||
|
||||
|
||||
import type {SecID} from "@/vcen/types/node.ts";
|
||||
import type {Att} from "@/vcen/types/att.ts";
|
||||
import type {RuleSet} from "@/vcen/types/rule.ts";
|
||||
|
||||
/**
|
||||
* Sección — agrupación lógica de atributos dentro de un Object.
|
||||
@ -1,13 +0,0 @@
|
||||
|
||||
|
||||
|
||||
export * from './att';
|
||||
export * from './cat';
|
||||
export * from './graph';
|
||||
export * from './incremental';
|
||||
export * from './node';
|
||||
export * from './obj';
|
||||
export * from './rule';
|
||||
export * from './sec';
|
||||
export * from './validation.ts'
|
||||
|
||||
Loading…
Reference in new issue