@asteby/metacore-runtime-react 7.1.5 → 9.0.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.
@@ -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": "7.1.5",
3
+ "version": "9.0.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,8 +32,8 @@
29
32
  "lucide-react": ">=0.460",
30
33
  "date-fns": ">=3",
31
34
  "react-day-picker": ">=8",
32
- "@asteby/metacore-sdk": "^2.2.0",
33
- "@asteby/metacore-ui": "^0.7.0"
35
+ "@asteby/metacore-sdk": "^2.4.0",
36
+ "@asteby/metacore-ui": "^2.0.0"
34
37
  },
35
38
  "peerDependenciesMeta": {
36
39
  "@tanstack/react-router": {
@@ -54,14 +57,16 @@
54
57
  "react-i18next": "^17.0.0",
55
58
  "sonner": "^2.0.0",
56
59
  "typescript": "^5.6.0",
60
+ "vitest": "^4.0.0",
57
61
  "zustand": "^5.0.0",
58
- "@asteby/metacore-sdk": "2.2.0",
59
- "@asteby/metacore-ui": "0.7.0"
62
+ "@asteby/metacore-sdk": "2.4.0",
63
+ "@asteby/metacore-ui": "2.0.0"
60
64
  },
61
65
  "scripts": {
62
66
  "build": "tsc -p tsconfig.json",
63
67
  "dev": "tsc -w -p tsconfig.json",
64
68
  "typecheck": "tsc -p tsconfig.json --noEmit",
69
+ "test": "vitest run",
65
70
  "clean": "rm -rf dist"
66
71
  }
67
72
  }
@@ -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
+ })