-
-
Acciones
-
{ const v = e.currentTarget.value; if (v) { addAction(v); e.currentTarget.value = ''; } }}
- class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-400
- px-1.5 py-0.5 focus:outline-none focus:border-indigo-500"
- >
- + añadir...
- {#each ACTION_KINDS as { kind, label }}
- {label}
- {/each}
-
+
Cuando se cumple
+
+
+
+ {#each ATT_STATES as s}
+ setIfTrue(s.state)}
+ class="px-2.5 py-1 text-xs rounded border transition-colors
+ {ifTrue === s.state
+ ? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
+ : `border-gray-700 text-gray-500 hover:border-gray-600 ${s.color}`}"
+ >{s.label}
+ {/each}
- {#if rule.actions.length === 0}
-
Sin acciones
- {/if}
-
- {#each rule.actions as action, idx (idx)}
-
-
-
-
-
- {ACTION_KINDS.find(k => k.kind === action.type)?.label ?? action.type}
-
- patchAction(idx, { label: e.currentTarget.value })}
- placeholder="Descripción..."
- class="flex-1 bg-transparent border-transparent text-xs text-gray-500
- placeholder-gray-700 rounded px-1 py-0.5 hover:border-gray-700
- focus:border-gray-600 focus:bg-gray-800 border
- focus:outline-none transition-colors"
- />
- editor.removeAction(ruleSet, rule.id, idx)}
- class="text-gray-600 hover:text-red-400 transition-colors text-xs shrink-0"
- >✕
-
-
-
- {#if action.type === 'attribute'}
-
-
- Si cumple
- patchAction(idx, { ifTrue: e.currentTarget.value })}
- class="field-input">
- {#each ATT_STATES as { state, label }}
- {label}
- {/each}
-
-
-
- Si no cumple
- patchAction(idx, { ifFalse: e.currentTarget.value })}
- class="field-input">
- {#each ATT_STATES as { state, label }}
- {label}
- {/each}
-
-
-
-
- Target (vacío = este atributo)
- patchAction(idx, { target: e.currentTarget.value ? [e.currentTarget.value] : [] })}
- class="field-input">
- — este atributo —
- {#each allAttrs as a}
- {a.label}
- {/each}
-
-
-
- {:else if action.type === 'validation'}
-
-
- Nivel
- patchAction(idx, { level: e.currentTarget.value })}
- class="field-input {action.level === 'error' ? 'text-red-400' : 'text-amber-400'}">
- Error
- Aviso
-
-
-
- Mensaje
- patchAction(idx, { message: e.currentTarget.value })}
- placeholder="Campo obligatorio"
- class="field-input" />
-
-
-
- {:else if action.type === 'domain'}
-
-
- Estado
- patchAction(idx, { state: e.currentTarget.value })}
- class="field-input">
- Permitido
- Prohibido
-
-
-
- Target
- patchAction(idx, { target: e.currentTarget.value ? [e.currentTarget.value] : [] })}
- class="field-input">
- — atributo —
- {#each allAttrs as a}
- {a.label}
- {/each}
-
-
-
-
- {:else if action.type === 'value'}
-
- Valor (JSON)
- {
- try {
- const parsed = JSON.parse(e.currentTarget.value)
- patchAction(idx, { value: { ...action.value, value: parsed } })
- } catch { /* esperar JSON válido */ }
- }}
- placeholder='"texto" o 42 o true'
- class="field-input font-mono text-xs" />
-
- {/if}
-
+
+ {#if selfAttId !== undefined && containerAttrs.length > 0}
+
+ Aplicar a
+ setTarget(e.currentTarget.value)}
+ class="field-input text-xs">
+ — este atributo —
+ {#each containerAttrs as a}
+ {a.label}
+ {/each}
+
- {/each}
+ {/if}
diff --git a/src/vcen-edit/comps/RuleList.svelte b/src/vcen-edit/comps/RuleList.svelte
index 063280a..bbb76f7 100644
--- a/src/vcen-edit/comps/RuleList.svelte
+++ b/src/vcen-edit/comps/RuleList.svelte
@@ -14,10 +14,12 @@ let {
ruleSet,
container,
catOpts,
+ selfAttId,
}: {
- ruleSet: RuleSet
- container: Obj | Sec
- catOpts: typeof editor.cat.opts
+ ruleSet: RuleSet
+ container: Obj | Sec
+ catOpts: typeof editor.cat.opts
+ selfAttId?: string
} = $props()
function addRule() {
@@ -36,7 +38,7 @@ function addRule() {
{ruleSet}
{container}
{catOpts}
- {editor}
+ {selfAttId}
/>
{/each}
{/if}
diff --git a/src/vcen-edit/comps/SecAttsPanel.svelte b/src/vcen-edit/comps/SecAttsPanel.svelte
new file mode 100644
index 0000000..16d631a
--- /dev/null
+++ b/src/vcen-edit/comps/SecAttsPanel.svelte
@@ -0,0 +1,153 @@
+
+
+{#if ctx?.sec}
+ {@const { objId, secId, sec, attId } = ctx}
+
+
+
+
+ editor.renameSec(objId, secId, e.currentTarget.value)}
+ placeholder="Nombre sección" class="field-input text-xs" />
+
+
+
+ Atributos ({sec.atts.length})
+
+ { creating = true; newAttName = '' }}
+ class="w-5 h-5 flex items-center justify-center rounded text-gray-500
+ hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors">+
+
+
+ {#if sec.atts.length === 0}
+
+ Sin atributos
+ { creating = true; newAttName = '' }}
+ class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
+ text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors">
+ Añadir atributo
+
+
+ {:else}
+ {#each sec.atts as att (att.id)}
+ {@const isActive = attId === att.id}
+
editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })}
+ onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })}
+ class="px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
+ transition-colors group border-l-2
+ {isActive
+ ? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
+ : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
+ >
+
+ {att.value.domain.kind.slice(0, 3)}
+
+
+ {#if att.name}{att.name}{:else}Sin nombre {/if}
+
+ {#if att.rules?.rules?.length}
+
+ {/if}
+ { e.stopPropagation(); editor.removeAtt(objId, secId, att.id) }}
+ class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity text-xs"
+ >✕
+
+ {/each}
+ {/if}
+ {#if creating}
+
+
{ if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
+ placeholder="Nombre del atributo"
+ class="field-input text-xs"
+ autofocus
+ />
+
+ at:
+ { attSlugError = '' }}
+ onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
+ placeholder="slug-atributo"
+ class="field-input text-xs font-mono flex-1"
+ />
+
+ {#if attSlugError}
+
{attSlugError}
+ {/if}
+
+
+ Añadir
+
+
+ ✕
+
+
+
+ {/if}
+
+
+
+ {#if attId}
+
+ {:else}
+
+ Selecciona un atributo para editarlo
+
+ {/if}
+
+
+{/if}
diff --git a/src/vcen-edit/comps/SecInfoPanel.svelte b/src/vcen-edit/comps/SecInfoPanel.svelte
new file mode 100644
index 0000000..cd3c107
--- /dev/null
+++ b/src/vcen-edit/comps/SecInfoPanel.svelte
@@ -0,0 +1,71 @@
+
+
+{#if ctx?.sec}
+ {@const { objId, secId, sec } = ctx}
+
+
+
{sec.name || 'Sin nombre'}
+ {sec.id}
+
+
+
+ {#each [
+ { label: 'Atributos', value: sec.atts.length },
+ { label: 'Reglas', value: sec.rules?.rules?.length ?? 0 },
+ ] as s}
+
+ {s.value}
+ {s.label}
+
+ {/each}
+
+
+
+
+
+
+ Reglas ({sec.rules?.rules?.length ?? 0})
+ {#if sec.rules}
+ editor.setSecRuleSet(objId, secId, undefined)}
+ class="text-xs text-red-600 hover:text-red-400 transition-colors normal-case font-normal">
+ Eliminar todas
+
+ {:else}
+ editor.setSecRuleSet(objId, secId, { rules: [] })}
+ class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors normal-case font-normal">
+ + Añadir reglas
+
+ {/if}
+
+ {#if sec.rules}
+
+ {:else}
+ Sin reglas
+ {/if}
+
+
+{/if}
diff --git a/src/vcen-edit/comps/catalog-editor.svelte.ts b/src/vcen-edit/comps/catalog-editor.svelte.ts
index e44e7e0..7f5fb6f 100644
--- a/src/vcen-edit/comps/catalog-editor.svelte.ts
+++ b/src/vcen-edit/comps/catalog-editor.svelte.ts
@@ -9,14 +9,19 @@ import type { Cat, Obj, Sec, Att, AttDomain, AttValue,
// SELECTION — nodo activo en el árbol
// ─────────────────────────────────────────────────────────────────────────────
-export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts';
+export type SelectionKind =
+ | 'cat-info' | 'cat-atts' | 'opts'
+ | 'obj-info' | 'obj-atts'
+ | 'sec-info' | 'sec-atts';
export type Selection =
- | { kind: 'cat' }
- | { kind: 'opts' } // panel Opts/OptCategories
- | { kind: 'obj'; objId: ObjID }
- | { kind: 'sec'; objId: ObjID; secId: SecID }
- | { kind: 'att'; objId: ObjID; secId?: SecID; attId: AttID };
+ | { kind: 'cat-info' }
+ | { kind: 'cat-atts'; attId?: AttID }
+ | { kind: 'opts' }
+ | { kind: 'obj-info'; objId: ObjID }
+ | { kind: 'obj-atts'; objId: ObjID; attId?: AttID }
+ | { kind: 'sec-info'; objId: ObjID; secId: SecID }
+ | { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID };
// ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS
@@ -35,16 +40,19 @@ function defaultAttValue(): AttValue {
return { domain: { kind: 'string', mode: 'input' }, value: '' };
}
-function defaultAtt(name = 'Nuevo atributo'): Att {
- return { id: nextId
('at'), name, value: defaultAttValue() };
+function defaultAtt(name = 'Nuevo atributo', slug?: string): Att {
+ const id = slug ? `at:${slug}` as AttID : nextId('at');
+ return { id, name, value: defaultAttValue() };
}
-function defaultSec(name = 'Nueva sección'): Sec {
- return { id: nextId('sc'), name, atts: [] };
+function defaultSec(name = 'Nueva sección', slug?: string): Sec {
+ const id = slug ? `sc:${slug}` as SecID : nextId('sc');
+ return { id, name, atts: [] };
}
-function defaultObj(name = 'Nuevo objeto'): Obj {
- return { id: nextId('ob'), name, atts: [], secs: [] };
+function defaultObj(name = 'Nuevo objeto', slug?: string): Obj {
+ const id = slug ? `ob:${slug}` as ObjID : nextId('ob');
+ return { id, name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
@@ -63,7 +71,7 @@ function defaultRule(): Rule {
id: nextId('rl'),
priority: 10,
condition: true,
- actions: [],
+ actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
};
}
@@ -90,10 +98,9 @@ function defaultRule(): Rule {
* ```
*/
export function createCatalogEditorStore(initial: Cat) {
- let cat = $state(structuredClone(initial));
- let selection = $state({ kind: 'cat' });
+ let cat = $state(JSON.parse(JSON.stringify(initial)));
+ let selection = $state({ kind: 'cat-info' });
let dirty = $state(false);
- let panel = $state<'cat' | 'objs' | 'opts'>('objs');
// ── Helpers internos ────────────────────────────────────────────────────
@@ -133,10 +140,10 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Obj ─────────────────────────────────────────────────────────────────
- function addObj(name?: string): ObjID {
- const obj = defaultObj(name);
+ function addObj(name?: string, slug?: string): ObjID {
+ const obj = defaultObj(name, slug);
mutate(() => { cat.objs.push(obj); });
- select({ kind: 'obj', objId: obj.id });
+ select({ kind: 'obj-info', objId: obj.id });
return obj.id;
}
@@ -152,8 +159,13 @@ export function createCatalogEditorStore(initial: Cat) {
cat.objs = cat.objs.filter(o => o.id !== objId);
});
// Limpiar selección si apuntaba al obj eliminado
- if (selection.kind === 'obj' && selection.objId === objId) select({ kind: 'cat' });
- if ((selection.kind === 'sec' || selection.kind === 'att') && selection.objId === objId) select({ kind: 'cat' });
+ const s = selection;
+ if (
+ (s.kind === 'obj-info' || s.kind === 'obj-atts') && s.objId === objId ||
+ (s.kind === 'sec-info' || s.kind === 'sec-atts') && s.objId === objId
+ ) {
+ select({ kind: 'cat-info' });
+ }
}
function moveObj(objId: ObjID, direction: 'up' | 'down') {
@@ -168,13 +180,13 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Sec ─────────────────────────────────────────────────────────────────
- function addSec(objId: ObjID, name?: string): SecID {
- const sec = defaultSec(name);
+ function addSec(objId: ObjID, name?: string, slug?: string): SecID {
+ const sec = defaultSec(name, slug);
mutate(() => {
const obj = findObj(objId);
if (obj) obj.secs.push(sec);
});
- select({ kind: 'sec', objId, secId: sec.id });
+ select({ kind: 'sec-info', objId, secId: sec.id });
return sec.id;
}
@@ -190,8 +202,12 @@ export function createCatalogEditorStore(initial: Cat) {
const obj = findObj(objId);
if (obj) obj.secs = obj.secs.filter(s => s.id !== secId);
});
- if (selection.kind === 'sec' && selection.secId === secId) select({ kind: 'obj', objId });
- if (selection.kind === 'att' && selection.secId === secId) select({ kind: 'obj', objId });
+ const s = selection;
+ if (
+ (s.kind === 'sec-info' || s.kind === 'sec-atts') && s.secId === secId
+ ) {
+ select({ kind: 'obj-atts', objId });
+ }
}
function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') {
@@ -215,8 +231,8 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Att (en Obj o Sec) ──────────────────────────────────────────────────
- function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID {
- const att = defaultAtt(name);
+ function addAtt(objId: ObjID, secId: SecID | undefined, name?: string, slug?: string): AttID {
+ const att = defaultAtt(name, slug);
mutate(() => {
if (secId) {
const sec = findSec(objId, secId);
@@ -226,14 +242,18 @@ export function createCatalogEditorStore(initial: Cat) {
if (obj) obj.atts.push(att);
}
});
- select({ kind: 'att', objId, secId, attId: att.id });
+ if (secId) {
+ select({ kind: 'sec-atts', objId, secId, attId: att.id });
+ } else {
+ select({ kind: 'obj-atts', objId, attId: att.id });
+ }
return att.id;
}
- function addCatAtt(name?: string): AttID {
- const att = defaultAtt(name);
+ function addCatAtt(name?: string, slug?: string): AttID {
+ const att = defaultAtt(name, slug);
mutate(() => { cat.atts.push(att); });
- select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id });
+ select({ kind: 'cat-atts', attId: att.id });
return att.id;
}
@@ -244,6 +264,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function renameCatAtt(attId: AttID, name: string) {
+ mutate(() => {
+ const att = findCatAtt(attId);
+ if (att) att.name = name;
+ });
+ }
+
function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) {
mutate(() => {
if (secId) {
@@ -254,8 +281,21 @@ export function createCatalogEditorStore(initial: Cat) {
if (obj) obj.atts = obj.atts.filter(a => a.id !== attId);
}
});
- if (selection.kind === 'att' && selection.attId === attId) {
- select(secId ? { kind: 'sec', objId, secId } : { kind: 'obj', objId });
+ const s = selection;
+ if (s.kind === 'obj-atts' && s.attId === attId) {
+ select({ kind: 'obj-atts', objId });
+ } else if (s.kind === 'sec-atts' && s.attId === attId) {
+ select({ kind: 'sec-atts', objId, secId: secId! });
+ }
+ }
+
+ function removeCatAtt(attId: AttID) {
+ mutate(() => {
+ cat.atts = cat.atts.filter(a => a.id !== attId);
+ });
+ const s = selection;
+ if (s.kind === 'cat-atts' && s.attId === attId) {
+ select({ kind: 'cat-atts' });
}
}
@@ -280,6 +320,14 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function updateCatAttDomain(attId: AttID, domain: AttDomain) {
+ mutate(() => {
+ const att = findCatAtt(attId);
+ if (!att) return;
+ att.value = { domain, value: defaultValueForDomain(domain) } as AttValue;
+ });
+ }
+
function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) {
mutate(() => {
const att = findAtt(objId, secId, attId);
@@ -287,6 +335,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function updateCatAttDefaultValue(attId: AttID, value: unknown) {
+ mutate(() => {
+ const att = findCatAtt(attId);
+ if (att) att.value = { ...att.value, value } as typeof att.value;
+ });
+ }
+
function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findAtt(objId, secId, attId);
@@ -294,6 +349,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function setCatAttRuleSet(attId: AttID, ruleSet: RuleSet | undefined) {
+ mutate(() => {
+ const att = findCatAtt(attId);
+ if (att) att.rules = ruleSet;
+ });
+ }
+
function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) {
mutate(() => {
const obj = findObj(objId);
@@ -369,6 +431,41 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function addCatAttOpt(attId: AttID, optId: OptID) {
+ mutate(() => {
+ const att = findCatAtt(attId)
+ if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
+ const list = (att.value.domain as any).list as AttOpt[]
+ if (!list.find((ao: AttOpt) => ao.optId === optId)) list.push({ optId })
+ })
+ }
+
+ function removeCatAttOpt(attId: AttID, optId: OptID) {
+ mutate(() => {
+ const att = findCatAtt(attId)
+ if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
+ ;(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]).filter((ao: AttOpt) => ao.optId !== optId)
+ })
+ }
+
+ function updateAttOptLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID, label: string) {
+ mutate(() => {
+ const att = findAtt(objId, secId, attId)
+ if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
+ const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
+ if (attOpt) attOpt.label = label || undefined
+ })
+ }
+
+ function updateCatAttOptLabel(attId: AttID, optId: OptID, label: string) {
+ mutate(() => {
+ const att = findCatAtt(attId)
+ if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
+ const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
+ if (attOpt) attOpt.label = label || undefined
+ })
+ }
+
// ── OptCategories ───────────────────────────────────────────────────────
function addOptCategory(name?: string): OptCatID {
@@ -402,12 +499,13 @@ export function createCatalogEditorStore(initial: Cat) {
return rule.id;
}
- function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial>) {
+ function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (!rule) return;
if (patch.condition !== undefined) rule.condition = patch.condition;
if (patch.priority !== undefined) rule.priority = patch.priority;
+ if ((patch as any).label !== undefined) (rule as any).label = (patch as any).label;
});
}
@@ -438,6 +536,25 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ // ── Slug validators ─────────────────────────────────────────────────────
+
+ function isObjSlugTaken(slug: string): boolean {
+ return cat.objs.some(o => o.id === `ob:${slug}`);
+ }
+
+ function isSecSlugTaken(slug: string): boolean {
+ return cat.objs.some(o => o.secs.some(s => s.id === `sc:${slug}`));
+ }
+
+ function isAttSlugTaken(slug: string): boolean {
+ const id = `at:${slug}`;
+ if (cat.atts.some(a => a.id === id)) return true;
+ return cat.objs.some(o =>
+ o.atts.some(a => a.id === id) ||
+ o.secs.some(s => s.atts.some(a => a.id === id))
+ );
+ }
+
// ── Persistencia ────────────────────────────────────────────────────────
/** Devuelve el Cat en estado actual. Útil para guardar. */
@@ -452,9 +569,9 @@ export function createCatalogEditorStore(initial: Cat) {
/** Descarta todos los cambios y vuelve al Cat inicial. */
function discard() {
- cat = structuredClone(initial);
+ cat = JSON.parse(JSON.stringify(initial));
dirty = false;
- selection = { kind: 'cat' };
+ selection = { kind: 'cat-info' };
}
// ── Opt activa (para OptsPanel) ──────────────────────────────────────────
@@ -486,12 +603,18 @@ export function createCatalogEditorStore(initial: Cat) {
addSec, renameSec, removeSec, moveSec,
setSecRuleSet,
- // Att
+ // Att (obj/sec level)
addAtt, addCatAtt, renameAtt, removeAtt, moveAtt,
updateAttDomain, updateAttDefaultValue, setAttRuleSet,
+ // Att (cat level)
+ renameCatAtt, removeCatAtt,
+ updateCatAttDomain, updateCatAttDefaultValue, setCatAttRuleSet,
+
// Opts globales
addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt,
+ addCatAttOpt, removeCatAttOpt,
+ updateAttOptLabel, updateCatAttOptLabel,
// OptCategories
addOptCategory, renameOptCategory, removeOptCategory,
@@ -500,13 +623,12 @@ export function createCatalogEditorStore(initial: Cat) {
addRule, updateRule, removeRule,
addAction, updateAction, removeAction,
+ // Slug validators
+ isObjSlugTaken, isSecSlugTaken, isAttSlugTaken,
+
// Persistencia
getCat, markClean, discard,
- // Panel
- get activePanel() { return panel; },
- setPanel(p: 'cat' | 'objs' | 'opts') { panel = p; },
-
// Acceso a finders para los paneles
findObj, findSec, findAtt, findCatAtt,
};
diff --git a/src/vcen-edit/lib/condition-editor.ts b/src/vcen-edit/lib/condition-editor.ts
index 751f350..7a30401 100644
--- a/src/vcen-edit/lib/condition-editor.ts
+++ b/src/vcen-edit/lib/condition-editor.ts
@@ -53,6 +53,7 @@ export interface ResolvedOpt {
export interface ScopeEntry {
path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel"
+ label: string; // nombre legible: "País", "Catálogo / País", "Motor / Combustible"
att: Att;
domain: AttDomain;
resolvedOpts?: ResolvedOpt[]; // solo presente si domain.kind === 'option' && mode === 'list'
@@ -60,7 +61,8 @@ export interface ScopeEntry {
/**
* Construye el scope de variables disponibles para una regla.
- * El scope incluye todos los atts del contenedor y sus secciones hijas.
+ * El scope incluye todos los atts del contenedor y sus secciones hijas,
+ * más los atts de nivel catálogo (prefijados con "cat/").
*
* Para atts de dominio `option/list`, resuelve las opciones disponibles
* cruzando las `AttOpt` del dominio con los `Opt` globales del catálogo,
@@ -68,14 +70,31 @@ export interface ScopeEntry {
*
* @param container - Obj o Sec que contiene la regla
* @param catOpts - Opts globales del catálogo (Cat.opts)
+ * @param catAtts - Atts de nivel catálogo (Cat.atts)
*/
-export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntry[] {
+function nameStr(name: unknown, fallback: string): string {
+ return typeof name === 'string' ? (name || fallback) : fallback;
+}
+
+export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: Att[] = []): ScopeEntry[] {
const entries: ScopeEntry[] = [];
+ // Atts de nivel catálogo — visibles desde cualquier regla
+ for (const att of catAtts) {
+ entries.push({
+ path: `cat/${att.id}`,
+ label: `Catálogo / ${nameStr(att.name, att.id)}`,
+ att,
+ domain: att.value.domain,
+ resolvedOpts: resolveOpts(att.value.domain, catOpts),
+ });
+ }
+
const atts = 'atts' in container ? container.atts : [];
for (const att of atts) {
entries.push({
path: att.id,
+ label: nameStr(att.name, att.id),
att,
domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
@@ -84,9 +103,11 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntr
if ('secs' in container) {
for (const sec of container.secs) {
+ const secLabel = nameStr(sec.name, sec.id);
for (const att of sec.atts) {
entries.push({
path: `${sec.id}/${att.id}`,
+ label: `${secLabel} / ${nameStr(att.name, att.id)}`,
att,
domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
@@ -271,8 +292,15 @@ function newId(): string { return `node_${++_idCounter}`; }
* Ú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
+ // true/false/null como condición raíz → nodo lógico vacío (true = siempre activa)
+ if (value === true || value === null) {
+ return makeLogicNode('and', []);
+ }
+ if (value === false) {
+ return makeLogicNode('or', []);
+ }
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ // Otro literal primitivo — envolver en compare
return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true });
}
@@ -425,9 +453,6 @@ export function validateCondition(
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;
}
diff --git a/src/vcen-edit/lib/rule-eval.ts b/src/vcen-edit/lib/rule-eval.ts
new file mode 100644
index 0000000..9124c02
--- /dev/null
+++ b/src/vcen-edit/lib/rule-eval.ts
@@ -0,0 +1,71 @@
+/**
+ * Utilidades para evaluar reglas contra valores por defecto del catálogo.
+ * Permite mostrar el estado calculado de un objeto/atributo en el editor.
+ */
+import type { Cat, RuleSet, AttState, Obj, Sec } from '@/vcen/lib'
+import { JsonLogicEngine } from '@/jslg/lib'
+
+const _engine = new JsonLogicEngine()
+
+/**
+ * Construye el contexto de datos para evaluar condiciones JsonLogic.
+ * Usa los valores por defecto de cada atributo como valores actuales.
+ *
+ * Rutas accesibles:
+ * cat/ → atributo de catálogo
+ * → atributo directo del contenedor
+ * / → atributo de sección (solo si container es Obj)
+ */
+export function buildEvalCtx(cat: Cat, container: Obj | Sec): Record {
+ const ctx: Record = {}
+
+ ctx['cat'] = {}
+ for (const att of cat.atts ?? []) {
+ ctx['cat'][att.id] = att.value.value
+ }
+
+ for (const att of container.atts) {
+ ctx[att.id] = att.value.value
+ }
+
+ if ('secs' in container) {
+ for (const sec of (container as Obj).secs) {
+ ctx[sec.id] = {}
+ for (const att of sec.atts) {
+ ctx[sec.id][att.id] = att.value.value
+ }
+ }
+ }
+
+ return ctx
+}
+
+/** Evalúa un RuleSet y retorna el AttState que gana (primera regla activa), o null. */
+export function evalRuleSetState(
+ ruleSet: RuleSet | undefined,
+ ctx: Record,
+): AttState | null {
+ if (!ruleSet?.rules.length) return null
+
+ // Mayor prioridad primero (número mayor = se evalúa antes)
+ const sorted = [...ruleSet.rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
+
+ for (const rule of sorted) {
+ let fires: boolean
+ try {
+ fires = rule.condition === true
+ ? true
+ : Boolean(_engine.evaluate(rule.condition as any, ctx))
+ } catch { continue }
+
+ if (!fires) continue
+
+ for (const action of rule.actions) {
+ if (action.type === 'attribute' && (!action.target || action.target.length === 0)) {
+ return action.ifTrue
+ }
+ }
+ }
+
+ return null
+}
diff --git a/src/vcen-edit/stores/catalog-editor.svelte.ts b/src/vcen-edit/stores/catalog-editor.svelte.ts
index 79a79eb..527789c 100644
--- a/src/vcen-edit/stores/catalog-editor.svelte.ts
+++ b/src/vcen-edit/stores/catalog-editor.svelte.ts
@@ -63,7 +63,7 @@ function defaultRule(): Rule {
id: nextId('rl'),
priority: 10,
condition: true,
- actions: [],
+ actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
};
}
@@ -401,12 +401,13 @@ export function createCatalogEditorStore(initial: Cat) {
return rule.id;
}
- function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial>) {
+ function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (!rule) return;
if (patch.condition !== undefined) rule.condition = patch.condition;
if (patch.priority !== undefined) rule.priority = patch.priority;
+ if ((patch as any).label !== undefined) (rule as any).label = (patch as any).label;
});
}
diff --git a/src/vcen-edit/stores/condition-builder.svelte.ts b/src/vcen-edit/stores/condition-builder.svelte.ts
index c10343b..1b2b386 100644
--- a/src/vcen-edit/stores/condition-builder.svelte.ts
+++ b/src/vcen-edit/stores/condition-builder.svelte.ts
@@ -1,5 +1,5 @@
import type { JsonLogicValue } from '@/jslg/lib';
-import type {Obj, Opt, Sec} from '@/vcen/lib/types';
+import type {Att, Obj, Opt, Sec} from '@/vcen/lib/types';
import type {
ScopeEntry,
ConditionNode,
@@ -54,6 +54,7 @@ export function createConditionBuilderStore(
container: Obj | Sec,
initial?: JsonLogicValue,
catOpts: Opt[] = [],
+ catAtts: Att[] = [],
) {
// ── Estado reactivo ───────────────────────────────────────────────────────
@@ -63,7 +64,7 @@ export function createConditionBuilderStore(
: makeLogicNode('and', [])
);
- const scope = $derived(buildScope(container, catOpts));
+ const scope = $derived(buildScope(container, catOpts, catAtts));
const errors = $derived(
validateCondition(root, scope)
diff --git a/src/vcen-edit/tests/condition_editor.test.ts b/src/vcen-edit/tests/condition_editor.test.ts
index 87cdfb1..1e7a008 100644
--- a/src/vcen-edit/tests/condition_editor.test.ts
+++ b/src/vcen-edit/tests/condition_editor.test.ts
@@ -299,6 +299,7 @@ describe('validateCondition', () => {
function makeScope(...atts: [string, string][]): ScopeEntry[] {
return atts.map(([path, kind]) => ({
path,
+ label: path.split('/').pop()!,
att: makeAtt(path.split('/').pop()!),
domain: { kind } as any,
}));