You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
520 lines
20 KiB
520 lines
20 KiB
<!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>
|