parent
4c0e0a33f0
commit
9bf050f990
@ -0,0 +1,325 @@
|
||||
# Arquitectura Reactiva - Documentación
|
||||
|
||||
## 🏗️ Estructura Híbrida
|
||||
|
||||
La arquitectura separa **estado agnóstico del framework** de los **adapters específicos**:
|
||||
|
||||
```
|
||||
src/
|
||||
├── state/ # ← Estado puro (sin framework)
|
||||
│ ├── SelectionState.ts
|
||||
│ ├── ValidationState.ts
|
||||
│ └── ConfigurationState.ts
|
||||
│
|
||||
└── adapters/ # ← Adapters por framework
|
||||
└── svelte/
|
||||
├── stores/ # Wrappers Svelte
|
||||
└── components/ # Componentes UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Capa 1: Estado Agnóstico (`/state`)
|
||||
|
||||
### `SelectionState.ts`
|
||||
|
||||
Gestiona qué opciones están seleccionadas.
|
||||
|
||||
**Responsabilidades:**
|
||||
- Almacenar selecciones: `Record<AttrID, OptionID>`
|
||||
- Notificar cambios via listeners
|
||||
- Historial de cambios (undo/redo)
|
||||
- Persistencia (toJSON/fromJSON)
|
||||
|
||||
**API:**
|
||||
```typescript
|
||||
const selection = new SelectionState();
|
||||
|
||||
// Establecer valor
|
||||
selection.set('at:suelo', 'op:parquet');
|
||||
|
||||
// Obtener valor
|
||||
selection.get('at:suelo'); // → 'op:parquet'
|
||||
|
||||
// Limpiar
|
||||
selection.clear('at:suelo');
|
||||
|
||||
// Suscribirse a cambios
|
||||
const unsubscribe = selection.subscribe((event) => {
|
||||
console.log(`${event.attrId} cambió a ${event.newValue}`);
|
||||
});
|
||||
|
||||
// Deshacer
|
||||
selection.undo();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ValidationState.ts`
|
||||
|
||||
Gestiona validaciones y violaciones usando el `RuleEngine`.
|
||||
|
||||
**Responsabilidades:**
|
||||
- Validar selecciones contra reglas
|
||||
- Detectar violaciones (errores/warnings)
|
||||
- Verificar valores permitidos/prohibidos
|
||||
- Determinar atributos requeridos
|
||||
|
||||
**API:**
|
||||
```typescript
|
||||
const validation = new ValidationState();
|
||||
|
||||
// Configurar reglas
|
||||
validation.setRules(catalog.rules);
|
||||
|
||||
// Validar estado
|
||||
const result = validation.validate(selectionState);
|
||||
|
||||
console.log(result.isValid); // → boolean
|
||||
console.log(result.violations); // → Violation[]
|
||||
|
||||
// Helpers
|
||||
validation.isValueAllowed('at:suelo', 'op:marmol', state);
|
||||
validation.getAllowedValues('at:suelo', state);
|
||||
validation.isRequired('at:material', state);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ConfigurationState.ts`
|
||||
|
||||
**Orquestador principal** que coordina todo.
|
||||
|
||||
**Responsabilidades:**
|
||||
- Gestionar catálogo y objeto
|
||||
- Coordinar SelectionState + ValidationState
|
||||
- Navegación (sección/vista actual)
|
||||
- Auto-validación en cambios
|
||||
- Persistencia completa
|
||||
|
||||
**API:**
|
||||
```typescript
|
||||
const config = new ConfigurationState({
|
||||
catalog: CATALOGO_VIVIENDAS,
|
||||
objectId: 'ob:apartamento',
|
||||
autoValidate: true
|
||||
});
|
||||
|
||||
// Selección
|
||||
config.selectOption('at:suelo', 'op:parquet');
|
||||
config.getSelection('at:suelo');
|
||||
|
||||
// Navegación
|
||||
config.setCurrentSection('sc:salon');
|
||||
config.setCurrentView('vw:front');
|
||||
|
||||
// Validación
|
||||
config.validate();
|
||||
config.isValid();
|
||||
config.getViolationsForAttribute('at:suelo');
|
||||
|
||||
// Helpers
|
||||
config.getAllowedValues('at:suelo');
|
||||
config.isRequired('at:material');
|
||||
|
||||
// Persistencia
|
||||
const saved = config.toJSON();
|
||||
config.fromJSON(saved);
|
||||
|
||||
// Utils
|
||||
config.reset();
|
||||
config.undo();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Capa 2: Adapters Svelte (`/adapters/svelte`)
|
||||
|
||||
### `stores/configuration.ts`
|
||||
|
||||
Wrapper que convierte `ConfigurationState` en Svelte store.
|
||||
|
||||
**Características:**
|
||||
- ✅ Reactivo automático (subscribe)
|
||||
- ✅ API idéntica a ConfigurationState
|
||||
- ✅ Derived stores para valores específicos
|
||||
- ✅ Type-safe
|
||||
|
||||
**Uso:**
|
||||
```typescript
|
||||
import { createConfigurationStore } from '@/adapters/svelte/stores';
|
||||
|
||||
const config = createConfigurationStore({
|
||||
catalog: CATALOGO_VIVIENDAS,
|
||||
objectId: 'ob:apartamento'
|
||||
});
|
||||
|
||||
// En componentes Svelte
|
||||
$: selection = $config.selection;
|
||||
$: isValid = $config.isValid;
|
||||
$: currentSection = $config.currentSection;
|
||||
|
||||
// Acciones
|
||||
config.selectOption('at:suelo', 'op:parquet');
|
||||
config.setCurrentSection('sc:salon');
|
||||
```
|
||||
|
||||
**Derived stores:**
|
||||
```typescript
|
||||
const currentSection = deriveCurrentSection(config);
|
||||
const validation = deriveValidation(config);
|
||||
const isValid = deriveIsValid(config);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Capa 3: Componentes UI (`/adapters/svelte/components`)
|
||||
|
||||
### `Configurator.svelte`
|
||||
|
||||
Componente principal de ejemplo.
|
||||
|
||||
**Features:**
|
||||
- ✅ Navegación de secciones
|
||||
- ✅ Selector de vistas
|
||||
- ✅ Panel de atributos
|
||||
- ✅ Preview visual
|
||||
- ✅ Validación en tiempo real
|
||||
- ✅ Undo/Reset
|
||||
|
||||
**Uso:**
|
||||
```svelte
|
||||
<script>
|
||||
import Configurator from '@/adapters/svelte/components/Configurator.svelte';
|
||||
import { CATALOGO_VIVIENDAS } from './fixtures';
|
||||
</script>
|
||||
|
||||
<Configurator
|
||||
catalog={CATALOGO_VIVIENDAS}
|
||||
objectId="ob:apartamento"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flujo de Datos
|
||||
|
||||
```
|
||||
User Action (click)
|
||||
↓
|
||||
Component Handler
|
||||
↓
|
||||
Store Action (config.selectOption)
|
||||
↓
|
||||
ConfigurationState
|
||||
↓
|
||||
SelectionState.set()
|
||||
↓
|
||||
Notifica listeners
|
||||
↓
|
||||
ValidationState.validate() (si autoValidate)
|
||||
↓
|
||||
Notifica cambio
|
||||
↓
|
||||
Svelte Store actualiza
|
||||
↓
|
||||
UI re-renderiza (reactive $config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Ventajas de esta arquitectura
|
||||
|
||||
### 1. **Separación de concerns**
|
||||
- Estado = Lógica de negocio (testeable sin UI)
|
||||
- Adapter = Integración con framework
|
||||
- Components = Solo presentación
|
||||
|
||||
### 2. **Framework agnostic**
|
||||
El 90% del código está en `/state` y no depende de Svelte.
|
||||
|
||||
Añadir React es fácil:
|
||||
```
|
||||
/adapters/react/
|
||||
└── hooks/
|
||||
└── useConfiguration.ts ← Wrapper con useState/useEffect
|
||||
```
|
||||
|
||||
### 3. **Testing simple**
|
||||
```typescript
|
||||
// Test estado SIN montar componentes
|
||||
const config = new ConfigurationState({...});
|
||||
config.selectOption('at:suelo', 'op:parquet');
|
||||
expect(config.getSelection('at:suelo')).toBe('op:parquet');
|
||||
```
|
||||
|
||||
### 4. **Type-safety completo**
|
||||
Todo tipado con TypeScript. El editor te ayuda en cada paso.
|
||||
|
||||
### 5. **Reactivo por defecto**
|
||||
Cambias el estado → UI se actualiza automáticamente.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Ejemplo completo
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createConfigurationStore } from '@/adapters/svelte/stores';
|
||||
|
||||
const config = createConfigurationStore({
|
||||
catalog: MY_CATALOG,
|
||||
objectId: 'ob:producto'
|
||||
});
|
||||
|
||||
// Reactivo automático
|
||||
$: selection = $config.selection;
|
||||
$: isValid = $config.isValid;
|
||||
$: violations = $config.validation?.violations || [];
|
||||
</script>
|
||||
|
||||
<!-- UI reactiva -->
|
||||
<div>
|
||||
<h1>{$config.object.name.es}</h1>
|
||||
|
||||
<select on:change={(e) => config.selectOption('at:color', e.target.value)}>
|
||||
<option value="">Seleccionar color</option>
|
||||
<option value="op:rojo">Rojo</option>
|
||||
<option value="op:azul">Azul</option>
|
||||
</select>
|
||||
|
||||
{#if !isValid}
|
||||
<div class="errors">
|
||||
{#each violations as v}
|
||||
<p>{v.rule.message.es}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button disabled={!isValid}>
|
||||
Confirmar
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Próximos pasos
|
||||
|
||||
1. ✅ Estado agnóstico implementado
|
||||
2. ✅ Adapter Svelte implementado
|
||||
3. ✅ Componente de ejemplo creado
|
||||
4. ⏳ Implementar renderizado de imágenes
|
||||
5. ⏳ Añadir precio dinámico
|
||||
6. ⏳ Implementar más componentes (AttributePanel, ValidationPanel, etc.)
|
||||
7. ⏳ Testing de stores
|
||||
8. ⏳ Documentación de componentes
|
||||
|
||||
---
|
||||
|
||||
## 📚 Archivos clave
|
||||
|
||||
- `/state/ConfigurationState.ts` - Orquestador principal
|
||||
- `/adapters/svelte/stores/configuration.ts` - Svelte store
|
||||
- `/adapters/svelte/components/Configurator.svelte` - Componente ejemplo
|
||||
- `/docs/ARCHITECTURE.md` - Este archivo
|
||||
@ -0,0 +1,813 @@
|
||||
# Modelo de datos - Documentación completa
|
||||
|
||||
## 📋 Tabla de contenidos
|
||||
|
||||
- [Visión general](#visión-general)
|
||||
- [Catálogo](#catálogo)
|
||||
- [Objetos configurables](#objetos-configurables)
|
||||
- [Secciones](#secciones)
|
||||
- [Atributos](#atributos)
|
||||
- [Opciones](#opciones)
|
||||
- [Reglas de validación](#reglas-de-validación)
|
||||
- [Ejemplo completo](#ejemplo-completo)
|
||||
|
||||
---
|
||||
|
||||
## Visión general
|
||||
|
||||
El modelo CPQ está organizado jerárquicamente:
|
||||
|
||||
```
|
||||
ConfigurationCatalog
|
||||
└── Objects (ConfigurableObject)
|
||||
├── Attributes (global)
|
||||
└── Sections
|
||||
└── Attributes (per-section)
|
||||
└── Options
|
||||
|
||||
Rules (aplican a cualquier combinación)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Catálogo
|
||||
|
||||
### `ConfigurationCatalog`
|
||||
|
||||
Contenedor principal de todo el modelo.
|
||||
|
||||
```typescript
|
||||
interface ConfigurationCatalog {
|
||||
name : I18nString;
|
||||
description? : I18nString;
|
||||
supportedLocales: string[];
|
||||
defaultLocale : string;
|
||||
options : Record<OptionID, OptionDefinition>;
|
||||
objects : Record<ObjectID, ConfigurableObject>;
|
||||
rules? : Record<RuleID, ValidationRule>;
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
const CATALOGO_VIVIENDAS: ConfigurationCatalog = {
|
||||
name: {
|
||||
es: 'Catálogo de Viviendas',
|
||||
en: 'Housing Catalog'
|
||||
},
|
||||
description: {
|
||||
es: 'Configurador de acabados',
|
||||
en: 'Finish configurator'
|
||||
},
|
||||
supportedLocales: ['es', 'en'],
|
||||
defaultLocale: 'es',
|
||||
options: { /* ... */ },
|
||||
objects: { /* ... */ },
|
||||
rules: { /* ... */ }
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Objetos configurables
|
||||
|
||||
### `ConfigurableObject`
|
||||
|
||||
Representa un producto/objeto que puede configurarse (ej: casa, coche, mueble).
|
||||
|
||||
```typescript
|
||||
interface ConfigurableObject {
|
||||
id : ObjectID; // 'ob:apartamento'
|
||||
name : I18nString;
|
||||
description : I18nString;
|
||||
attributes : Attribute[]; // Atributos GLOBALES
|
||||
sections : Record<SectionID, Section>;
|
||||
sectionOrder?: SectionID[];
|
||||
category? : string;
|
||||
metadata? : Metadata;
|
||||
}
|
||||
```
|
||||
|
||||
### Atributos globales vs de sección
|
||||
|
||||
- **Globales**: Afectan a TODO el objeto (ej: calidad general, color principal)
|
||||
- **De sección**: Solo afectan a una sección específica (ej: suelo del salón)
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
const APARTAMENTO: ConfigurableObject = {
|
||||
id: 'ob:apartamento',
|
||||
name: { es: 'Apartamento', en: 'Apartment' },
|
||||
description: { es: 'Apartamento de 1 dormitorio', en: '1 bedroom apartment' },
|
||||
|
||||
// Atributos GLOBALES (afectan a todo)
|
||||
attributes: [
|
||||
{
|
||||
id: 'at:calidad',
|
||||
name: { es: 'Calidad de acabados', en: 'Finish quality' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:calidad_estandar',
|
||||
options: [
|
||||
{ optionId: 'op:calidad_estandar' },
|
||||
{ optionId: 'op:calidad_premium' },
|
||||
{ optionId: 'op:calidad_lujo' }
|
||||
],
|
||||
isController: true, // ✅ Controla visibilidad/precios globalmente
|
||||
display: {
|
||||
uiVisible: true,
|
||||
affectsPrice: true
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
// Secciones del objeto
|
||||
sections: {
|
||||
'sc:salon': { /* ... */ },
|
||||
'sc:bano': { /* ... */ }
|
||||
},
|
||||
|
||||
sectionOrder: ['sc:salon', 'sc:bano']
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secciones
|
||||
|
||||
### `Section`
|
||||
|
||||
Representa una parte del objeto configurable (ej: salón, baño, motor).
|
||||
|
||||
```typescript
|
||||
interface Section {
|
||||
id : SectionID;
|
||||
name : I18nString;
|
||||
description : I18nString;
|
||||
attrs : Attribute[]; // Atributos DE ESTA SECCIÓN
|
||||
visualConfig: SectionVisualConfig;
|
||||
availability: SectionAvailability;
|
||||
order? : number;
|
||||
icon? : string;
|
||||
metadata? : Metadata;
|
||||
}
|
||||
```
|
||||
|
||||
### Visual Config
|
||||
|
||||
Controla cómo se renderiza visualmente la sección:
|
||||
|
||||
```typescript
|
||||
interface SectionVisualConfig {
|
||||
strategy: RenderingStrategy;
|
||||
|
||||
// Para 'static_image' o 'dynamic_image'
|
||||
imageUrlTemplate? : string;
|
||||
globalAttrDependencies? : AttrID[];
|
||||
sectionAttrDependencies?: AttrID[];
|
||||
basePath? : string;
|
||||
|
||||
// Para 'api_generated'
|
||||
apiConfig?: APIConfig;
|
||||
|
||||
// Fallback
|
||||
fallbackImage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Estrategias de renderizado:**
|
||||
|
||||
| Estrategia | Uso |
|
||||
|------------|-----|
|
||||
| `static_image` | URL con placeholders: `{basePath}/salon/{at_suelo}/{at_pared}.jpg` |
|
||||
| `dynamic_image` | URL dinámica calculada |
|
||||
| `api_generated` | Llamada a API externa para generar imagen |
|
||||
| `three_d` | Modelo 3D con texturas dinámicas |
|
||||
| `composite_layers` | Capas PNG superpuestas |
|
||||
| `none` | Sin renderizado visual |
|
||||
|
||||
### Availability
|
||||
|
||||
Controla cuándo la sección está disponible:
|
||||
|
||||
```typescript
|
||||
interface SectionAvailability {
|
||||
mode: 'required' | 'optional' | 'conditional';
|
||||
|
||||
// Si mode es 'conditional'
|
||||
condition?: JsonLogic;
|
||||
dependsOn?: AttrID[];
|
||||
|
||||
// Qué hacer al desactivarse
|
||||
onDeactivate?: {
|
||||
action: 'clear' | 'preserve' | 'reset';
|
||||
confirmWithUser?: boolean;
|
||||
confirmMessage?: I18nString;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo de sección:**
|
||||
|
||||
```typescript
|
||||
const seccionSalon: Section = {
|
||||
id: 'sc:salon',
|
||||
name: { es: 'Salón', en: 'Living room' },
|
||||
description: { es: 'Salón - comedor principal', en: 'Main living - dining room' },
|
||||
|
||||
// Atributos DE ESTA SECCIÓN
|
||||
attrs: [
|
||||
{
|
||||
id: 'at:suelo',
|
||||
name: { es: 'Suelo', en: 'Flooring' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:suelo_ceramica',
|
||||
options: [
|
||||
{ optionId: 'op:suelo_ceramica' },
|
||||
{ optionId: 'op:suelo_parquet' }
|
||||
],
|
||||
display: {
|
||||
uiVisible: true,
|
||||
affectsVisual: true, // ✅ Afecta al render
|
||||
affectsPrice: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'at:pared',
|
||||
name: { es: 'Color paredes', en: 'Wall color' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:pared_blanco',
|
||||
options: [ /* ... */ ],
|
||||
display: { uiVisible: true, affectsVisual: true }
|
||||
}
|
||||
],
|
||||
|
||||
// Configuración visual
|
||||
visualConfig: {
|
||||
strategy: 'static_image',
|
||||
imageUrlTemplate: '{basePath}/salon/{at_suelo}/{at_pared}/view.jpg',
|
||||
basePath: '/renders/vivienda',
|
||||
|
||||
// Atributos GLOBALES que afectan esta imagen
|
||||
globalAttrDependencies: ['at:calidad'],
|
||||
|
||||
// Atributos DE SECCIÓN que afectan
|
||||
sectionAttrDependencies: ['at:suelo', 'at:pared']
|
||||
},
|
||||
|
||||
// Disponibilidad
|
||||
availability: {
|
||||
mode: 'required' // Siempre visible
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Atributos
|
||||
|
||||
### Tipos de atributos
|
||||
|
||||
```typescript
|
||||
type Attribute =
|
||||
| FixedAttribute
|
||||
| DynamicAttribute
|
||||
| QuantifiableAttribute
|
||||
| ComputedAttribute;
|
||||
```
|
||||
|
||||
### 1. Fixed Attribute
|
||||
|
||||
Valor fijo, no modificable por el usuario.
|
||||
|
||||
```typescript
|
||||
interface FixedAttribute {
|
||||
type : 'fixed';
|
||||
dataType: DataType;
|
||||
value : Value;
|
||||
unit? : I18nString;
|
||||
display : AttributeDisplay;
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'at:m2_salon',
|
||||
name: { es: 'Superficie', en: 'Surface' },
|
||||
type: 'fixed',
|
||||
dataType: 'number',
|
||||
value: 25,
|
||||
unit: { es: 'm²', en: 'sqm' },
|
||||
display: {
|
||||
uiVisible: false, // ❌ No se muestra al usuario
|
||||
readonly: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dynamic Attribute
|
||||
|
||||
Seleccionable por el usuario entre opciones.
|
||||
|
||||
```typescript
|
||||
interface DynamicAttribute {
|
||||
type : 'dynamic';
|
||||
dataType : 'reference';
|
||||
defaultValue : OptionID;
|
||||
options : AttributeOption[];
|
||||
required? : boolean;
|
||||
isController? : boolean;
|
||||
controls? : SectionID[];
|
||||
filterExpression?: JsonLogic;
|
||||
display : AttributeDisplay;
|
||||
}
|
||||
```
|
||||
|
||||
**Opciones:**
|
||||
|
||||
```typescript
|
||||
interface AttributeOption {
|
||||
optionId : OptionID;
|
||||
priority? : number; // Orden de presentación
|
||||
pricingOverride?: number; // Precio custom para esta combinación
|
||||
metadata? : Metadata;
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'at:calidad',
|
||||
name: { es: 'Calidad de acabados', en: 'Finish quality' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:calidad_estandar',
|
||||
options: [
|
||||
{ optionId: 'op:calidad_estandar', priority: 1 },
|
||||
{ optionId: 'op:calidad_premium', priority: 2 },
|
||||
{ optionId: 'op:calidad_lujo', priority: 3 }
|
||||
],
|
||||
required: true,
|
||||
isController: true, // ✅ Controla otras secciones/atributos
|
||||
display: {
|
||||
uiVisible: true,
|
||||
affectsPrice: true,
|
||||
affectsVisual: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Quantifiable Attribute
|
||||
|
||||
Valor numérico configurable (cantidad, tamaño).
|
||||
|
||||
```typescript
|
||||
interface QuantifiableAttribute {
|
||||
type : 'quantifiable';
|
||||
dataType : 'number';
|
||||
quantity : QuantityDefinition;
|
||||
unit? : I18nString;
|
||||
userConfigurable?: boolean;
|
||||
pricePerUnit? : number;
|
||||
min? : number;
|
||||
max? : number;
|
||||
display : AttributeDisplay;
|
||||
}
|
||||
|
||||
type QuantityDefinition =
|
||||
| number // Fijo
|
||||
| { min: number; max: number; step?: number; default: number } // Rango
|
||||
| { values: number[]; default: number }; // Lista discreta
|
||||
```
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'at:cantidad_lamparas',
|
||||
name: { es: 'Número de lámparas', en: 'Number of lamps' },
|
||||
type: 'quantifiable',
|
||||
dataType: 'number',
|
||||
quantity: {
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 3
|
||||
},
|
||||
unit: { es: 'uds', en: 'units' },
|
||||
userConfigurable: true,
|
||||
pricePerUnit: 150, // 150€ por lámpara
|
||||
display: {
|
||||
uiVisible: true,
|
||||
affectsPrice: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Computed Attribute
|
||||
|
||||
Valor calculado dinámicamente basado en otros atributos.
|
||||
|
||||
```typescript
|
||||
interface ComputedAttribute {
|
||||
type : 'computed';
|
||||
dataType : DataType;
|
||||
unit? : I18nString;
|
||||
expression : JsonLogic;
|
||||
dependencies: AttrID[];
|
||||
display : AttributeDisplay;
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'at:precio_total',
|
||||
name: { es: 'Precio total', en: 'Total price' },
|
||||
type: 'computed',
|
||||
dataType: 'number',
|
||||
unit: { es: '€', en: '€' },
|
||||
expression: {
|
||||
'+': [
|
||||
{ var: 'attributes.at_precio_base' },
|
||||
{ var: 'attributes.at_extras' }
|
||||
]
|
||||
},
|
||||
dependencies: ['at:precio_base', 'at:extras'],
|
||||
display: {
|
||||
uiVisible: true,
|
||||
readonly: true, // ✅ Siempre readonly
|
||||
affectsPrice: false // Ya ES el precio
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Display Config
|
||||
|
||||
Controla cómo y cuándo se muestra un atributo:
|
||||
|
||||
```typescript
|
||||
interface AttributeDisplay {
|
||||
uiVisible? : boolean | JsonLogic; // ¿Se muestra?
|
||||
affectsVisual? : boolean | JsonLogic; // ¿Afecta al render?
|
||||
affectsPrice? : boolean | JsonLogic; // ¿Afecta al precio?
|
||||
readonly? : boolean; // ¿Solo lectura?
|
||||
order? : number; // Orden en UI
|
||||
icon? : string;
|
||||
helpText? : I18nString;
|
||||
}
|
||||
```
|
||||
|
||||
**Visibilidad condicional:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
display: {
|
||||
uiVisible: {
|
||||
'==': [{ var: 'attributes.at_tipo' }, 'avanzado']
|
||||
},
|
||||
affectsVisual: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Opciones
|
||||
|
||||
### `OptionDefinition`
|
||||
|
||||
Define una opción seleccionable (valor concreto de un atributo).
|
||||
|
||||
```typescript
|
||||
interface OptionDefinition {
|
||||
id : OptionID;
|
||||
name : I18nString;
|
||||
description : I18nString;
|
||||
media? : Media;
|
||||
pricing? : Pricing;
|
||||
tags? : string[];
|
||||
style? : string;
|
||||
metadata? : Metadata;
|
||||
}
|
||||
```
|
||||
|
||||
### Pricing
|
||||
|
||||
```typescript
|
||||
interface Pricing {
|
||||
baseAmount: number | 'consultation';
|
||||
currency?: string;
|
||||
dynamicExpression?: JsonLogic;
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'op:suelo_parquet',
|
||||
name: { es: 'Parquet', en: 'Parquet' },
|
||||
description: {
|
||||
es: 'Suelo de madera natural',
|
||||
en: 'Natural wood floor'
|
||||
},
|
||||
pricing: {
|
||||
baseAmount: 45, // €/m²
|
||||
currency: 'EUR',
|
||||
dynamicExpression: {
|
||||
// Descuento si calidad es lujo
|
||||
'if': [
|
||||
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] },
|
||||
{ '*': [45, 0.9] }, // 10% descuento
|
||||
45
|
||||
]
|
||||
}
|
||||
},
|
||||
tags: ['madera', 'calido', 'natural'],
|
||||
media: {
|
||||
thumbnail: '/images/parquet-thumb.jpg',
|
||||
images: ['/images/parquet-1.jpg', '/images/parquet-2.jpg']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reglas de validación
|
||||
|
||||
### `ValidationRule`
|
||||
|
||||
Define restricciones y dependencias entre atributos.
|
||||
|
||||
```typescript
|
||||
interface ValidationRule {
|
||||
id : RuleID;
|
||||
name : I18nString;
|
||||
condition : JsonLogic; // Cuándo se activa
|
||||
action : ValidationRuleAction; // Qué hace
|
||||
priority : number; // Mayor = más importante
|
||||
bidirectional: boolean;
|
||||
affects : AttrID[]; // Atributos involucrados
|
||||
severity : Severity; // 'error' | 'warning' | 'info'
|
||||
message : I18nString;
|
||||
}
|
||||
```
|
||||
|
||||
### Tipos de acción
|
||||
|
||||
```typescript
|
||||
type ValidationRuleActionType =
|
||||
| 'allow' // Lista blanca de valores permitidos
|
||||
| 'forbid' // Prohíbe valores específicos
|
||||
| 'require' // Hace obligatorio un atributo
|
||||
| 'suggest'; // Sugiere valores (no fuerza)
|
||||
|
||||
interface ValidationRuleAction {
|
||||
type : ValidationRuleActionType;
|
||||
targetAttr : AttrID;
|
||||
values : Value[];
|
||||
}
|
||||
```
|
||||
|
||||
### Ejemplos de reglas
|
||||
|
||||
#### 1. Regla FORBID
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'rl:estandar_no_lujo',
|
||||
name: {
|
||||
es: 'Estándar sin lujo',
|
||||
en: 'Standard without luxury'
|
||||
},
|
||||
|
||||
// CUANDO: calidad es estándar
|
||||
condition: {
|
||||
'==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar']
|
||||
},
|
||||
|
||||
// ENTONCES: prohíbe mármol en baño
|
||||
action: {
|
||||
type: 'forbid',
|
||||
targetAttr: 'at:revest_bano',
|
||||
values: ['op:revest_marmol']
|
||||
},
|
||||
|
||||
priority: 9,
|
||||
bidirectional: false,
|
||||
affects: ['at:calidad', 'at:revest_bano'],
|
||||
severity: 'error',
|
||||
|
||||
message: {
|
||||
es: 'El mármol no está disponible en calidad estándar',
|
||||
en: 'Marble is not available in standard quality'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Regla ALLOW
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'rl:roble_colores',
|
||||
name: {
|
||||
es: 'Roble colores permitidos',
|
||||
en: 'Oak allowed colors'
|
||||
},
|
||||
|
||||
// CUANDO: material es roble
|
||||
condition: {
|
||||
'==': [{ var: 'attributes.at_material' }, 'op:material_roble']
|
||||
},
|
||||
|
||||
// ENTONCES: solo permite blanco y natural
|
||||
action: {
|
||||
type: 'allow',
|
||||
targetAttr: 'at:color',
|
||||
values: ['op:color_blanco', 'op:color_natural']
|
||||
},
|
||||
|
||||
priority: 5,
|
||||
bidirectional: false,
|
||||
affects: ['at:material', 'at:color'],
|
||||
severity: 'error',
|
||||
|
||||
message: {
|
||||
es: 'Roble solo admite blanco o natural',
|
||||
en: 'Oak only allows white or natural'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Regla REQUIRE
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'rl:premium_require_material',
|
||||
name: {
|
||||
es: 'Premium requiere material',
|
||||
en: 'Premium requires material'
|
||||
},
|
||||
|
||||
// CUANDO: acabado es premium
|
||||
condition: {
|
||||
'==': [{ var: 'attributes.at_acabado' }, 'op:acabado_premium']
|
||||
},
|
||||
|
||||
// ENTONCES: material es obligatorio
|
||||
action: {
|
||||
type: 'require',
|
||||
targetAttr: 'at:material',
|
||||
values: [] // No importa qué valor, pero debe tener uno
|
||||
},
|
||||
|
||||
priority: 8,
|
||||
bidirectional: false,
|
||||
affects: ['at:acabado', 'at:material'],
|
||||
severity: 'error',
|
||||
|
||||
message: {
|
||||
es: 'El acabado premium requiere seleccionar material',
|
||||
en: 'Premium finish requires material selection'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Regla BIDIRECCIONAL
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'rl:negro_premium',
|
||||
name: {
|
||||
es: 'Negro requiere premium',
|
||||
en: 'Black requires premium'
|
||||
},
|
||||
|
||||
// DIRECCIÓN 1: sanitario negro → prohíbe calidad estándar
|
||||
condition: {
|
||||
'==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro']
|
||||
},
|
||||
action: {
|
||||
type: 'forbid',
|
||||
targetAttr: 'at:calidad',
|
||||
values: ['op:calidad_estandar']
|
||||
},
|
||||
|
||||
// ⚠️ IMPORTANTE: Para bidireccional, crear 2 reglas separadas es más claro:
|
||||
// - Regla 1: negro → prohíbe estándar
|
||||
// - Regla 2: estándar → prohíbe negro
|
||||
|
||||
priority: 8,
|
||||
bidirectional: false, // ✅ Usar false y crear regla inversa manualmente
|
||||
affects: ['at:sanitario', 'at:calidad'],
|
||||
severity: 'error',
|
||||
|
||||
message: {
|
||||
es: 'Los sanitarios negros requieren calidad premium o superior',
|
||||
en: 'Black sanitary ware requires premium quality or higher'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prioridades
|
||||
|
||||
Las reglas se evalúan en orden de prioridad **descendente**:
|
||||
|
||||
- **10-15**: Reglas críticas (incompatibilidades técnicas)
|
||||
- **5-9**: Reglas importantes (restricciones de negocio)
|
||||
- **1-4**: Reglas sugerencias (recomendaciones estéticas)
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo completo
|
||||
|
||||
Ver archivo completo: [`housing-catalog.fixture.ts`](../tests/fixtures/housing-catalog.fixture.ts)
|
||||
|
||||
### Estructura mínima
|
||||
|
||||
```typescript
|
||||
const CATALOGO_MINIMO: ConfigurationCatalog = {
|
||||
name: { es: 'Mi Catálogo', en: 'My Catalog' },
|
||||
supportedLocales: ['es', 'en'],
|
||||
defaultLocale: 'es',
|
||||
|
||||
// 1. Definir opciones
|
||||
options: {
|
||||
'op:color_rojo': {
|
||||
id: 'op:color_rojo',
|
||||
name: { es: 'Rojo', en: 'Red' },
|
||||
description: { es: 'Color rojo', en: 'Red color' },
|
||||
pricing: { baseAmount: 0 }
|
||||
},
|
||||
'op:color_azul': {
|
||||
id: 'op:color_azul',
|
||||
name: { es: 'Azul', en: 'Blue' },
|
||||
description: { es: 'Color azul', en: 'Blue color' },
|
||||
pricing: { baseAmount: 50 }
|
||||
}
|
||||
},
|
||||
|
||||
// 2. Definir objetos
|
||||
objects: {
|
||||
'ob:producto': {
|
||||
id: 'ob:producto',
|
||||
name: { es: 'Producto', en: 'Product' },
|
||||
description: { es: 'Mi producto', en: 'My product' },
|
||||
|
||||
// Atributos globales
|
||||
attributes: [
|
||||
{
|
||||
id: 'at:color',
|
||||
name: { es: 'Color', en: 'Color' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:color_rojo',
|
||||
options: [
|
||||
{ optionId: 'op:color_rojo' },
|
||||
{ optionId: 'op:color_azul' }
|
||||
],
|
||||
display: { uiVisible: true, affectsPrice: true }
|
||||
}
|
||||
],
|
||||
|
||||
// Secciones
|
||||
sections: {
|
||||
'sc:principal': {
|
||||
id: 'sc:principal',
|
||||
name: { es: 'Principal', en: 'Main' },
|
||||
description: { es: 'Sección principal', en: 'Main section' },
|
||||
attrs: [],
|
||||
visualConfig: {
|
||||
strategy: 'static_image',
|
||||
imageUrlTemplate: '/images/{at_color}.jpg'
|
||||
},
|
||||
availability: { mode: 'required' }
|
||||
}
|
||||
},
|
||||
|
||||
sectionOrder: ['sc:principal']
|
||||
}
|
||||
},
|
||||
|
||||
// 3. Definir reglas (opcional)
|
||||
rules: {}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Recursos adicionales
|
||||
|
||||
- [Sistema de tipos](./TYPES.md)
|
||||
- [Reglas de validación](./RULES.md)
|
||||
- [i18n](./I18N.md)
|
||||
- [Guía de creación](./CATALOG_GUIDE.md)
|
||||
@ -0,0 +1,258 @@
|
||||
# CPQ Engine - Motor de Configuración de Producto
|
||||
|
||||
Sistema TypeScript type-safe para configuración de productos complejos (Configure-Price-Quote) con soporte i18n, reglas de validación declarativas y renderizado visual condicional.
|
||||
|
||||
## 🎯 Características principales
|
||||
|
||||
- **Type-safe**: Sistema de tipos robusto con IDs con prefijos branded
|
||||
- **i18n nativo**: Soporte multiidioma en todos los elementos del modelo
|
||||
- **Reglas declarativas**: Motor de validación basado en JsonLogic
|
||||
- **Visual rendering**: Generación dinámica de imágenes basada en selecciones
|
||||
- **Arquitectura modular**: Separación clara entre modelo, motor y utilidades
|
||||
- **Testing completo**: >180 tests unitarios con Vitest
|
||||
|
||||
## 📦 Estructura del proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── types/ # Definiciones de tipos TypeScript
|
||||
│ ├── core/ # Tipos base (Value, ID, JsonLogic)
|
||||
│ ├── model/ # Tipos del modelo (Attribute, Section, Rule)
|
||||
│ └── i18n/ # Sistema de internacionalización
|
||||
├── engine/ # Motores de evaluación
|
||||
│ ├── json-logic/ # Evaluador JsonLogic
|
||||
│ └── rule-engine.ts # Motor de reglas de validación
|
||||
├── utils/ # Utilidades
|
||||
│ ├── ids/ # Sistema de IDs con prefijos
|
||||
│ └── i18n/ # Funciones de traducción
|
||||
├── messages/ # Sistema de mensajes centralizados
|
||||
│ ├── errors.ts # Mensajes de error
|
||||
│ ├── warnings.ts # Mensajes de advertencia
|
||||
│ └── logger.ts # Logger estructurado
|
||||
└── constants/ # Constantes globales
|
||||
└── defaults.ts # Valores por defecto
|
||||
```
|
||||
|
||||
## 🚀 Inicio rápido
|
||||
|
||||
### Instalación
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Ejecutar tests
|
||||
|
||||
```bash
|
||||
# Todos los tests
|
||||
npm test
|
||||
|
||||
# Con coverage
|
||||
npm test -- --coverage
|
||||
|
||||
# Watch mode
|
||||
npm test -- --watch
|
||||
```
|
||||
|
||||
### Ejemplo básico
|
||||
|
||||
```typescript
|
||||
import { CATALOGO_VIVIENDAS } from './fixtures/housing-catalog';
|
||||
import { RuleEngine } from './engine/rule-engine';
|
||||
|
||||
// Obtener un objeto configurable
|
||||
const apartamento = CATALOGO_VIVIENDAS.objects['ob:apartamento'];
|
||||
|
||||
// Crear estado de selección
|
||||
const seleccion = {
|
||||
'at:calidad': 'op:calidad_premium',
|
||||
'at:suelo': 'op:suelo_parquet',
|
||||
'at:sanitario': 'op:sanit_negro'
|
||||
};
|
||||
|
||||
// Validar con reglas
|
||||
const engine = new RuleEngine();
|
||||
const reglas = Object.values(CATALOGO_VIVIENDAS.rules);
|
||||
|
||||
const esValido = engine.isValid(reglas, seleccion);
|
||||
const violaciones = engine.getViolations(reglas, seleccion);
|
||||
|
||||
console.log('¿Configuración válida?', esValido);
|
||||
console.log('Violaciones:', violaciones);
|
||||
```
|
||||
|
||||
## 📚 Documentación
|
||||
|
||||
- [**Modelo de datos**](./docs/MODEL.md) - Estructura completa del modelo
|
||||
- [**Sistema de tipos**](./docs/TYPES.md) - Tipos TypeScript y branded IDs
|
||||
- [**Reglas de validación**](./docs/RULES.md) - Cómo crear y usar reglas
|
||||
- [**i18n**](./docs/I18N.md) - Sistema de internacionalización
|
||||
- [**Motor de renderizado**](./docs/RENDERING.md) - Generación de imágenes
|
||||
- [**Guía de creación**](./docs/CATALOG_GUIDE.md) - Cómo crear un catálogo completo
|
||||
|
||||
## 🏗️ Arquitectura
|
||||
|
||||
### Flujo de datos
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Catalog JSON │
|
||||
│ (Definición) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────┐
|
||||
│ Type System │────▶│ Validation │
|
||||
│ (Compile-time) │ │ (Runtime) │
|
||||
└────────┬────────┘ └──────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────┐
|
||||
│ User Selection │────▶│ Rule Engine │
|
||||
│ (State) │ │ (JsonLogic) │
|
||||
└────────┬────────┘ └──────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────┐
|
||||
│ Visual Render │ │ Pricing │
|
||||
│ (Images) │ │ (Calculate) │
|
||||
└─────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Capas
|
||||
|
||||
1. **Types Layer** - Definiciones de tipos, contratos
|
||||
2. **Model Layer** - Estructura del catálogo (Objects, Sections, Attributes)
|
||||
3. **Engine Layer** - Lógica de negocio (Rules, JsonLogic, Rendering)
|
||||
4. **Utils Layer** - Funciones auxiliares (IDs, i18n, logging)
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
El proyecto tiene >180 tests organizados en:
|
||||
|
||||
- **Unit tests** - Utilidades individuales (IDs, i18n, JsonLogic)
|
||||
- **Integration tests** - Motor de reglas completo
|
||||
- **Fixture tests** - Catálogo de viviendas real
|
||||
|
||||
```bash
|
||||
# Ver coverage
|
||||
npm test -- --coverage
|
||||
|
||||
# Coverage actual: ~95%
|
||||
```
|
||||
|
||||
## 🌍 i18n
|
||||
|
||||
Sistema de internacionalización con 9 idiomas soportados:
|
||||
|
||||
```typescript
|
||||
const mensaje: I18nString = {
|
||||
es: 'Hola mundo',
|
||||
en: 'Hello world',
|
||||
de: 'Hallo Welt',
|
||||
fr: 'Bonjour le monde',
|
||||
it: 'Ciao mondo',
|
||||
pt: 'Olá mundo',
|
||||
ca: 'Hola món',
|
||||
eu: 'Kaixo mundua',
|
||||
gl: 'Ola mundo'
|
||||
};
|
||||
|
||||
// Uso
|
||||
const texto = translate(mensaje, 'es'); // → "Hola mundo"
|
||||
```
|
||||
|
||||
**Strings simples** también son válidos:
|
||||
|
||||
```typescript
|
||||
const simple: I18nString = 'OK'; // Válido en todos los idiomas
|
||||
```
|
||||
|
||||
## 🎨 Ejemplo de modelo real
|
||||
|
||||
El proyecto incluye un **catálogo completo de viviendas** como fixture:
|
||||
|
||||
- 2 modelos: Apartamento (4 secciones) y Dúplex (5 secciones)
|
||||
- 24 opciones de acabados (suelos, paredes, sanitarios, etc.)
|
||||
- 6 reglas de validación con prioridades
|
||||
- Generación de imágenes con templates
|
||||
- Pricing dinámico
|
||||
|
||||
Ver: [`housing-catalog.fixture.ts`](./tests/fixtures/housing-catalog.fixture.ts)
|
||||
|
||||
## 🔧 Tecnologías
|
||||
|
||||
- **TypeScript 5.x** - Lenguaje principal
|
||||
- **Vitest** - Framework de testing
|
||||
- **JsonLogic** - Motor de reglas declarativas
|
||||
- **Branded Types** - IDs type-safe
|
||||
|
||||
## 📝 Convenciones
|
||||
|
||||
### IDs con prefijos
|
||||
|
||||
Todos los IDs usan prefijos branded:
|
||||
|
||||
| Tipo | Prefijo | Ejemplo |
|
||||
|------|---------|---------|
|
||||
| Attribute | `at:` | `at:calidad` |
|
||||
| Section | `sc:` | `sc:salon` |
|
||||
| Option | `op:` | `op:premium` |
|
||||
| Object | `ob:` | `ob:apartamento` |
|
||||
| Rule | `rl:` | `rl:negro_premium` |
|
||||
| View | `vw:` | `vw:front` |
|
||||
| Hotspot | `hs:` | `hs:punto1` |
|
||||
|
||||
### Mensajes
|
||||
|
||||
Todos los mensajes de error/warning/info están **centralizados** en `src/messages/`:
|
||||
|
||||
```typescript
|
||||
import { ERRORS, logger } from '@/messages';
|
||||
|
||||
// ❌ NO hacer
|
||||
throw new Error('Invalid ID format');
|
||||
|
||||
// ✅ Hacer
|
||||
const errorMsg = ERRORS.INVALID_ID_FORMAT(id);
|
||||
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id });
|
||||
throw new Error(JSON.stringify(errorMsg));
|
||||
```
|
||||
|
||||
### Naming
|
||||
|
||||
- **Types**: PascalCase (`AttributeDisplay`, `ValidationRule`)
|
||||
- **Interfaces**: PascalCase con prefijo `I` opcional
|
||||
- **Functions**: camelCase (`extractIdPart`, `isValid`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`DEFAULTS`, `MESSAGE_CATEGORIES`)
|
||||
- **Files**: kebab-case (`rule-engine.ts`, `id-parser.ts`)
|
||||
|
||||
## 🤝 Contribuir
|
||||
|
||||
1. Fork el proyecto
|
||||
2. Crea una rama (`git checkout -b feature/amazing`)
|
||||
3. Commit cambios (`git commit -m 'Add amazing feature'`)
|
||||
4. Push a la rama (`git push origin feature/amazing`)
|
||||
5. Abre un Pull Request
|
||||
|
||||
### Requisitos para PR
|
||||
|
||||
- ✅ Todos los tests pasan
|
||||
- ✅ Coverage >90%
|
||||
- ✅ Sin errores de TypeScript
|
||||
- ✅ Mensajes centralizados (no inline)
|
||||
- ✅ Documentación actualizada
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
MIT License - ver [LICENSE](LICENSE)
|
||||
|
||||
## 🙏 Agradecimientos
|
||||
|
||||
- [JsonLogic](http://jsonlogic.com/) - Motor de reglas declarativas
|
||||
- [Vitest](https://vitest.dev/) - Framework de testing
|
||||
|
||||
---
|
||||
|
||||
**Versión**: 1.0.0
|
||||
**Última actualización**: Febrero 2026
|
||||
@ -0,0 +1,338 @@
|
||||
# Sistema de Vistas - Integración Completa
|
||||
|
||||
## 📐 Conceptos clave
|
||||
|
||||
Cada **Section** tiene múltiples **Views** (vistas) que representan diferentes ángulos o perspectivas visuales de esa sección.
|
||||
|
||||
```
|
||||
Section (ej: Salón)
|
||||
├── View: Front (frontal)
|
||||
├── View: Side (lateral)
|
||||
└── View: Top (cenital)
|
||||
```
|
||||
|
||||
## 🏗️ Estructura
|
||||
|
||||
### Section (actualizada)
|
||||
|
||||
```typescript
|
||||
interface Section {
|
||||
id: SectionID;
|
||||
name: I18nString;
|
||||
attrs: Attribute[];
|
||||
|
||||
// ✅ NUEVO: Vistas disponibles (SIEMPRE presente)
|
||||
views: Record<ViewID, SectionView>;
|
||||
|
||||
// ✅ NUEVO: Vista por defecto (SIEMPRE presente)
|
||||
defaultView: ViewID;
|
||||
|
||||
availability: SectionAvailability;
|
||||
}
|
||||
```
|
||||
|
||||
### SectionView
|
||||
|
||||
```typescript
|
||||
interface SectionView {
|
||||
id: ViewID; // 'vw:front', 'vw:side', 'vw:top'
|
||||
name: I18nString;
|
||||
description?: I18nString;
|
||||
visualConfig: SectionVisualConfig; // Configuración ESPECÍFICA de esta vista
|
||||
order?: number;
|
||||
icon?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 Ejemplo completo
|
||||
|
||||
```typescript
|
||||
import type { Section, SectionView } from '@/types';
|
||||
|
||||
const VIEW_FRONT : ViewID = 'vw:front';
|
||||
const VIEW_SIDE : ViewID = 'vw:side';
|
||||
const VIEW_TOP : ViewID = 'vw:top';
|
||||
|
||||
const seccionSalon: Section = {
|
||||
id: 'sc:salon',
|
||||
name: { es: 'Salón', en: 'Living room' },
|
||||
description: { es: 'Salón - comedor principal', en: 'Main living - dining room' },
|
||||
|
||||
attrs: [
|
||||
{
|
||||
id: 'at:suelo',
|
||||
name: { es: 'Suelo', en: 'Flooring' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:suelo_ceramica',
|
||||
options: [
|
||||
{ optionId: 'op:suelo_ceramica' },
|
||||
{ optionId: 'op:suelo_parquet' }
|
||||
],
|
||||
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
|
||||
},
|
||||
{
|
||||
id: 'at:pared',
|
||||
name: { es: 'Color paredes', en: 'Wall color' },
|
||||
type: 'dynamic',
|
||||
dataType: 'reference',
|
||||
defaultValue: 'op:pared_blanco',
|
||||
options: [
|
||||
{ optionId: 'op:pared_blanco' },
|
||||
{ optionId: 'op:pared_gris' }
|
||||
],
|
||||
display: { uiVisible: true, affectsVisual: true }
|
||||
}
|
||||
],
|
||||
|
||||
// ✅ VISTAS: Diferentes ángulos de la misma sección
|
||||
views: {
|
||||
[VIEW_FRONT]: {
|
||||
id: VIEW_FRONT,
|
||||
name: { es: 'Vista frontal', en: 'Front view' },
|
||||
description: { es: 'Vista de frente del salón', en: 'Front view of living room' },
|
||||
visualConfig: {
|
||||
strategy: 'static_image',
|
||||
// Template incluye {view} para diferenciar ángulos
|
||||
imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}/{at_pared}.jpg',
|
||||
basePath: '/renders/vivienda',
|
||||
globalAttrDependencies: ['at:calidad'],
|
||||
sectionAttrDependencies: ['at:suelo', 'at:pared']
|
||||
},
|
||||
order: 1,
|
||||
icon: 'camera-front'
|
||||
},
|
||||
|
||||
[VIEW_SIDE]: {
|
||||
id: VIEW_SIDE,
|
||||
name: { es: 'Vista lateral', en: 'Side view' },
|
||||
description: { es: 'Vista lateral del salón', en: 'Side view of living room' },
|
||||
visualConfig: {
|
||||
strategy: 'static_image',
|
||||
imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}.jpg',
|
||||
basePath: '/renders/vivienda',
|
||||
globalAttrDependencies: ['at:calidad'],
|
||||
// Solo el suelo afecta esta vista
|
||||
sectionAttrDependencies: ['at:suelo']
|
||||
},
|
||||
order: 2,
|
||||
icon: 'camera-side'
|
||||
},
|
||||
|
||||
[VIEW_TOP]: {
|
||||
id: VIEW_TOP,
|
||||
name: { es: 'Vista cenital', en: 'Top view' },
|
||||
description: { es: 'Vista desde arriba', en: 'Top-down view' },
|
||||
visualConfig: {
|
||||
strategy: 'static_image',
|
||||
imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}/{at_muebles}.jpg',
|
||||
basePath: '/renders/vivienda',
|
||||
globalAttrDependencies: ['at:calidad'],
|
||||
// Vista cenital muestra suelo y distribución de muebles
|
||||
sectionAttrDependencies: ['at:suelo', 'at:muebles']
|
||||
},
|
||||
order: 3,
|
||||
icon: 'camera-top'
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ Vista por defecto (la primera que se muestra al usuario)
|
||||
defaultView: VIEW_FRONT,
|
||||
|
||||
availability: { mode: 'required' }
|
||||
};
|
||||
```
|
||||
|
||||
## 🔄 Generación de URLs
|
||||
|
||||
### Placeholders en `imageUrlTemplate`
|
||||
|
||||
El template soporta estos placeholders:
|
||||
|
||||
| Placeholder | Reemplazado por | Ejemplo |
|
||||
|-------------|----------------|---------|
|
||||
| `{basePath}` | basePath de la config | `/renders/vivienda` |
|
||||
| `{section}` | ID de sección sin prefijo | `salon` (de `sc:salon`) |
|
||||
| `{view}` | ID de vista sin prefijo | `front` (de `vw:front`) |
|
||||
| `{at_<id>}` | Valor del atributo normalizado | `parquet` (de `at:suelo` = `op:suelo_parquet`) |
|
||||
|
||||
### Ejemplo de resolución
|
||||
|
||||
**Estado:**
|
||||
```typescript
|
||||
const state = {
|
||||
'at:suelo': 'op:suelo_parquet',
|
||||
'at:pared': 'op:pared_gris',
|
||||
'at:calidad': 'op:calidad_premium'
|
||||
};
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```
|
||||
{basePath}/{section}/{view}/{at_suelo}/{at_pared}.jpg
|
||||
```
|
||||
|
||||
**URL resultante para vista frontal:**
|
||||
```
|
||||
/renders/vivienda/salon/front/parquet/gris.jpg
|
||||
```
|
||||
|
||||
**URL resultante para vista lateral:**
|
||||
```
|
||||
/renders/vivienda/salon/side/parquet.jpg
|
||||
```
|
||||
|
||||
## 🎨 Uso en el UI
|
||||
|
||||
```typescript
|
||||
// Obtener sección
|
||||
const salon = apartamento.sections['sc:salon'];
|
||||
|
||||
// Listar todas las vistas disponibles
|
||||
const vistasFront = Object.values(salon.views)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
console.log(vistasFront);
|
||||
// [
|
||||
// { id: 'vw:front', name: { es: 'Vista frontal' }, ... },
|
||||
// { id: 'vw:side', name: { es: 'Vista lateral' }, ... },
|
||||
// { id: 'vw:top', name: { es: 'Vista cenital' }, ... }
|
||||
// ]
|
||||
|
||||
// Vista activa (por defecto)
|
||||
const vistaActual = salon.views[salon.defaultView];
|
||||
|
||||
// Cambiar vista
|
||||
const nuevaVista = salon.views['vw:side'];
|
||||
|
||||
// Generar URL de la vista actual
|
||||
const urlImagen = generarImagenUrl(
|
||||
salon.id,
|
||||
vistaActual.id,
|
||||
vistaActual.visualConfig,
|
||||
state
|
||||
);
|
||||
```
|
||||
|
||||
## 🏭 Estrategias de renderizado por vista
|
||||
|
||||
Cada vista puede tener su propia estrategia:
|
||||
|
||||
```typescript
|
||||
views: {
|
||||
'vw:front': {
|
||||
id: 'vw:front',
|
||||
name: { es: 'Frontal', en: 'Front' },
|
||||
visualConfig: {
|
||||
strategy: 'static_image', // ← Imagen estática
|
||||
imageUrlTemplate: '...'
|
||||
}
|
||||
},
|
||||
'vw:360': {
|
||||
id: 'vw:360',
|
||||
name: { es: 'Vista 360º', en: '360º view' },
|
||||
visualConfig: {
|
||||
strategy: 'three_d', // ← Modelo 3D interactivo
|
||||
threeDConfig: {
|
||||
modelUrl: '/models/salon.glb',
|
||||
textureAttrs: [
|
||||
{ attrId: 'at:suelo', scope: 'section' },
|
||||
{ attrId: 'at:pared', scope: 'section' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
'vw:ar': {
|
||||
id: 'vw:ar',
|
||||
name: { es: 'Realidad Aumentada', en: 'AR' },
|
||||
visualConfig: {
|
||||
strategy: 'api_generated', // ← API externa
|
||||
apiConfig: {
|
||||
endpoint: 'https://ar.example.com/generate',
|
||||
method: 'POST',
|
||||
timeout: 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 Vistas contextuales
|
||||
|
||||
Las vistas pueden mostrarse condicionalmente:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'vw:detail_plumbing',
|
||||
name: { es: 'Detalle fontanería', en: 'Plumbing detail' },
|
||||
visualConfig: {
|
||||
strategy: 'static_image',
|
||||
imageUrlTemplate: '{basePath}/{section}/detail/{at_sanitario}.jpg'
|
||||
},
|
||||
// Nota: No hay campo 'visible' aquí porque las vistas
|
||||
// siempre están disponibles. El UI decide si mostrarlas.
|
||||
order: 10 // Ordenar al final
|
||||
}
|
||||
```
|
||||
|
||||
## 🔑 Claves de diseño
|
||||
|
||||
### 1. Cada sección DEBE tener al menos una vista
|
||||
|
||||
```typescript
|
||||
// ❌ MAL
|
||||
views: {}
|
||||
|
||||
// ✅ BIEN
|
||||
views: {
|
||||
'vw:default': {
|
||||
id: 'vw:default',
|
||||
name: { es: 'Vista principal', en: 'Main view' },
|
||||
visualConfig: { /* ... */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. defaultView DEBE existir en views
|
||||
|
||||
```typescript
|
||||
// ❌ MAL
|
||||
views: { 'vw:front': { /* ... */ } },
|
||||
defaultView: 'vw:side' // ← No existe!
|
||||
|
||||
// ✅ BIEN
|
||||
views: {
|
||||
'vw:front': { /* ... */ },
|
||||
'vw:side': { /* ... */ }
|
||||
},
|
||||
defaultView: 'vw:front' // ← Existe
|
||||
```
|
||||
|
||||
### 3. Los atributos que afectan CUALQUIER vista deben estar en dependencies
|
||||
|
||||
```typescript
|
||||
// Vista frontal usa: suelo, pared
|
||||
// Vista lateral usa: suelo
|
||||
// Vista cenital usa: suelo, muebles
|
||||
|
||||
// En la sección, marca affectsVisual: true para todos
|
||||
attrs: [
|
||||
{ id: 'at:suelo', display: { affectsVisual: true } }, // ✅ Afecta 3 vistas
|
||||
{ id: 'at:pared', display: { affectsVisual: true } }, // ✅ Afecta 1 vista
|
||||
{ id: 'at:muebles', display: { affectsVisual: true } } // ✅ Afecta 1 vista
|
||||
]
|
||||
```
|
||||
|
||||
## 🎯 Ventajas del sistema de vistas
|
||||
|
||||
✅ **Flexibilidad**: Cada vista puede tener diferente estrategia de renderizado
|
||||
✅ **Optimización**: Solo cargar assets de la vista activa
|
||||
✅ **Claridad**: Separación explícita de ángulos/perspectivas
|
||||
✅ **Escalabilidad**: Añadir nuevas vistas sin modificar la estructura
|
||||
✅ **UX**: Usuario puede cambiar de vista según necesidad
|
||||
|
||||
## 📚 Recursos
|
||||
|
||||
- [Modelo completo](./MODEL.md)
|
||||
- [Visual Rendering](./RENDERING.md)
|
||||
- [URL Builder](./URL_BUILDER.md)
|
||||
@ -0,0 +1,346 @@
|
||||
<!--
|
||||
============================================================================
|
||||
CONFIGURATOR - Example Svelte Component
|
||||
============================================================================
|
||||
|
||||
Ejemplo de cómo usar el ConfigurationStore en un componente Svelte.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createConfigurationStore } from '@/svelte/stores';
|
||||
import type { ConfigurationCatalog } from '@/types';
|
||||
|
||||
// Props
|
||||
export let catalog: ConfigurationCatalog;
|
||||
export let objectId: string;
|
||||
|
||||
// Crear el store
|
||||
const config = createConfigurationStore({
|
||||
catalog,
|
||||
objectId,
|
||||
autoValidate: true
|
||||
});
|
||||
|
||||
// Acceso reactivo al estado
|
||||
$: currentSection = $config.currentSection;
|
||||
$: currentView = $config.currentView;
|
||||
$: selection = $config.selection;
|
||||
$: validation = $config.validation;
|
||||
$: isValid = $config.isValid;
|
||||
|
||||
// Handlers
|
||||
function handleOptionSelect(attrId: string, optionId: string) {
|
||||
config.selectOption(attrId, optionId);
|
||||
}
|
||||
|
||||
function handleSectionChange(sectionId: string) {
|
||||
config.setCurrentSection(sectionId);
|
||||
}
|
||||
|
||||
function handleViewChange(viewId: string) {
|
||||
config.setCurrentView(viewId);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
config.reset();
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
config.undo();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="configurator">
|
||||
<!-- Header -->
|
||||
<header class="configurator-header">
|
||||
<h1>{$config.object.name.es}</h1>
|
||||
<p>{$config.object.description.es}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={handleUndo}>
|
||||
⬅️ Deshacer
|
||||
</button>
|
||||
<button on:click={handleReset}>
|
||||
🔄 Resetear
|
||||
</button>
|
||||
<button disabled={!isValid}>
|
||||
✅ Confirmar configuración
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="configurator-content">
|
||||
<!-- Section Navigation -->
|
||||
<nav class="section-nav">
|
||||
<h2>Secciones</h2>
|
||||
<ul>
|
||||
{#each Object.values($config.object.sections) as section}
|
||||
<li>
|
||||
<button
|
||||
class:active={section.id === currentSection?.id}
|
||||
on:click={() => handleSectionChange(section.id)}
|
||||
>
|
||||
{section.icon || '📦'} {section.name.es}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Attributes Panel -->
|
||||
<div class="attributes-panel">
|
||||
{#if currentSection}
|
||||
<h2>{currentSection.name.es}</h2>
|
||||
<p>{currentSection.description.es}</p>
|
||||
|
||||
<!-- Atributos de la sección -->
|
||||
{#each currentSection.attrs as attr}
|
||||
{#if attr.type === 'dynamic' && attr.display?.uiVisible}
|
||||
<div class="attribute">
|
||||
<label for={attr.id}>
|
||||
{attr.name.es}
|
||||
{#if config.isRequired(attr.id)}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<select
|
||||
id={attr.id}
|
||||
value={selection[attr.id] || ''}
|
||||
on:change={(e) => handleOptionSelect(attr.id, e.target.value)}
|
||||
class:error={config.hasErrorsForAttribute(attr.id)}
|
||||
>
|
||||
<option value="">-- Seleccionar --</option>
|
||||
{#each attr.options as opt}
|
||||
{@const option = catalog.options[opt.optionId]}
|
||||
{#if option}
|
||||
<option value={option.id}>
|
||||
{option.name.es}
|
||||
{#if option.pricing?.baseAmount}
|
||||
(+{option.pricing.baseAmount}€)
|
||||
{/if}
|
||||
</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Violaciones -->
|
||||
{#each config.getViolationsForAttribute(attr.id) as violation}
|
||||
<div class="violation {violation.severity}">
|
||||
{violation.rule.message.es}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Visual Preview -->
|
||||
<div class="visual-panel">
|
||||
{#if currentView}
|
||||
<div class="view-selector">
|
||||
<h3>Vistas</h3>
|
||||
{#each Object.values(currentSection.views || {}) as view}
|
||||
<button
|
||||
class:active={view.id === currentView.id}
|
||||
on:click={() => handleViewChange(view.id)}
|
||||
>
|
||||
{view.icon || '📷'} {view.name.es}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<h3>{currentView.name.es}</h3>
|
||||
<!-- Aquí iría el renderizado de la imagen -->
|
||||
<div class="image-placeholder">
|
||||
<p>Vista: {currentView.id}</p>
|
||||
<p>Template: {currentView.visualConfig.imageUrlTemplate}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-visual">
|
||||
<p>Esta sección no tiene visualización</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Summary -->
|
||||
{#if validation}
|
||||
<footer class="validation-summary">
|
||||
{#if validation.hasErrors}
|
||||
<div class="errors">
|
||||
❌ {validation.violations.filter(v => v.severity === 'error').length} errores
|
||||
</div>
|
||||
{/if}
|
||||
{#if validation.hasWarnings}
|
||||
<div class="warnings">
|
||||
⚠️ {validation.violations.filter(v => v.severity === 'warning').length} advertencias
|
||||
</div>
|
||||
{/if}
|
||||
{#if validation.isValid}
|
||||
<div class="success">
|
||||
✅ Configuración válida
|
||||
</div>
|
||||
{/if}
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.configurator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.configurator-header {
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.configurator-content {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-nav {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-nav button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-nav button:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.section-nav button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.attributes-panel {
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.attribute {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.attribute label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.attribute select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.attribute select.error {
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
.violation {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.violation.error {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.violation.warning {
|
||||
background: #ffc;
|
||||
color: #840;
|
||||
}
|
||||
|
||||
.visual-panel {
|
||||
padding: 2rem;
|
||||
background: #f9f9f9;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.view-selector {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.view-selector button {
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-selector button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
background: #ddd;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.validation-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.errors {
|
||||
color: #c00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warnings {
|
||||
color: #840;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #080;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* SVELTE CONFIGURATION STORE
|
||||
* ============================================================================
|
||||
*
|
||||
* Adapter que expone ConfigurationState como Svelte store.
|
||||
*/
|
||||
|
||||
import { writable, derived, type Readable } from 'svelte/store';
|
||||
import type {
|
||||
ConfigurableObject,
|
||||
ConfigurationCatalog,
|
||||
AttrID,
|
||||
OptionID,
|
||||
SectionID,
|
||||
ViewID,
|
||||
Section,
|
||||
SectionView
|
||||
} from '@/types';
|
||||
import {
|
||||
ConfigurationState,
|
||||
type ConfigurationStateOptions
|
||||
} from '@/state/ConfigurationState';
|
||||
import type { ValidationResult } from '@/state/ValidationState';
|
||||
import type { SelectionStateData } from '@/state/SelectionState';
|
||||
|
||||
// ============================================================================
|
||||
// STORE STATE INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
export interface ConfigurationStoreState {
|
||||
catalog : ConfigurationCatalog;
|
||||
object : ConfigurableObject;
|
||||
selection : SelectionStateData;
|
||||
validation : ValidationResult | null;
|
||||
currentSection : Section | null;
|
||||
currentSectionId: SectionID | null;
|
||||
currentView : SectionView | null;
|
||||
currentViewId : ViewID | null;
|
||||
isValid : boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION STORE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Crea un store de configuración reactivo para Svelte
|
||||
*/
|
||||
export function createConfigurationStore(options: ConfigurationStateOptions) {
|
||||
// Estado core (framework agnostic)
|
||||
const state = new ConfigurationState(options);
|
||||
|
||||
// Store interno con versión para forzar actualizaciones
|
||||
const { subscribe, set, update } = writable(getStoreState());
|
||||
|
||||
// Función helper para obtener el estado actual del store
|
||||
function getStoreState(): ConfigurationStoreState {
|
||||
return {
|
||||
catalog : state.getCatalog(),
|
||||
object : state.getObject(),
|
||||
selection : state.getAllSelections(),
|
||||
validation : state.getValidationResult(),
|
||||
currentSection : state.getCurrentSection(),
|
||||
currentSectionId: state.getCurrentSectionId(),
|
||||
currentView : state.getCurrentView(),
|
||||
currentViewId : state.getCurrentViewId(),
|
||||
isValid : state.isValid()
|
||||
};
|
||||
}
|
||||
|
||||
// Suscribirse a cambios del estado core
|
||||
state.subscribe(() => {
|
||||
set(getStoreState());
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// PUBLIC API
|
||||
// ========================================================================
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Selection actions
|
||||
selectOption: (attrId: AttrID, optionId: OptionID) => {
|
||||
state.selectOption(attrId, optionId);
|
||||
},
|
||||
|
||||
clearSelection: (attrId: AttrID) => {
|
||||
state.clearSelection(attrId);
|
||||
},
|
||||
|
||||
// Navigation actions
|
||||
setCurrentSection: (sectionId: SectionID) => {
|
||||
state.setCurrentSection(sectionId);
|
||||
},
|
||||
|
||||
setCurrentView: (viewId: ViewID) => {
|
||||
state.setCurrentView(viewId);
|
||||
},
|
||||
|
||||
// Validation actions
|
||||
validate: () => {
|
||||
return state.validate();
|
||||
},
|
||||
|
||||
// Helpers
|
||||
getSelection: (attrId: AttrID) => {
|
||||
return state.getSelection(attrId);
|
||||
},
|
||||
|
||||
getAllowedValues: (attrId: AttrID) => {
|
||||
return state.getAllowedValues(attrId);
|
||||
},
|
||||
|
||||
isRequired: (attrId: AttrID) => {
|
||||
return state.isRequired(attrId);
|
||||
},
|
||||
|
||||
hasErrorsForAttribute: (attrId: AttrID) => {
|
||||
return state.hasErrorsForAttribute(attrId);
|
||||
},
|
||||
|
||||
getViolationsForAttribute: (attrId: AttrID) => {
|
||||
return state.getViolationsForAttribute(attrId);
|
||||
},
|
||||
|
||||
// Persistence
|
||||
toJSON: () => state.toJSON(),
|
||||
|
||||
fromJSON: (data: ReturnType<typeof state.toJSON>) => {
|
||||
state.fromJSON(data);
|
||||
},
|
||||
|
||||
// Utilities
|
||||
reset: () => state.reset(),
|
||||
undo: () => state.undo(),
|
||||
|
||||
// Direct access to state (for advanced use)
|
||||
_state: state
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DERIVED STORES (COMPUTED VALUES)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Derived store para la sección actual
|
||||
*/
|
||||
export function deriveCurrentSection(
|
||||
configStore: ReturnType<typeof createConfigurationStore>
|
||||
): Readable<Section | null> {
|
||||
return derived(configStore, $config => $config.currentSection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived store para la vista actual
|
||||
*/
|
||||
export function deriveCurrentView(
|
||||
configStore: ReturnType<typeof createConfigurationStore>
|
||||
): Readable<SectionView | null> {
|
||||
return derived(configStore, $config => $config.currentView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived store para validación
|
||||
*/
|
||||
export function deriveValidation(
|
||||
configStore: ReturnType<typeof createConfigurationStore>
|
||||
): Readable<ValidationResult | null> {
|
||||
return derived(configStore, $config => $config.validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived store para verificar si es válido
|
||||
*/
|
||||
export function deriveIsValid(
|
||||
configStore: ReturnType<typeof createConfigurationStore>
|
||||
): Readable<boolean> {
|
||||
return derived(configStore, $config => $config.isValid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived store para selección
|
||||
*/
|
||||
export function deriveSelection(
|
||||
configStore: ReturnType<typeof createConfigurationStore>
|
||||
): Readable<SelectionStateData> {
|
||||
return derived(configStore, $config => $config.selection);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TYPE EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
export type ConfigurationStore = ReturnType<typeof createConfigurationStore>;
|
||||
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* VALIDATION MESSAGES - Section & Object
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type { I18nString } from '@/types';
|
||||
|
||||
export const VALIDATION_SECTION = {
|
||||
// Section - Views coherence
|
||||
HAS_VIEWS_NO_DEFAULT: (): I18nString => ({
|
||||
es: 'La sección tiene vistas pero no tiene defaultView',
|
||||
en: 'Section has views but no defaultView'
|
||||
}),
|
||||
|
||||
HAS_DEFAULT_NO_VIEWS: (): I18nString => ({
|
||||
es: 'La sección tiene defaultView pero no tiene vistas',
|
||||
en: 'Section has defaultView but no views'
|
||||
}),
|
||||
|
||||
DEFAULT_VIEW_NOT_FOUND: (viewId: string): I18nString => ({
|
||||
es: `La vista por defecto '${viewId}' no existe en las vistas disponibles`,
|
||||
en: `Default view '${viewId}' not found in available views`
|
||||
}),
|
||||
|
||||
// Section - Visual coherence
|
||||
VISUAL_ATTRS_NO_VIEWS: (): I18nString => ({
|
||||
es: 'La sección tiene atributos con affectsVisual=true pero no tiene vistas. Los cambios visuales no tendrán efecto.',
|
||||
en: 'Section has attributes with affectsVisual=true but no views. Visual changes will have no effect.'
|
||||
}),
|
||||
|
||||
// Section - Attributes
|
||||
DUPLICATE_ATTRIBUTE_ID: (attrId: string): I18nString => ({
|
||||
es: `ID de atributo duplicado: ${attrId}`,
|
||||
en: `Duplicate attribute ID: ${attrId}`
|
||||
}),
|
||||
|
||||
// View - Dependencies
|
||||
DEPENDENCY_NOT_IN_SECTION: (attrId: string): I18nString => ({
|
||||
es: `La dependencia '${attrId}' no existe en los atributos de la sección`,
|
||||
en: `Dependency '${attrId}' not found in section attributes`
|
||||
}),
|
||||
|
||||
// View - Template validation
|
||||
TEMPLATE_ATTR_NOT_IN_SECTION: (attrId: string): I18nString => ({
|
||||
es: `El template referencia el atributo '${attrId}' que no existe en la sección`,
|
||||
en: `Template references attribute '${attrId}' not found in section`
|
||||
}),
|
||||
|
||||
TEMPLATE_ATTR_NOT_IN_DEPS: (attrId: string): I18nString => ({
|
||||
es: `El template usa '${attrId}' pero no está en sectionAttrDependencies`,
|
||||
en: `Template uses '${attrId}' but it's not in sectionAttrDependencies`
|
||||
}),
|
||||
|
||||
UNKNOWN_PLACEHOLDER: (placeholder: string): I18nString => ({
|
||||
es: `Placeholder desconocido '${placeholder}' en el template`,
|
||||
en: `Unknown placeholder '${placeholder}' in template`
|
||||
})
|
||||
} as const;
|
||||
|
||||
export const VALIDATION_OBJECT = {
|
||||
// Object - Visual Strategy
|
||||
MISSING_VISUAL_STRATEGY: (): I18nString => ({
|
||||
es: 'El objeto no tiene visualStrategy definida',
|
||||
en: 'Object missing visualStrategy'
|
||||
}),
|
||||
|
||||
VISUAL_SECTIONS_WITH_NONE_STRATEGY: (): I18nString => ({
|
||||
es: 'El objeto tiene secciones con vistas pero visualStrategy es "none"',
|
||||
en: 'Object has visual sections but visualStrategy is "none"'
|
||||
}),
|
||||
|
||||
// Object - BasePath
|
||||
MISSING_BASE_PATH: (strategy: string): I18nString => ({
|
||||
es: `La estrategia '${strategy}' requiere basePath a nivel de objeto o vista`,
|
||||
en: `visualStrategy '${strategy}' requires basePath at object or view level`
|
||||
}),
|
||||
|
||||
// Object - API Config
|
||||
MISSING_API_CONFIG: (): I18nString => ({
|
||||
es: 'La estrategia "api_generated" requiere apiConfig a nivel de objeto o vista',
|
||||
en: 'visualStrategy "api_generated" requires apiConfig at object or view level'
|
||||
}),
|
||||
|
||||
// Object - Section Order
|
||||
SECTION_ORDER_UNKNOWN_SECTION: (sectionId: string): I18nString => ({
|
||||
es: `sectionOrder referencia una sección desconocida: '${sectionId}'`,
|
||||
en: `sectionOrder references unknown section '${sectionId}'`
|
||||
})
|
||||
} as const;
|
||||
@ -0,0 +1,377 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* CONFIGURATION STATE - Framework Agnostic
|
||||
* ============================================================================
|
||||
*
|
||||
* Orquesta todo el estado de la configuración.
|
||||
* Coordina SelectionState, ValidationState, y el modelo del catálogo.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConfigurableObject,
|
||||
ConfigurationCatalog,
|
||||
AttrID,
|
||||
OptionID,
|
||||
SectionID,
|
||||
ViewID, ObjectID
|
||||
} from '@/types';
|
||||
import { SelectionState, type SelectionStateChangeEvent } from './SelectionState';
|
||||
import { ValidationState, type ValidationResult } from './ValidationState';
|
||||
|
||||
export interface ConfigurationStateOptions {
|
||||
catalog: ConfigurationCatalog;
|
||||
objectId: string;
|
||||
autoValidate?: boolean; // Auto-validar en cada cambio (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado completo de la configuración
|
||||
*/
|
||||
export class ConfigurationState {
|
||||
// Core state
|
||||
private catalog: ConfigurationCatalog;
|
||||
private object: ConfigurableObject;
|
||||
private selection: SelectionState;
|
||||
private validation: ValidationState;
|
||||
|
||||
// Options
|
||||
private autoValidate: boolean;
|
||||
|
||||
// Current UI state
|
||||
private currentSectionId: SectionID | null = null;
|
||||
private currentViewId: ViewID | null = null;
|
||||
|
||||
// Listeners
|
||||
private stateChangeListeners: Set<() => void> = new Set();
|
||||
|
||||
constructor(options: ConfigurationStateOptions) {
|
||||
this.catalog = options.catalog;
|
||||
this.autoValidate = options.autoValidate ?? true;
|
||||
|
||||
// Buscar el objeto
|
||||
const obj = this.catalog.objects[options.objectId as ObjectID];
|
||||
if (!obj) {
|
||||
throw new Error(`Object ${options.objectId} not found in catalog`);
|
||||
}
|
||||
this.object = obj;
|
||||
|
||||
// Inicializar estados
|
||||
this.selection = new SelectionState();
|
||||
this.validation = new ValidationState();
|
||||
|
||||
// Configurar reglas de validación
|
||||
if (this.catalog.rules) {
|
||||
this.validation.setRules(Object.values(this.catalog.rules));
|
||||
}
|
||||
|
||||
// Suscribirse a cambios de selección para auto-validar
|
||||
this.selection.subscribe((event) => {
|
||||
if (this.autoValidate) {
|
||||
this.validation.validate(this.selection.getAll());
|
||||
}
|
||||
this.notifyStateChange();
|
||||
});
|
||||
|
||||
// Suscribirse a cambios de validación
|
||||
this.validation.subscribe(() => {
|
||||
this.notifyStateChange();
|
||||
});
|
||||
|
||||
// Inicializar con valores por defecto
|
||||
this.initializeDefaults();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// INITIALIZATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Inicializa con valores por defecto del objeto
|
||||
*/
|
||||
private initializeDefaults(): void {
|
||||
// Atributos globales
|
||||
this.object.attributes.forEach(attr => {
|
||||
if (attr.type === 'dynamic' && attr.defaultValue) {
|
||||
this.selection.set(attr.id, attr.defaultValue);
|
||||
}
|
||||
});
|
||||
|
||||
// Atributos de secciones
|
||||
Object.values(this.object.sections).forEach(section => {
|
||||
section.attrs.forEach(attr => {
|
||||
if (attr.type === 'dynamic' && attr.defaultValue) {
|
||||
this.selection.set(attr.id, attr.defaultValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Establecer primera sección como actual
|
||||
const firstSectionId = this.object.sectionOrder?.[0] ||
|
||||
Object.keys(this.object.sections)[0] as SectionID;
|
||||
|
||||
if (firstSectionId) {
|
||||
this.setCurrentSection(firstSectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CATALOG & OBJECT
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Obtiene el catálogo completo
|
||||
*/
|
||||
getCatalog(): ConfigurationCatalog {
|
||||
return this.catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el objeto actual
|
||||
*/
|
||||
getObject(): ConfigurableObject {
|
||||
return this.object;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SELECTION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Selecciona una opción para un atributo
|
||||
*/
|
||||
selectOption(attrId: AttrID, optionId: OptionID): void {
|
||||
// Verificar si el valor está permitido
|
||||
if (!this.validation.isValueAllowed(attrId, optionId, this.selection.getAll())) {
|
||||
console.warn(`Option ${optionId} is not allowed for attribute ${attrId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.set(attrId, optionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia la selección de un atributo
|
||||
*/
|
||||
clearSelection(attrId: AttrID): void {
|
||||
this.selection.clear(attrId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el valor seleccionado de un atributo
|
||||
*/
|
||||
getSelection(attrId: AttrID): OptionID | undefined {
|
||||
return this.selection.get(attrId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene toda la selección
|
||||
*/
|
||||
getAllSelections(): Record<AttrID, OptionID> {
|
||||
return this.selection.getAll();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// VALIDATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Valida el estado actual
|
||||
*/
|
||||
validate(): ValidationResult {
|
||||
return this.validation.validate(this.selection.getAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el resultado de la última validación
|
||||
*/
|
||||
getValidationResult(): ValidationResult | null {
|
||||
return this.validation.getLastResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la configuración es válida
|
||||
*/
|
||||
isValid(): boolean {
|
||||
const result = this.validation.getLastResult();
|
||||
return result?.isValid ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene violaciones para un atributo
|
||||
*/
|
||||
getViolationsForAttribute(attrId: AttrID) {
|
||||
return this.validation.getViolationsForAttribute(attrId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un atributo tiene errores
|
||||
*/
|
||||
hasErrorsForAttribute(attrId: AttrID): boolean {
|
||||
return this.validation.hasErrorsForAttribute(attrId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene valores permitidos para un atributo
|
||||
*/
|
||||
getAllowedValues(attrId: AttrID): OptionID[] | undefined {
|
||||
return this.validation.getAllowedValues(attrId, this.selection.getAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un atributo es requerido
|
||||
*/
|
||||
isRequired(attrId: AttrID): boolean {
|
||||
return this.validation.isRequired(attrId, this.selection.getAll());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// NAVIGATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Establece la sección actual
|
||||
*/
|
||||
setCurrentSection(sectionId: SectionID): void {
|
||||
const section = this.object.sections[sectionId];
|
||||
if (!section) {
|
||||
console.warn(`Section ${sectionId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSectionId = sectionId;
|
||||
|
||||
// Establecer vista por defecto si tiene vistas
|
||||
if (section.views && section.defaultView) {
|
||||
this.currentViewId = section.defaultView;
|
||||
} else {
|
||||
this.currentViewId = null;
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la sección actual
|
||||
*/
|
||||
getCurrentSection() {
|
||||
return this.currentSectionId
|
||||
? this.object.sections[this.currentSectionId]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece la vista actual
|
||||
*/
|
||||
setCurrentView(viewId: ViewID): void {
|
||||
const section = this.getCurrentSection();
|
||||
if (!section?.views?.[viewId]) {
|
||||
console.warn(`View ${viewId} not found in current section`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentViewId = viewId;
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la vista actual
|
||||
*/
|
||||
getCurrentView() {
|
||||
const section = this.getCurrentSection();
|
||||
return this.currentViewId && section?.views
|
||||
? section.views[this.currentViewId]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el ID de la sección actual
|
||||
*/
|
||||
getCurrentSectionId(): SectionID | null {
|
||||
return this.currentSectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el ID de la vista actual
|
||||
*/
|
||||
getCurrentViewId(): ViewID | null {
|
||||
return this.currentViewId;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SUBSCRIPTION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Suscribe a cambios de estado
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.stateChangeListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.stateChangeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica cambios de estado
|
||||
*/
|
||||
private notifyStateChange(): void {
|
||||
this.stateChangeListeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PERSISTENCE
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Serializa el estado a JSON
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
objectId: this.object.id,
|
||||
selection: this.selection.toJSON(),
|
||||
currentSectionId: this.currentSectionId,
|
||||
currentViewId: this.currentViewId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaura el estado desde JSON
|
||||
*/
|
||||
fromJSON(data: ReturnType<typeof this.toJSON>): void {
|
||||
this.selection.fromJSON(data.selection);
|
||||
|
||||
if (data.currentSectionId) {
|
||||
this.setCurrentSection(data.currentSectionId);
|
||||
}
|
||||
|
||||
if (data.currentViewId) {
|
||||
this.setCurrentView(data.currentViewId);
|
||||
}
|
||||
|
||||
// Re-validar
|
||||
if (this.autoValidate) {
|
||||
this.validation.validate(this.selection.getAll());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// UTILITIES
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Resetea toda la configuración
|
||||
*/
|
||||
reset(): void {
|
||||
this.selection.clearAll();
|
||||
this.validation.clear();
|
||||
this.initializeDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deshace el último cambio
|
||||
*/
|
||||
undo(): boolean {
|
||||
return this.selection.undo();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* SELECTION STATE - Framework Agnostic
|
||||
* ============================================================================
|
||||
*
|
||||
* Gestiona el estado de selección de opciones.
|
||||
* No depende de ningún framework, puede usarse con Svelte, React, Vue, etc.
|
||||
*/
|
||||
|
||||
import type { AttrID, OptionID, SectionID } from '@/types';
|
||||
|
||||
export type SelectionStateData = Record<AttrID, OptionID>;
|
||||
|
||||
export interface SelectionStateChangeEvent {
|
||||
attrId: AttrID;
|
||||
previousValue?: OptionID;
|
||||
newValue?: OptionID;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de selección de opciones
|
||||
*/
|
||||
export class SelectionState {
|
||||
private state: SelectionStateData = {};
|
||||
private listeners: Set<(event: SelectionStateChangeEvent) => void> = new Set();
|
||||
private history: SelectionStateChangeEvent[] = [];
|
||||
|
||||
/**
|
||||
* Establece el valor de un atributo
|
||||
*/
|
||||
set(attrId: AttrID, value: OptionID | undefined): void {
|
||||
const previousValue = this.state[attrId];
|
||||
|
||||
// Si el valor no cambia, no hacer nada
|
||||
if (previousValue === value) return;
|
||||
|
||||
const event: SelectionStateChangeEvent = {
|
||||
attrId,
|
||||
previousValue,
|
||||
newValue: value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.state[attrId];
|
||||
} else {
|
||||
this.state[attrId] = value;
|
||||
}
|
||||
|
||||
// Registrar en historial
|
||||
this.history.push(event);
|
||||
|
||||
// Notificar listeners
|
||||
this.notifyListeners(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el valor de un atributo
|
||||
*/
|
||||
get(attrId: AttrID): OptionID | undefined {
|
||||
return this.state[attrId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todo el estado
|
||||
*/
|
||||
getAll(): SelectionStateData {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un atributo tiene valor
|
||||
*/
|
||||
has(attrId: AttrID): boolean {
|
||||
return attrId in this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el valor de un atributo
|
||||
*/
|
||||
clear(attrId: AttrID): void {
|
||||
this.set(attrId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia todo el estado
|
||||
*/
|
||||
clearAll(): void {
|
||||
const attrIds = Object.keys(this.state) as AttrID[];
|
||||
attrIds.forEach(attrId => this.clear(attrId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece múltiples valores a la vez
|
||||
*/
|
||||
setMany(values: Partial<SelectionStateData>): void {
|
||||
Object.entries(values).forEach(([attrId, value]) => {
|
||||
if (value !== undefined) {
|
||||
this.set(attrId as AttrID, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene valores de una sección específica
|
||||
*/
|
||||
getBySection(sectionId: SectionID, attrIds: AttrID[]): Partial<SelectionStateData> {
|
||||
const result: Partial<SelectionStateData> = {};
|
||||
attrIds.forEach(attrId => {
|
||||
const value = this.state[attrId];
|
||||
if (value !== undefined) {
|
||||
result[attrId] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suscribe un listener a cambios
|
||||
*/
|
||||
subscribe(listener: (event: SelectionStateChangeEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
// Retorna función para desuscribirse
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica a todos los listeners
|
||||
*/
|
||||
private notifyListeners(event: SelectionStateChangeEvent): void {
|
||||
this.listeners.forEach(listener => listener(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el historial de cambios
|
||||
*/
|
||||
getHistory(): SelectionStateChangeEvent[] {
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el historial
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deshace el último cambio
|
||||
*/
|
||||
undo(): boolean {
|
||||
if (this.history.length === 0) return false;
|
||||
|
||||
const lastEvent = this.history.pop()!;
|
||||
|
||||
// Revertir sin añadir al historial ni notificar
|
||||
if (lastEvent.previousValue === undefined) {
|
||||
delete this.state[lastEvent.attrId];
|
||||
} else {
|
||||
this.state[lastEvent.attrId] = lastEvent.previousValue;
|
||||
}
|
||||
|
||||
// Notificar del cambio
|
||||
this.notifyListeners({
|
||||
attrId: lastEvent.attrId,
|
||||
previousValue: lastEvent.newValue,
|
||||
newValue: lastEvent.previousValue,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el tamaño del estado
|
||||
*/
|
||||
get size(): number {
|
||||
return Object.keys(this.state).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el estado está vacío
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializa el estado a JSON
|
||||
*/
|
||||
toJSON(): SelectionStateData {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaura el estado desde JSON
|
||||
*/
|
||||
fromJSON(data: SelectionStateData): void {
|
||||
this.clearAll();
|
||||
this.setMany(data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* VALIDATION STATE - Framework Agnostic
|
||||
* ============================================================================
|
||||
*
|
||||
* Gestiona el estado de validación y violaciones.
|
||||
*/
|
||||
|
||||
import type { AttrID, ValidationRule, RuleID } from '@/types';
|
||||
import type { SelectionStateData } from './SelectionState';
|
||||
import { RuleEngine } from '@/engine/rule-engine';
|
||||
|
||||
export interface Violation {
|
||||
rule: ValidationRule;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
attrId?: AttrID;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
hasErrors: boolean;
|
||||
hasWarnings: boolean;
|
||||
violations: Violation[];
|
||||
violationsByAttribute: Map<AttrID, Violation[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de validación
|
||||
*/
|
||||
export class ValidationState {
|
||||
private engine: RuleEngine;
|
||||
private rules: ValidationRule[] = [];
|
||||
private lastResult: ValidationResult | null = null;
|
||||
private listeners: Set<(result: ValidationResult) => void> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.engine = new RuleEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece las reglas de validación
|
||||
*/
|
||||
setRules(rules: ValidationRule[]): void {
|
||||
this.rules = rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida el estado actual
|
||||
*/
|
||||
validate(selectionState: SelectionStateData): ValidationResult {
|
||||
const violations = this.engine.getViolations(this.rules, selectionState);
|
||||
|
||||
const hasErrors = violations.some(v => v.severity === 'error');
|
||||
const hasWarnings = violations.some(v => v.severity === 'warning');
|
||||
|
||||
// Agrupar violaciones por atributo
|
||||
const violationsByAttribute = new Map<AttrID, Violation[]>();
|
||||
|
||||
violations.forEach(violation => {
|
||||
if (violation.rule.action?.targetAttr) {
|
||||
const attrId = violation.rule.action.targetAttr;
|
||||
if (!violationsByAttribute.has(attrId)) {
|
||||
violationsByAttribute.set(attrId, []);
|
||||
}
|
||||
violationsByAttribute.get(attrId)!.push(violation);
|
||||
}
|
||||
});
|
||||
|
||||
const result: ValidationResult = {
|
||||
isValid: !hasErrors,
|
||||
hasErrors,
|
||||
hasWarnings,
|
||||
violations,
|
||||
violationsByAttribute
|
||||
};
|
||||
|
||||
this.lastResult = result;
|
||||
this.notifyListeners(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el último resultado de validación
|
||||
*/
|
||||
getLastResult(): ValidationResult | null {
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene violaciones de un atributo específico
|
||||
*/
|
||||
getViolationsForAttribute(attrId: AttrID): Violation[] {
|
||||
if (!this.lastResult) return [];
|
||||
return this.lastResult.violationsByAttribute.get(attrId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un atributo tiene errores
|
||||
*/
|
||||
hasErrorsForAttribute(attrId: AttrID): boolean {
|
||||
const violations = this.getViolationsForAttribute(attrId);
|
||||
return violations.some(v => v.severity === 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un valor es permitido para un atributo
|
||||
*/
|
||||
isValueAllowed(attrId: AttrID, value: OptionID, selectionState: SelectionStateData): boolean {
|
||||
return this.engine.isValueAllowed(attrId, value, this.rules, selectionState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene valores permitidos para un atributo
|
||||
*/
|
||||
getAllowedValues(attrId: AttrID, selectionState: SelectionStateData): OptionID[] | undefined {
|
||||
const results = this.engine.getAttributeResults(this.rules, selectionState);
|
||||
const attrResult = results.get(attrId);
|
||||
|
||||
if (!attrResult) return undefined;
|
||||
|
||||
return attrResult.allowedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene valores prohibidos para un atributo
|
||||
*/
|
||||
getForbiddenValues(attrId: AttrID, selectionState: SelectionStateData): OptionID[] {
|
||||
const results = this.engine.getAttributeResults(this.rules, selectionState);
|
||||
const attrResult = results.get(attrId);
|
||||
|
||||
if (!attrResult) return [];
|
||||
|
||||
return attrResult.forbiddenValues || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un atributo es requerido
|
||||
*/
|
||||
isRequired(attrId: AttrID, selectionState: SelectionStateData): boolean {
|
||||
const results = this.engine.getAttributeResults(this.rules, selectionState);
|
||||
const attrResult = results.get(attrId);
|
||||
|
||||
return attrResult?.required || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suscribe un listener a cambios de validación
|
||||
*/
|
||||
subscribe(listener: (result: ValidationResult) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
// Si ya hay un resultado, notificar inmediatamente
|
||||
if (this.lastResult) {
|
||||
listener(this.lastResult);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica a todos los listeners
|
||||
*/
|
||||
private notifyListeners(result: ValidationResult): void {
|
||||
this.listeners.forEach(listener => listener(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el estado de validación
|
||||
*/
|
||||
clear(): void {
|
||||
this.lastResult = null;
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,131 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* CONFIGURABLE OBJECT TYPES
|
||||
* CONFIGURABLE OBJECT TYPES - REFACTORED
|
||||
* ============================================================================
|
||||
*
|
||||
* ConfigurableObject ahora define la estrategia de renderizado global.
|
||||
*/
|
||||
|
||||
import type { I18nString } from '../i18n';
|
||||
import type { Metadata } from '../core';
|
||||
import type { Metadata, APIConfig } from '../core';
|
||||
import type { ObjectID, SectionID, Attribute, Section } from '@/types';
|
||||
|
||||
// ============================================================================
|
||||
// RENDERING STRATEGY
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estrategia de renderizado para TODO el objeto configurable.
|
||||
* Todas las vistas de todas las secciones usan esta estrategia.
|
||||
*/
|
||||
export type RenderingStrategy =
|
||||
| 'static_image' // URLs estáticas con placeholders
|
||||
| 'dynamic_image' // URLs dinámicas calculadas en runtime
|
||||
| 'api_generated' // Generación async via API externa
|
||||
| 'three_d' // Modelo 3D con texturas dinámicas
|
||||
| 'composite_layers' // Composición de capas PNG
|
||||
| 'none'; // Sin renderizado visual
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL VISUAL CONFIG
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuración visual global del objeto.
|
||||
* Actúa como FALLBACK para todas las vistas que no especifiquen sus propios valores.
|
||||
*/
|
||||
export interface GlobalVisualConfig {
|
||||
/**
|
||||
* Ruta base para todas las imágenes del objeto.
|
||||
* Las vistas pueden override este valor.
|
||||
*
|
||||
* Ejemplo: '/renders/vivienda'
|
||||
*/
|
||||
basePath?: string;
|
||||
|
||||
/**
|
||||
* Configuración API global.
|
||||
* Solo aplica si visualStrategy es 'api_generated'.
|
||||
* Las vistas pueden override este valor.
|
||||
*/
|
||||
apiConfig?: APIConfig;
|
||||
|
||||
/**
|
||||
* Imagen de fallback global.
|
||||
* Usada si falla la generación de cualquier vista.
|
||||
* Las vistas pueden override este valor.
|
||||
*
|
||||
* Ejemplo: '/renders/fallback.jpg'
|
||||
*/
|
||||
fallbackImage?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURABLE OBJECT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Objeto configurable (producto).
|
||||
*
|
||||
* Ejemplos: casa, coche, mueble, laptop, etc.
|
||||
*/
|
||||
export interface ConfigurableObject {
|
||||
id : ObjectID;
|
||||
name : I18nString;
|
||||
description : I18nString;
|
||||
attributes : Attribute[];
|
||||
sections : Record<SectionID, Section>;
|
||||
id: ObjectID;
|
||||
name: I18nString;
|
||||
description: I18nString;
|
||||
|
||||
/**
|
||||
* ✅ ESTRATEGIA DE RENDERIZADO para TODO el objeto.
|
||||
*
|
||||
* Esta estrategia aplica a TODAS las vistas de TODAS las secciones.
|
||||
* No se puede tener diferentes estrategias por sección/vista.
|
||||
*
|
||||
* Si necesitas estrategias mixtas (excepcional), usa 'dynamic_image'
|
||||
* y maneja la lógica en runtime.
|
||||
*/
|
||||
visualStrategy: RenderingStrategy;
|
||||
|
||||
/**
|
||||
* ✅ CONFIGURACIÓN VISUAL GLOBAL (fallback).
|
||||
*
|
||||
* Valores por defecto que heredan todas las vistas
|
||||
* si no especifican sus propios valores.
|
||||
*
|
||||
* Opcional si todas las vistas definen sus propios valores.
|
||||
*/
|
||||
visualConfig?: GlobalVisualConfig;
|
||||
|
||||
/**
|
||||
* Atributos GLOBALES del objeto.
|
||||
* Aplican a todo el objeto independientemente de la sección.
|
||||
*
|
||||
* Ejemplo: 'calidad de acabados', 'color principal'
|
||||
*/
|
||||
attributes: Attribute[];
|
||||
|
||||
/**
|
||||
* Secciones del objeto.
|
||||
*
|
||||
* Ejemplo para una casa:
|
||||
* - 'sc:salon'
|
||||
* - 'sc:bano'
|
||||
* - 'sc:dormitorio'
|
||||
*/
|
||||
sections: Record<SectionID, Section>;
|
||||
|
||||
/**
|
||||
* Orden de presentación de secciones en UI.
|
||||
* Si no se especifica, el orden es arbitrario.
|
||||
*/
|
||||
sectionOrder?: SectionID[];
|
||||
category? : string;
|
||||
metadata? : Metadata;
|
||||
|
||||
/**
|
||||
* Categoría del objeto (opcional).
|
||||
*
|
||||
* Ejemplo: 'residencial', 'automovil', 'mobiliario'
|
||||
*/
|
||||
category?: string;
|
||||
|
||||
/** Metadata adicional */
|
||||
metadata?: Metadata;
|
||||
}
|
||||
@ -1,41 +1,363 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* SECTION VALIDATOR
|
||||
* SECTION VALIDATOR - UPDATED
|
||||
* ============================================================================
|
||||
*
|
||||
* Valida secciones con la nueva arquitectura:
|
||||
* - views opcionales
|
||||
* - strategy a nivel de objeto
|
||||
* - validación de coherencia
|
||||
*/
|
||||
|
||||
import type { Section, AttrID } from '@/types';
|
||||
import type { ValidationError } from '@/utils';
|
||||
import {ERRORS} from "@/messages";
|
||||
import type {
|
||||
Section,
|
||||
SectionView,
|
||||
ConfigurableObject,
|
||||
AttrID,
|
||||
ViewID,
|
||||
I18nString,
|
||||
SupportedLocale,
|
||||
Severity
|
||||
} from '@/types';
|
||||
import { VALIDATION_SECTION, VALIDATION_OBJECT } from '@/messages/validation-section';
|
||||
import { translate } from '@/utils/i18n/translation.utils';
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION ERROR
|
||||
// ============================================================================
|
||||
|
||||
export interface SectionValidationError {
|
||||
sectionId : string;
|
||||
viewId? : ViewID;
|
||||
message : I18nString;
|
||||
severity : Severity;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valida una sección individual
|
||||
*/
|
||||
export function validateSection(section: Section): SectionValidationError[] {
|
||||
const errors: SectionValidationError[] = [];
|
||||
|
||||
// 1. Coherencia views <-> defaultView
|
||||
const hasViews = section.views && Object.keys(section.views).length > 0;
|
||||
const hasDefaultView = section.defaultView !== undefined;
|
||||
|
||||
if (hasViews && !hasDefaultView) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
message: VALIDATION_SECTION.HAS_VIEWS_NO_DEFAULT(),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasViews && hasDefaultView) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
message: VALIDATION_SECTION.HAS_DEFAULT_NO_VIEWS(),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
if (hasViews && hasDefaultView) {
|
||||
// Verificar que defaultView existe en views
|
||||
if (!section.views![section.defaultView!]) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
message: VALIDATION_SECTION.DEFAULT_VIEW_NOT_FOUND(section.defaultView!),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
// Validar cada vista
|
||||
for (const view of Object.values(section.views!)) {
|
||||
errors.push(...validateView(section, view));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Coherencia affectsVisual <-> views
|
||||
const hasVisualAttrs = section.attrs.some(attr =>
|
||||
attr.display?.affectsVisual === true
|
||||
);
|
||||
|
||||
if (hasVisualAttrs && !hasViews) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
message: VALIDATION_SECTION.VISUAL_ATTRS_NO_VIEWS(),
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Validar IDs únicos de atributos
|
||||
const attrIds = new Set<AttrID>();
|
||||
for (const attr of section.attrs) {
|
||||
if (attrIds.has(attr.id)) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
message: VALIDATION_SECTION.DUPLICATE_ATTRIBUTE_ID(attr.id),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
attrIds.add(attr.id);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida una vista individual
|
||||
*/
|
||||
function validateView(
|
||||
section : Section,
|
||||
view : SectionView
|
||||
): SectionValidationError[] {
|
||||
const errors: SectionValidationError[] = [];
|
||||
|
||||
const config = view.visualConfig;
|
||||
|
||||
// 1. Validar imageUrlTemplate si existe
|
||||
if (config.imageUrlTemplate) {
|
||||
errors.push(...validateImageTemplate(section, view));
|
||||
}
|
||||
|
||||
// 2. Validar dependencies
|
||||
if (config.sectionAttrDependencies) {
|
||||
const sectionAttrIds = new Set(section.attrs.map(a => a.id));
|
||||
|
||||
for (const attrId of config.sectionAttrDependencies) {
|
||||
if (!sectionAttrIds.has(attrId)) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
viewId: view.id,
|
||||
message: VALIDATION_SECTION.DEPENDENCY_NOT_IN_SECTION(attrId),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar sección en runtime
|
||||
* Valida el template de URL de imagen
|
||||
*/
|
||||
export function validateSection(section: Section): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
function validateImageTemplate(
|
||||
section : Section,
|
||||
view : SectionView
|
||||
): SectionValidationError[] {
|
||||
const errors: SectionValidationError[] = [];
|
||||
const template = view.visualConfig.imageUrlTemplate!;
|
||||
|
||||
// Validar que imageUrlTemplate tiene placeholders válidos
|
||||
if (section.visualConfig.imageUrlTemplate) {
|
||||
const template = section.visualConfig.imageUrlTemplate;
|
||||
// Extraer placeholders: {basePath}, {section}, {view}, {at_suelo}
|
||||
const placeholders = template.match(/\{[^}]+}/g) || [];
|
||||
const sectionAttrIds = new Set(section.attrs.map(a => a.id));
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
const cleanName = placeholder.slice(1, -1); // Quitar { }
|
||||
|
||||
placeholders.forEach(placeholder => {
|
||||
const cleanName = placeholder.slice(1, -1); // Remove { }
|
||||
// Placeholders reservados
|
||||
if (['basePath', 'section', 'view'].includes(cleanName)) {
|
||||
continue; // OK
|
||||
}
|
||||
|
||||
// Placeholders de atributos: {at_suelo}
|
||||
if (cleanName.startsWith('at_')) {
|
||||
const attrId = cleanName.replace('at_', 'at:') as AttrID;
|
||||
|
||||
if (cleanName.startsWith('global.')) {
|
||||
const attrName = cleanName.replace('global.', '');
|
||||
const attrId = `at:${attrName}` as AttrID;
|
||||
// ¿Está en dependencies de sección?
|
||||
const inDeps = view.visualConfig.sectionAttrDependencies?.includes(attrId);
|
||||
|
||||
if (!section.visualConfig.globalAttrDependencies?.includes(attrId)) {
|
||||
// ¿Existe en la sección?
|
||||
const exists = sectionAttrIds.has(attrId);
|
||||
|
||||
if (!exists) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
viewId: view.id,
|
||||
message: VALIDATION_SECTION.TEMPLATE_ATTR_NOT_IN_SECTION(attrId),
|
||||
severity: 'error'
|
||||
});
|
||||
} else if (!inDeps) {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
message: ERRORS.PLACEHOLDER_NOT_IN_DEPENDENCIES(placeholder, section.id),
|
||||
viewId: view.id,
|
||||
message: VALIDATION_SECTION.TEMPLATE_ATTR_NOT_IN_DEPS(attrId),
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
}
|
||||
// Placeholder desconocido
|
||||
else {
|
||||
errors.push({
|
||||
sectionId: section.id,
|
||||
viewId: view.id,
|
||||
message: VALIDATION_SECTION.UNKNOWN_PLACEHOLDER(placeholder),
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OBJECT VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valida un objeto completo y todas sus secciones
|
||||
*/
|
||||
export function validateObject(object: ConfigurableObject): SectionValidationError[] {
|
||||
const errors: SectionValidationError[] = [];
|
||||
|
||||
// 1. Validar estrategia visual
|
||||
if (!object.visualStrategy) {
|
||||
errors.push({
|
||||
sectionId: object.id,
|
||||
message: VALIDATION_OBJECT.MISSING_VISUAL_STRATEGY(),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Validar cada sección
|
||||
for (const section of Object.values(object.sections)) {
|
||||
errors.push(...validateSection(section));
|
||||
}
|
||||
|
||||
// 3. Validar coherencia visualStrategy <-> views
|
||||
const hasVisualSections = Object.values(object.sections).some(s =>
|
||||
s.views && Object.keys(s.views).length > 0
|
||||
);
|
||||
|
||||
if (hasVisualSections && object.visualStrategy === 'none') {
|
||||
errors.push({
|
||||
sectionId: object.id,
|
||||
message: VALIDATION_OBJECT.VISUAL_SECTIONS_WITH_NONE_STRATEGY(),
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Validar basePath según estrategia
|
||||
const needsBasePath = ['static_image', 'dynamic_image'].includes(object.visualStrategy);
|
||||
const hasBasePath = object.visualConfig?.basePath !== undefined;
|
||||
|
||||
if (needsBasePath && !hasBasePath) {
|
||||
// Verificar si alguna vista lo define
|
||||
const anyViewHasBasePath = Object.values(object.sections).some(section =>
|
||||
section.views && Object.values(section.views).some(view =>
|
||||
view.visualConfig.basePath !== undefined
|
||||
)
|
||||
);
|
||||
|
||||
if (!anyViewHasBasePath) {
|
||||
errors.push({
|
||||
sectionId: object.id,
|
||||
message: VALIDATION_OBJECT.MISSING_BASE_PATH(object.visualStrategy),
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Validar apiConfig según estrategia
|
||||
if (object.visualStrategy === 'api_generated') {
|
||||
const hasApiConfig = object.visualConfig?.apiConfig !== undefined;
|
||||
|
||||
if (!hasApiConfig) {
|
||||
// Verificar si alguna vista lo define
|
||||
const anyViewHasApiConfig = Object.values(object.sections).some(section =>
|
||||
section.views && Object.values(section.views).some(view =>
|
||||
view.visualConfig.apiConfig !== undefined
|
||||
)
|
||||
);
|
||||
|
||||
if (!anyViewHasApiConfig) {
|
||||
errors.push({
|
||||
sectionId: object.id,
|
||||
message: VALIDATION_OBJECT.MISSING_API_CONFIG(),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Validar sectionOrder
|
||||
if (object.sectionOrder) {
|
||||
const sectionIds = new Set(Object.keys(object.sections));
|
||||
|
||||
for (const sectionId of object.sectionOrder) {
|
||||
if (!sectionIds.has(sectionId)) {
|
||||
errors.push({
|
||||
sectionId: object.id,
|
||||
message: VALIDATION_OBJECT.SECTION_ORDER_UNKNOWN_SECTION(sectionId),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Agrupa errores por severidad
|
||||
*/
|
||||
export function groupBySeverity(errors: SectionValidationError[]): {
|
||||
errors : SectionValidationError[];
|
||||
warnings: SectionValidationError[];
|
||||
info : SectionValidationError[];
|
||||
} {
|
||||
return {
|
||||
errors: errors.filter(e => e.severity === 'error'),
|
||||
warnings: errors.filter(e => e.severity === 'warning'),
|
||||
info: errors.filter(e => e.severity === 'info')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si hay errores críticos
|
||||
*/
|
||||
export function hasErrors(errors: SectionValidationError[]): boolean {
|
||||
return errors.some(e => e.severity === 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea errores para mostrar
|
||||
*/
|
||||
export function formatErrors(
|
||||
errors: SectionValidationError[],
|
||||
locale: SupportedLocale = 'es'
|
||||
): string {
|
||||
const grouped = groupBySeverity(errors);
|
||||
const lines: string[] = [];
|
||||
|
||||
if (grouped.errors.length > 0) {
|
||||
lines.push('ERRORS:');
|
||||
grouped.errors.forEach(e => {
|
||||
const location = e.viewId
|
||||
? `${e.sectionId} > ${e.viewId}`
|
||||
: e.sectionId;
|
||||
lines.push(` ❌ [${location}] ${translate(e.message, locale)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (grouped.warnings.length > 0) {
|
||||
lines.push('WARNINGS:');
|
||||
grouped.warnings.forEach(e => {
|
||||
const location = e.viewId
|
||||
? `${e.sectionId} > ${e.viewId}`
|
||||
: e.sectionId;
|
||||
lines.push(` ⚠️ [${location}] ${translate(e.message, locale)}`);
|
||||
});
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Loading…
Reference in new issue