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

7 months ago
<!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>

Powered by TurnKey Linux.