@asteby/metacore-runtime-react 8.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/dist/column-visibility.d.ts +22 -0
- package/dist/column-visibility.d.ts.map +1 -0
- package/dist/column-visibility.js +40 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +4 -1
- package/dist/dynamic-form-schema.d.ts +7 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -0
- package/dist/dynamic-form-schema.js +68 -0
- package/dist/dynamic-form.d.ts +2 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +28 -9
- package/dist/dynamic-relation-helpers.d.ts +77 -0
- package/dist/dynamic-relation-helpers.d.ts.map +1 -0
- package/dist/dynamic-relation-helpers.js +186 -0
- package/dist/dynamic-relation.d.ts +64 -0
- package/dist/dynamic-relation.d.ts.map +1 -0
- package/dist/dynamic-relation.js +226 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +17 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/relations.md +290 -0
- package/package.json +9 -3
- package/src/__tests__/column-visibility.test.ts +116 -0
- package/src/__tests__/dynamic-form.test.ts +104 -0
- package/src/__tests__/dynamic-relation.test.ts +293 -0
- package/src/column-visibility.ts +43 -0
- package/src/dynamic-columns.tsx +4 -1
- package/src/dynamic-form-schema.ts +66 -0
- package/src/dynamic-form.tsx +34 -9
- package/src/dynamic-relation-helpers.ts +226 -0
- package/src/dynamic-relation.tsx +497 -0
- package/src/dynamic-table.tsx +20 -2
- package/src/index.ts +14 -0
- package/src/types.ts +49 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# `<DynamicRelation>` — propuesta de API
|
|
2
|
+
|
|
3
|
+
> **Status:** Draft / RFC. La implementación todavía no está mergeada.
|
|
4
|
+
> **Audience:** autores de hosts (apps Vite que consumen `@asteby/metacore-runtime-react`) y mantenedores del kernel/SDK.
|
|
5
|
+
> **Companion contracts:** `manifest.RelationDef` (`metacore-kernel/manifest/manifest.go`), envelope kernel `{success, data, meta}`.
|
|
6
|
+
|
|
7
|
+
## 1. Motivación
|
|
8
|
+
|
|
9
|
+
El kernel ya expone `RelationDef` en el manifest de cada `ModelDefinition` con dos formas:
|
|
10
|
+
|
|
11
|
+
- `kind: "one_to_many"` — el modelo dueño tiene muchas filas en `Through`. La FK vive en `Through.foreign_key` apuntando a `owner.references` (default `"id"`).
|
|
12
|
+
- `kind: "many_to_many"` — `Pivot` es la tabla de unión entre el dueño y `Through`. La FK del lado dueño vive en `Pivot.foreign_key`.
|
|
13
|
+
|
|
14
|
+
Hoy `runtime-react` cubre el plano flat (un modelo, una tabla) con `<DynamicTable>`, `<DynamicForm>` y `<DynamicCRUDPage>`. Falta un primitivo que renderice **edges** entre modelos sin que el host tenga que escribir el wiring de cada relación a mano.
|
|
15
|
+
|
|
16
|
+
`<DynamicRelation>` es ese primitivo: un componente metadata-driven que recibe el modelo dueño, el `id` del registro padre, y la `name` de la relación declarada en el manifest; el resto (endpoint, columnas, acciones) se resuelve desde la metadata expuesta por el kernel.
|
|
17
|
+
|
|
18
|
+
## 2. Principios de diseño
|
|
19
|
+
|
|
20
|
+
1. **Un solo componente, dos kinds.** Misma forma de invocación, distinto comportamiento. El `kind` es un discriminator, no dos componentes separados, así el hosts puede leer la metadata y decidir cuál usar sin un switch.
|
|
21
|
+
2. **Composición sobre configuración.** `<DynamicRelation>` usa `<DynamicTable>` y `<DynamicForm>` por dentro. No reimplementa toolbar, paginación ni columns rendering.
|
|
22
|
+
3. **Brand-neutral.** Cero copy de producto; todos los strings son props con default razonable. Las traducciones se resuelven vía `useTranslation()` cuando el host monta `<I18nProvider>`.
|
|
23
|
+
4. **Backend-shape declarativo.** El componente no asume rutas custom; sólo confía en el contrato de sub-resource definido en §6. Si el kernel cambia el shape, sólo se actualiza el adapter interno.
|
|
24
|
+
5. **Misma envelope que el resto del kernel.** `{success, data, meta}` para listas paginadas, `{success, data}` para mutaciones single-record. Errores: `{success: false, error: {...}}`.
|
|
25
|
+
|
|
26
|
+
## 3. API pública
|
|
27
|
+
|
|
28
|
+
### 3.1 Props comunes
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
export type DynamicRelationKind = 'one_to_many' | 'many_to_many'
|
|
32
|
+
|
|
33
|
+
interface DynamicRelationCommonProps {
|
|
34
|
+
/** Modelo dueño tal como aparece en el manifest (`ModelDefinition.ModelKey`). */
|
|
35
|
+
model: string
|
|
36
|
+
|
|
37
|
+
/** id del registro padre. Suele venir del router (`useParams().id`). */
|
|
38
|
+
parentId: string | number
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Nombre de la relación tal como está declarada en
|
|
42
|
+
* `RelationDef.name` para `model`. Es el address que usa la SDK; no
|
|
43
|
+
* pasar el modelo target — el SDK lo deriva del manifest.
|
|
44
|
+
*/
|
|
45
|
+
name: string
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Override opcional del foreign key. Se usa sólo cuando el host
|
|
49
|
+
* monta una variante no declarada en el manifest (ej: una vista
|
|
50
|
+
* custom). En el caso normal queda undefined y el SDK lo lee de
|
|
51
|
+
* `RelationDef.ForeignKey`.
|
|
52
|
+
*/
|
|
53
|
+
foreignKey?: string
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Override del modelo target. Default = `RelationDef.through`. Mismo
|
|
57
|
+
* caveat que `foreignKey`.
|
|
58
|
+
*/
|
|
59
|
+
through?: string
|
|
60
|
+
|
|
61
|
+
/** Hidden columns en la tabla embebida. Pasthru a `<DynamicTable>`. */
|
|
62
|
+
hiddenColumns?: string[]
|
|
63
|
+
|
|
64
|
+
/** Toolbar / acciones visibles. Default deriva de `RelationDef.permissions`. */
|
|
65
|
+
canCreate?: boolean
|
|
66
|
+
canDelete?: boolean
|
|
67
|
+
|
|
68
|
+
/** Mensajería. Strings traducibles. */
|
|
69
|
+
strings?: Partial<DynamicRelationStrings>
|
|
70
|
+
|
|
71
|
+
/** className opcional para el wrapper. */
|
|
72
|
+
className?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DynamicRelationStrings {
|
|
76
|
+
title: string // default: nombre de la relación
|
|
77
|
+
emptyState: string // default: "No hay registros relacionados"
|
|
78
|
+
addLabel: string // default: "Agregar"
|
|
79
|
+
removeLabel: string // default: "Quitar"
|
|
80
|
+
confirmRemove: string // default: "¿Quitar la relación?"
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3.2 `kind="one_to_many"`
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
interface DynamicRelationOneToManyProps extends DynamicRelationCommonProps {
|
|
88
|
+
kind: 'one_to_many'
|
|
89
|
+
/**
|
|
90
|
+
* Override del modelo hijo. Default = `RelationDef.through`.
|
|
91
|
+
* El componente lista filas de `through` filtradas por
|
|
92
|
+
* `foreignKey == parentId`.
|
|
93
|
+
*/
|
|
94
|
+
model: string // owner
|
|
95
|
+
foreignKey?: string
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Comportamiento:**
|
|
100
|
+
- Lista: `GET /api/dynamic/<through>?f_<foreignKey>=eq:<parentId>` (filter sintaxis estándar de `query/params.go`).
|
|
101
|
+
- Crear: `POST /api/dynamic/<through>` con `{ <foreignKey>: parentId, ...form }`. El `<DynamicForm>` interno usa la metadata del modelo `through` y oculta el FK porque ya está fijado.
|
|
102
|
+
- Borrar (desvincular ≡ borrar la fila hija): `DELETE /api/dynamic/<through>/<childId>`.
|
|
103
|
+
- Editar: redirige al CRUD page del modelo hijo, no se inlinea (se evita renderear dos formularios full-screen).
|
|
104
|
+
|
|
105
|
+
### 3.3 `kind="many_to_many"`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface DynamicRelationManyToManyProps extends DynamicRelationCommonProps {
|
|
109
|
+
kind: 'many_to_many'
|
|
110
|
+
/**
|
|
111
|
+
* Override de la pivot table. Default = `RelationDef.pivot`. Sólo
|
|
112
|
+
* se necesita si el host expone variantes custom; el caso normal
|
|
113
|
+
* queda undefined.
|
|
114
|
+
*/
|
|
115
|
+
through?: string // target model
|
|
116
|
+
pivot?: string
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Campos extra de la pivot que el formulario de attach debe
|
|
120
|
+
* pedir al usuario (ej: `role` en `user_org`, `quantity` en
|
|
121
|
+
* `order_product`). Si está vacío, attach es un combobox simple
|
|
122
|
+
* con sólo el target id.
|
|
123
|
+
*/
|
|
124
|
+
pivotFields?: string[]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Comportamiento:**
|
|
129
|
+
- Lista: `GET /api/dynamic/<model>/<parentId>/relations/<name>` — sub-resource virtual; la respuesta es la unión target ⨝ pivot, paginada con la misma envelope que `<DynamicTable>` consume hoy.
|
|
130
|
+
- Attach: `POST /api/dynamic/<model>/<parentId>/relations/<name>` con `{ target_id: "...", ...pivotFields }`. Si `pivotFields` está vacío, el componente usa un combobox poblado por el endpoint `/api/options/<through>` ya existente; con `pivotFields` no vacío, abre un `<DynamicForm>` inline.
|
|
131
|
+
- Detach: `DELETE /api/dynamic/<model>/<parentId>/relations/<name>/<targetIdOrPivotId>`.
|
|
132
|
+
- Editar pivot: si el manifest declara `pivot.editable=true`, click en una fila abre el `<DynamicForm>` con los `pivotFields`; PUT al mismo path del detach.
|
|
133
|
+
|
|
134
|
+
> **Nota para el kernel:** los endpoints `/api/dynamic/:model/:id/relations/:name(/...)` no existen todavía. Son la otra mitad de este RFC y dependen del trabajo descrito en §6.
|
|
135
|
+
|
|
136
|
+
### 3.4 Tipo unión exportado
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
export type DynamicRelationProps =
|
|
140
|
+
| DynamicRelationOneToManyProps
|
|
141
|
+
| DynamicRelationManyToManyProps
|
|
142
|
+
|
|
143
|
+
export function DynamicRelation(props: DynamicRelationProps): JSX.Element
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
El discriminator es `kind`. TypeScript narrowea automáticamente, así que `props.pivot` sólo es accesible cuando `kind === "many_to_many"`.
|
|
147
|
+
|
|
148
|
+
## 4. Resolución de metadata
|
|
149
|
+
|
|
150
|
+
La metadata se lee del cache compartido (`useMetadataCache` en `metadata-cache.ts`). El SDK necesita:
|
|
151
|
+
|
|
152
|
+
1. **`TableMetadata`** del modelo `through` — para columns de la tabla embebida.
|
|
153
|
+
2. **`RelationDef`** del modelo dueño — para validar `name`, derivar `foreignKey`, `through`, `pivot`.
|
|
154
|
+
|
|
155
|
+
Se propone extender `TableMetadata` con un campo `relations?: RelationDef[]` (mismo shape que el manifest, expuesto vía `GET /api/dynamic/:model/metadata`). El SDK no fabrica defaults: si el `name` solicitado no existe en `metadata.relations`, el componente renderiza un panel de error con el mensaje `Unknown relation "<name>" on model "<model>"` en dev y un fallback vacío en producción (mismo patrón que `<DynamicTable>` cuando recibe un model inexistente).
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// types.ts — addition
|
|
159
|
+
export interface RelationMetadata {
|
|
160
|
+
name: string
|
|
161
|
+
kind: DynamicRelationKind
|
|
162
|
+
through: string
|
|
163
|
+
foreignKey: string
|
|
164
|
+
references?: string // default "id"
|
|
165
|
+
pivot?: string
|
|
166
|
+
pivotFields?: ActionFieldDef[] // m2m only — campos editables sobre el pivot
|
|
167
|
+
permissions?: { canAttach: boolean; canDetach: boolean; canCreate: boolean; canDelete: boolean }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface TableMetadata {
|
|
171
|
+
// ...existing
|
|
172
|
+
relations?: RelationMetadata[]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## 5. Ejemplos de uso
|
|
177
|
+
|
|
178
|
+
### 5.1 One-to-many: comentarios de un ticket
|
|
179
|
+
|
|
180
|
+
Modelo `tickets` con `RelationDef{name: "comments", kind: "one_to_many", through: "ticket_comments", foreignKey: "ticket_id"}`.
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
import { DynamicRelation } from '@asteby/metacore-runtime-react'
|
|
184
|
+
|
|
185
|
+
export function TicketDetailPage() {
|
|
186
|
+
const { id } = useParams({ from: '/tickets/$id' })
|
|
187
|
+
return (
|
|
188
|
+
<div className="space-y-8">
|
|
189
|
+
<TicketHeader id={id} />
|
|
190
|
+
<DynamicRelation
|
|
191
|
+
kind="one_to_many"
|
|
192
|
+
model="tickets"
|
|
193
|
+
parentId={id}
|
|
194
|
+
name="comments"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Sin más config, el componente:
|
|
202
|
+
- pide `GET /api/dynamic/ticket_comments?f_ticket_id=eq:<id>`
|
|
203
|
+
- renderiza la tabla con columns derivadas del `TableMetadata` de `ticket_comments`
|
|
204
|
+
- expone un botón "Agregar" que abre `<DynamicForm>` con `ticket_id` pre-llenado y oculto.
|
|
205
|
+
|
|
206
|
+
### 5.2 Many-to-many simple: tags de un artículo
|
|
207
|
+
|
|
208
|
+
Modelo `articles` con `RelationDef{name: "tags", kind: "many_to_many", through: "tags", pivot: "article_tags", foreignKey: "article_id"}`.
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
<DynamicRelation
|
|
212
|
+
kind="many_to_many"
|
|
213
|
+
model="articles"
|
|
214
|
+
parentId={article.id}
|
|
215
|
+
name="tags"
|
|
216
|
+
/>
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Como no hay `pivotFields`, el botón "Agregar" abre un combobox poblado por `/api/options/tags` y attach es un POST con `{ target_id }`.
|
|
220
|
+
|
|
221
|
+
### 5.3 Many-to-many con pivot rico: usuarios de una organización con `role`
|
|
222
|
+
|
|
223
|
+
Modelo `organizations` con `RelationDef{name: "members", kind: "many_to_many", through: "users", pivot: "org_members", foreignKey: "organization_id"}`.
|
|
224
|
+
|
|
225
|
+
El manifest declara `pivot.fields: [{ key: "role", label: "Rol", type: "select", options: [...] }, { key: "starts_at", type: "date" }]`.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
<DynamicRelation
|
|
229
|
+
kind="many_to_many"
|
|
230
|
+
model="organizations"
|
|
231
|
+
parentId={org.id}
|
|
232
|
+
name="members"
|
|
233
|
+
strings={{ title: 'Miembros', addLabel: 'Invitar' }}
|
|
234
|
+
/>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
El botón "Invitar" abre un `<DynamicForm>` con un combobox `target_id` (poblado por `/api/options/users`) más los `pivotFields`. Al guardar: `POST /api/dynamic/organizations/<orgId>/relations/members` con `{ target_id: "...", role: "owner", starts_at: "2026-05-04" }`.
|
|
238
|
+
|
|
239
|
+
### 5.4 Embebido en `<DynamicCRUDPage>`
|
|
240
|
+
|
|
241
|
+
El caso típico: un `tabs` panel debajo del form de edición.
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
<DynamicCRUDPage
|
|
245
|
+
model="tickets"
|
|
246
|
+
detailExtras={(record) => (
|
|
247
|
+
<Tabs defaultValue="comments">
|
|
248
|
+
<TabsList>
|
|
249
|
+
<TabsTrigger value="comments">Comentarios</TabsTrigger>
|
|
250
|
+
<TabsTrigger value="watchers">Watchers</TabsTrigger>
|
|
251
|
+
</TabsList>
|
|
252
|
+
<TabsContent value="comments">
|
|
253
|
+
<DynamicRelation kind="one_to_many" model="tickets" parentId={record.id} name="comments" />
|
|
254
|
+
</TabsContent>
|
|
255
|
+
<TabsContent value="watchers">
|
|
256
|
+
<DynamicRelation kind="many_to_many" model="tickets" parentId={record.id} name="watchers" />
|
|
257
|
+
</TabsContent>
|
|
258
|
+
</Tabs>
|
|
259
|
+
)}
|
|
260
|
+
/>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`detailExtras` ya existe (o se agrega con esta RFC) como punto de extensión del CRUD page.
|
|
264
|
+
|
|
265
|
+
## 6. Contrato backend requerido
|
|
266
|
+
|
|
267
|
+
Esta RFC asume tres endpoints adicionales en `dynamic/handler.go` (no existen todavía):
|
|
268
|
+
|
|
269
|
+
| Verb | Path | Notes |
|
|
270
|
+
| -------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
271
|
+
| `GET` | `/api/dynamic/:model/:id/relations/:name` | Lista paginada del lado N de la relación. Acepta los mismos query params que `/api/dynamic/:model`. Sólo m2m necesita esto en estricta lectura; o2m también lo expone para devolver la metadata del FK aplicada. |
|
|
272
|
+
| `POST` | `/api/dynamic/:model/:id/relations/:name` | m2m: attach. Body: `{ target_id, ...pivotFields }`. |
|
|
273
|
+
| `DELETE` | `/api/dynamic/:model/:id/relations/:name/:targetIdOrPivotId` | m2m: detach. o2m: redirige al delete del modelo hijo. |
|
|
274
|
+
|
|
275
|
+
El o2m **puede** funcionar sólo con los endpoints flat existentes (filtro `f_<fk>=eq:<id>`) — es la implementación inicial sugerida. Los sub-resource paths se reservan por consistencia y para que el SDK no tenga que ramificar el cliente HTTP por kind.
|
|
276
|
+
|
|
277
|
+
## 7. Open questions
|
|
278
|
+
|
|
279
|
+
1. **Cascade en o2m delete.** El `<DynamicRelation kind="one_to_many">` borra la fila hija; ¿debería ofrecer un modo "soft-detach" que limpia el FK a NULL en vez de borrar? Default propuesto: borrar (consistente con el comportamiento actual del `<DynamicTable>`). Soft-detach se puede agregar como `onRemove="null" | "delete"` en una iteración futura sin romper la API.
|
|
280
|
+
2. **Bulk attach.** No incluido en v1 — un solo target por click. La toolbar bulk de `<DynamicTable>` se reusará cuando el kernel exponga `POST /relations/:name/bulk`.
|
|
281
|
+
3. **Polimórficas.** Fuera de alcance. `RelationDef.Kind` no las contempla todavía; cuando lo haga, se agrega `kind: "polymorphic"` al union.
|
|
282
|
+
4. **Ordering del pivot.** Si el manifest declara `pivot.position` (columna entera), agregar drag&drop a la tabla. Marcado como follow-up; el componente expone `enableReorder?: boolean` placeholder con default `false`.
|
|
283
|
+
|
|
284
|
+
## 8. Plan de entrega
|
|
285
|
+
|
|
286
|
+
1. **Doc (esta RFC).** Acordar shape de props y endpoints requeridos. ← *este PR.*
|
|
287
|
+
2. **Kernel.** Extender `dynamic/handler.go` con los sub-resource paths (§6). Exponer `relations` en el payload de metadata.
|
|
288
|
+
3. **SDK runtime-react.** Implementar `dynamic-relation.tsx` reusando `<DynamicTable>` y `<DynamicForm>`. Re-exportar desde `index.ts`.
|
|
289
|
+
4. **Tests.** Snapshot del shape de props + test de integración contra un kernel mockeado (mismo patrón que `dynamic-table` ya usa).
|
|
290
|
+
5. **Adopción.** Una app del ecosistema (probablemente *link*) migra al menos una pantalla detail-with-children como dogfood antes del release.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"import": "./dist/index.js"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"zod": "^4.3.0"
|
|
21
|
+
},
|
|
19
22
|
"peerDependencies": {
|
|
20
23
|
"react": ">=18",
|
|
21
24
|
"react-dom": ">=18",
|
|
@@ -29,7 +32,7 @@
|
|
|
29
32
|
"lucide-react": ">=0.460",
|
|
30
33
|
"date-fns": ">=3",
|
|
31
34
|
"react-day-picker": ">=8",
|
|
32
|
-
"@asteby/metacore-sdk": "^2.
|
|
35
|
+
"@asteby/metacore-sdk": "^2.4.0",
|
|
33
36
|
"@asteby/metacore-ui": "^2.0.0"
|
|
34
37
|
},
|
|
35
38
|
"peerDependenciesMeta": {
|
|
@@ -53,15 +56,18 @@
|
|
|
53
56
|
"react-dom": "^19.2.4",
|
|
54
57
|
"react-i18next": "^17.0.0",
|
|
55
58
|
"sonner": "^2.0.0",
|
|
59
|
+
"tsx": "^4.21.0",
|
|
56
60
|
"typescript": "^5.6.0",
|
|
61
|
+
"vitest": "^4.0.0",
|
|
57
62
|
"zustand": "^5.0.0",
|
|
58
|
-
"@asteby/metacore-sdk": "2.
|
|
63
|
+
"@asteby/metacore-sdk": "2.4.0",
|
|
59
64
|
"@asteby/metacore-ui": "2.0.0"
|
|
60
65
|
},
|
|
61
66
|
"scripts": {
|
|
62
67
|
"build": "tsc -p tsconfig.json",
|
|
63
68
|
"dev": "tsc -w -p tsconfig.json",
|
|
64
69
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
70
|
+
"test": "vitest run",
|
|
65
71
|
"clean": "rm -rf dist"
|
|
66
72
|
}
|
|
67
73
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isColumnVisibleInTable,
|
|
5
|
+
getSearchableColumnKeys,
|
|
6
|
+
} from '../column-visibility'
|
|
7
|
+
import type { ColumnDefinition, TableMetadata } from '../types'
|
|
8
|
+
|
|
9
|
+
const baseCol = (overrides: Partial<ColumnDefinition> = {}): ColumnDefinition => ({
|
|
10
|
+
key: 'name',
|
|
11
|
+
label: 'Name',
|
|
12
|
+
type: 'text',
|
|
13
|
+
sortable: true,
|
|
14
|
+
filterable: false,
|
|
15
|
+
...overrides,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const baseMeta = (columns: ColumnDefinition[]): TableMetadata => ({
|
|
19
|
+
title: 'Mock',
|
|
20
|
+
endpoint: '/data/mock',
|
|
21
|
+
columns,
|
|
22
|
+
actions: [],
|
|
23
|
+
perPageOptions: [10],
|
|
24
|
+
defaultPerPage: 10,
|
|
25
|
+
searchPlaceholder: 'Search…',
|
|
26
|
+
enableCRUDActions: true,
|
|
27
|
+
hasActions: false,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('isColumnVisibleInTable', () => {
|
|
31
|
+
it('keeps columns with no visibility flag (legacy zero-value)', () => {
|
|
32
|
+
expect(isColumnVisibleInTable(baseCol())).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps columns with visibility="all"', () => {
|
|
36
|
+
expect(isColumnVisibleInTable(baseCol({ visibility: 'all' }))).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps columns with visibility="table"', () => {
|
|
40
|
+
expect(isColumnVisibleInTable(baseCol({ visibility: 'table' }))).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('hides columns with visibility="modal"', () => {
|
|
44
|
+
expect(isColumnVisibleInTable(baseCol({ visibility: 'modal' }))).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('hides columns with visibility="list"', () => {
|
|
48
|
+
expect(isColumnVisibleInTable(baseCol({ visibility: 'list' }))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('hides columns with the legacy hidden boolean even if visibility="all"', () => {
|
|
52
|
+
expect(isColumnVisibleInTable(baseCol({ hidden: true, visibility: 'all' }))).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('hides columns with an unknown visibility value (fail-closed)', () => {
|
|
56
|
+
expect(isColumnVisibleInTable(baseCol({ visibility: 'detail' as any }))).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('getSearchableColumnKeys', () => {
|
|
61
|
+
it('returns null when no column declares searchable (legacy metadata)', () => {
|
|
62
|
+
const meta = baseMeta([
|
|
63
|
+
baseCol({ key: 'name' }),
|
|
64
|
+
baseCol({ key: 'email' }),
|
|
65
|
+
])
|
|
66
|
+
expect(getSearchableColumnKeys(meta)).toBe(null)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns only the searchable keys when at least one column declares it', () => {
|
|
70
|
+
const meta = baseMeta([
|
|
71
|
+
baseCol({ key: 'name', searchable: true }),
|
|
72
|
+
baseCol({ key: 'email', searchable: true }),
|
|
73
|
+
baseCol({ key: 'phone', searchable: false }),
|
|
74
|
+
baseCol({ key: 'created_at' }), // undefined → not searchable
|
|
75
|
+
])
|
|
76
|
+
expect(getSearchableColumnKeys(meta)).toEqual(['name', 'email'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns an empty array when every column is explicitly opted out', () => {
|
|
80
|
+
const meta = baseMeta([
|
|
81
|
+
baseCol({ key: 'name', searchable: false }),
|
|
82
|
+
baseCol({ key: 'email', searchable: false }),
|
|
83
|
+
])
|
|
84
|
+
expect(getSearchableColumnKeys(meta)).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats searchable=true on a single column as the explicit allowlist', () => {
|
|
88
|
+
const meta = baseMeta([
|
|
89
|
+
baseCol({ key: 'name', searchable: true }),
|
|
90
|
+
baseCol({ key: 'internal_notes' }),
|
|
91
|
+
])
|
|
92
|
+
expect(getSearchableColumnKeys(meta)).toEqual(['name'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('handles missing columns array defensively', () => {
|
|
96
|
+
const meta = { columns: undefined as unknown as ColumnDefinition[] }
|
|
97
|
+
expect(getSearchableColumnKeys(meta as any)).toBe(null)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('column-visibility integration with mock metadata', () => {
|
|
102
|
+
it('filtering and search keys can be derived from a single mock', () => {
|
|
103
|
+
const meta = baseMeta([
|
|
104
|
+
baseCol({ key: 'id', visibility: 'list', searchable: false }),
|
|
105
|
+
baseCol({ key: 'name', visibility: 'all', searchable: true }),
|
|
106
|
+
baseCol({ key: 'email', visibility: 'table', searchable: true }),
|
|
107
|
+
baseCol({ key: 'password_hash', visibility: 'modal', searchable: false }),
|
|
108
|
+
baseCol({ key: 'profile', visibility: 'modal', searchable: false }),
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
const tableColumns = meta.columns.filter(isColumnVisibleInTable).map(c => c.key)
|
|
112
|
+
expect(tableColumns).toEqual(['name', 'email'])
|
|
113
|
+
|
|
114
|
+
expect(getSearchableColumnKeys(meta)).toEqual(['name', 'email'])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildZodSchema, resolveWidget } from '../dynamic-form-schema'
|
|
3
|
+
import type { ActionFieldDef } from '../types'
|
|
4
|
+
|
|
5
|
+
describe('buildZodSchema', () => {
|
|
6
|
+
it('aplica regex de Validation a strings', () => {
|
|
7
|
+
const fields: ActionFieldDef[] = [
|
|
8
|
+
{ key: 'sku', label: 'SKU', type: 'string', required: true, validation: { regex: '^[A-Z]{3}-\\d{3}$' } },
|
|
9
|
+
]
|
|
10
|
+
const schema = buildZodSchema(fields)
|
|
11
|
+
expect(schema.safeParse({ sku: 'ABC-123' }).success).toBe(true)
|
|
12
|
+
expect(schema.safeParse({ sku: 'abc-123' }).success).toBe(false)
|
|
13
|
+
expect(schema.safeParse({ sku: 'ABCD-123' }).success).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('aplica min/max como longitud sobre strings', () => {
|
|
17
|
+
const fields: ActionFieldDef[] = [
|
|
18
|
+
{ key: 'name', label: 'Nombre', type: 'string', required: true, validation: { min: 3, max: 8 } },
|
|
19
|
+
]
|
|
20
|
+
const schema = buildZodSchema(fields)
|
|
21
|
+
expect(schema.safeParse({ name: 'ab' }).success).toBe(false)
|
|
22
|
+
expect(schema.safeParse({ name: 'abc' }).success).toBe(true)
|
|
23
|
+
expect(schema.safeParse({ name: 'abcdefgh' }).success).toBe(true)
|
|
24
|
+
expect(schema.safeParse({ name: 'abcdefghi' }).success).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('aplica min/max como bounds sobre números', () => {
|
|
28
|
+
const fields: ActionFieldDef[] = [
|
|
29
|
+
{ key: 'age', label: 'Edad', type: 'number', required: true, validation: { min: 18, max: 99 } },
|
|
30
|
+
]
|
|
31
|
+
const schema = buildZodSchema(fields)
|
|
32
|
+
expect(schema.safeParse({ age: 17 }).success).toBe(false)
|
|
33
|
+
expect(schema.safeParse({ age: 18 }).success).toBe(true)
|
|
34
|
+
expect(schema.safeParse({ age: 99 }).success).toBe(true)
|
|
35
|
+
expect(schema.safeParse({ age: 100 }).success).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('marca campos requeridos vacíos como inválidos', () => {
|
|
39
|
+
const fields: ActionFieldDef[] = [
|
|
40
|
+
{ key: 'title', label: 'Título', type: 'string', required: true },
|
|
41
|
+
]
|
|
42
|
+
const schema = buildZodSchema(fields)
|
|
43
|
+
expect(schema.safeParse({ title: '' }).success).toBe(false)
|
|
44
|
+
expect(schema.safeParse({ title: 'ok' }).success).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('campos opcionales aceptan vacío o ausente', () => {
|
|
48
|
+
const fields: ActionFieldDef[] = [
|
|
49
|
+
{ key: 'note', label: 'Nota', type: 'string' },
|
|
50
|
+
{ key: 'qty', label: 'Cantidad', type: 'number' },
|
|
51
|
+
]
|
|
52
|
+
const schema = buildZodSchema(fields)
|
|
53
|
+
expect(schema.safeParse({ note: '', qty: '' }).success).toBe(true)
|
|
54
|
+
expect(schema.safeParse({}).success).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('valida email y url por type', () => {
|
|
58
|
+
const fields: ActionFieldDef[] = [
|
|
59
|
+
{ key: 'mail', label: 'Email', type: 'email', required: true },
|
|
60
|
+
{ key: 'site', label: 'URL', type: 'url', required: true },
|
|
61
|
+
]
|
|
62
|
+
const schema = buildZodSchema(fields)
|
|
63
|
+
expect(schema.safeParse({ mail: 'a@b.co', site: 'https://x.test' }).success).toBe(true)
|
|
64
|
+
expect(schema.safeParse({ mail: 'no-email', site: 'https://x.test' }).success).toBe(false)
|
|
65
|
+
expect(schema.safeParse({ mail: 'a@b.co', site: 'no-url' }).success).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('regex inválida no rompe el build (silently skipped)', () => {
|
|
69
|
+
const fields: ActionFieldDef[] = [
|
|
70
|
+
{ key: 'x', label: 'X', type: 'string', required: true, validation: { regex: '[invalid(' } },
|
|
71
|
+
]
|
|
72
|
+
expect(() => buildZodSchema(fields)).not.toThrow()
|
|
73
|
+
const schema = buildZodSchema(fields)
|
|
74
|
+
expect(schema.safeParse({ x: 'anything' }).success).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('booleans requeridos exigen un valor explícito', () => {
|
|
78
|
+
const fields: ActionFieldDef[] = [
|
|
79
|
+
{ key: 'agree', label: 'Acepto', type: 'boolean', required: true },
|
|
80
|
+
]
|
|
81
|
+
const schema = buildZodSchema(fields)
|
|
82
|
+
expect(schema.safeParse({ agree: true }).success).toBe(true)
|
|
83
|
+
expect(schema.safeParse({ agree: false }).success).toBe(true)
|
|
84
|
+
expect(schema.safeParse({}).success).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('resolveWidget', () => {
|
|
89
|
+
it('respeta widget explícito', () => {
|
|
90
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'textarea' })).toBe('textarea')
|
|
91
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'richtext' })).toBe('richtext')
|
|
92
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'color' })).toBe('color')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('cae al inferido por type cuando widget no está', () => {
|
|
96
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'textarea' })).toBe('textarea')
|
|
97
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'select' })).toBe('select')
|
|
98
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'boolean' })).toBe('switch')
|
|
99
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'number' })).toBe('number')
|
|
100
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'date' })).toBe('date')
|
|
101
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'string' })).toBe('text')
|
|
102
|
+
expect(resolveWidget({ key: 'k', label: 'L', type: 'email' })).toBe('text')
|
|
103
|
+
})
|
|
104
|
+
})
|