@groupteknology/vuno 0.1.0 → 0.4.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/README.md CHANGED
@@ -1,215 +1,330 @@
1
- # @groupteknology/vuno
2
-
3
- Formularios, tablas y diálogos declarativos para Nuxt 4, construidos sobre [Nuxt UI](https://ui.nuxt.com).
4
-
5
- El módulo **no aporta estética**. Todo lo que pinta sale del tema del proyecto que lo instala: usa las utilidades semánticas de Nuxt UI (`bg-default`, `text-muted`, `border-default`), su escala de radios y sus colores. Si cambias `primary` o `--ui-radius`, los componentes de vuno cambian contigo.
6
-
7
- ## Instalación
8
-
9
- ```bash
10
- pnpm add @groupteknology/vuno
11
- ```
12
-
13
- ```ts
14
- // nuxt.config.ts
15
- export default defineNuxtConfig({
16
- modules: ['@nuxt/ui', '@groupteknology/vuno'],
17
- })
18
- ```
19
-
20
- ## Formularios
21
-
22
- Un formulario se declara como datos y se renderiza solo. El estado vive en un único objeto y cada campo lo lee y lo escribe por ruta, incluidas las anidadas.
23
-
24
- ```vue
25
- <script setup lang="ts">
26
- import type { VunoFormDefinition } from '@groupteknology/vuno'
27
- import * as z from 'zod'
28
-
29
- const schema = z.object({
30
- sku: z.string().min(1, 'El SKU es requerido'),
31
- name: z.string().min(1, 'El nombre es requerido'),
32
- })
33
-
34
- const state = ref({ sku: '', name: '', slug: '' })
35
-
36
- const definition = {
37
- layout: 'tabs', // 'sections' (por defecto) | 'tabs'
38
- sections: [
39
- {
40
- id: 'general',
41
- title: 'Datos generales',
42
- icon: 'lucide:info',
43
- fields: [
44
- { name: 'sku', type: 'text', label: 'SKU', columns: { xs: 12, lg: 6 }, required: true },
45
- { name: 'name', type: 'text', label: 'Nombre', columns: { xs: 12, lg: 6 }, required: true },
46
- { name: 'slug', type: 'slug', label: 'Slug', source: 'name' },
47
- ],
48
- },
49
- ],
50
- } satisfies VunoFormDefinition
51
- </script>
52
-
53
- <template>
54
- <VForm v-model:state="state" :definition="definition" :schema="schema" @submit="guardar">
55
- <template #footer="{ dirty, reset }">
56
- <UButton :disabled="!dirty" label="Descartar" variant="ghost" @click="reset" />
57
- <UButton label="Guardar" type="submit" />
58
- </template>
59
- </VForm>
60
- </template>
61
- ```
62
-
63
- ### Tipos de campo
64
-
65
- `text` · `email` · `password` · `textarea` · `number` · `select` · `radio` · `checkbox` · `color` · `slug` · `file` · `array`
66
-
67
- Cada uno declara sus propios props mediante una unión discriminada por `type`, así que el editor te avisa si a un `slug` le falta el `source` o si le pasas un `placeholder` a un `file`.
68
-
69
- ### Campos repetibles
70
-
71
- `type: 'array'` repite un grupo de campos y genera rutas anidadas (`variants.0.sku`) que coinciden con las que produce la validación, de modo que cada campo muestra su propio error. Admite anidamiento.
72
-
73
- ```ts
74
- {
75
- name: 'variants',
76
- type: 'array',
77
- label: 'Variantes',
78
- sortable: true,
79
- fields: [
80
- { name: 'sku', type: 'text', label: 'SKU', columns: { xs: 12, md: 6 } },
81
- { name: 'stock', type: 'number', label: 'Stock', columns: { xs: 12, md: 6 } },
82
- ],
83
- }
84
- ```
85
-
86
- Con `layout: 'tabs'`, cada pestaña muestra un contador de los campos con error que contiene, incluidos los anidados.
87
-
88
- ### En un modal
89
-
90
- `VFormModal` añade el diálogo, los botones y el ancho adecuado. Los botones van en el pie del modal —fijo mientras el cuerpo hace scroll— asociados al formulario por `id`.
91
-
92
- ```vue
93
- <VFormModal v-model:open="open" v-model:state="state" :definition="definition" :schema="schema" title="Nuevo producto" close-on-submit @submit="guardar" />
94
- ```
95
-
96
- ## Una pantalla de mantenimiento entera
97
-
98
- `useVunoCrud` resuelve el ciclo completo: listar, dar de alta o editar en un modal, borrar con confirmación, invalidar la caché y avisar. Está construido sobre [TanStack Query](https://tanstack.com/query).
99
-
100
- ```vue
101
- <script setup lang="ts">
102
- const query = useVunoTableQuery({ sort: 'sku', url: true })
103
-
104
- const { items, total, failure, isPending, isSaving, isDeleting,
105
- formOpen, editing, state, submit, openCreate, openEdit,
106
- confirmOpen, deleting, askDelete, confirmDelete } = useVunoCrud<Product, ProductForm>({
107
- key: 'products',
108
- query,
109
- emptyForm: () => ({ name: '', sku: '' }),
110
- list: (q) => $fetch('/api/products', { query: q }),
111
- create: (form) => $fetch('/api/products', { body: form, method: 'POST' }),
112
- update: (id, form) => $fetch(`/api/products/${id}`, { body: form, method: 'PATCH' }),
113
- remove: (row) => $fetch(`/api/products/${row.id}`, { method: 'DELETE' }),
114
- })
115
- </script>
116
- ```
117
-
118
- La consulta forma parte de la clave de caché, así que cada página, filtro y orden se guarda por separado. Usa `placeholderData: keepPreviousData`, que mantiene en pantalla los datos anteriores mientras llega la nueva página: sin eso, cada cambio de filtro deja `data` en `undefined` un instante y la tabla parpadea enseñando su estado vacío. Los textos de los avisos salen de `vuno.messages` y se pueden afinar por pantalla con la opción `messages`.
119
-
120
- `@tanstack/vue-query` es una dependencia opcional: solo hace falta si usas este composable.
121
-
122
- ## Páginas
123
-
124
- `VPage` pone la estructura que repite cualquier pantalla de mantenimiento:
125
-
126
- ```vue
127
- <VPage title="Productos" description="Catálogo completo." :breadcrumb="[{ label: 'Inicio', to: '/' }, { label: 'Productos' }]">
128
- <template #actions>
129
- <UButton icon="lucide:plus" label="Nuevo producto" @click="openCreate" />
130
- </template>
131
-
132
- <VTable ... />
133
- </VPage>
134
- ```
135
-
136
- ## Tablas
137
-
138
- `VTable` funciona en modo servidor: emite el estado de consulta y tú devuelves la página.
139
-
140
- ```vue
141
- <script setup lang="ts">
142
- const query = useVunoTableQuery({ sort: 'sku', url: true, history: 'push', filterKeys: ['status'] })
143
-
144
- const { data, status } = await useFetch('/api/products', {
145
- query: computed(() => ({ page: query.value.page, sort: query.value.sort?.column, ...query.value.filters })),
146
- })
147
- </script>
148
-
149
- <template>
150
- <VTable v-model:query="query" :definition="definition" :data="data?.data ?? []" :total="data?.total ?? 0" :loading="status === 'pending'" />
151
- </template>
152
- ```
153
-
154
- Con `url: true` los filtros, la página, la búsqueda y el orden viajan en la query string: el enlace es compartible y recargar conserva la vista.
155
-
156
- ### Una tabla vacía no es una que falló
157
-
158
- Son dos estados distintos y se pintan distinto. Sin separarlos, una consulta que falla se normaliza a lista vacía (`data ?? []`) y la tabla dice «No hay resultados»: afirma que no hay nada cuando lo cierto es que no se pudo saber.
159
-
160
- ```ts
161
- const failure = computed(() => isError.value
162
- ? { title: 'No pudimos cargar los productos', hint: 'Puede ser algo temporal.', retry: () => void refetch() }
163
- : undefined)
164
- ```
165
-
166
- El reintento va **dentro** del objeto para que no se pueda pintar un error sin ofrecer salida. Mientras hay fallo, la tabla no muestra filas ni paginación. El estado vacío se declara en la definición (`empty: { title, hint, icon }`) y tiene un slot `empty-actions` donde encaja el «crear el primero».
167
-
168
- Los dos estados existen además como componentes sueltos, para las pantallas que no son una tabla:
169
-
170
- ```vue
171
- <VEmptyState title="Todavía no hay pedidos" hint="Aparecerán aquí en cuanto entre el primero." icon="lucide:inbox">
172
- <template #actions><UButton label="Crear pedido" /></template>
173
- </VEmptyState>
174
-
175
- <VFailureState :failure="failure" />
176
- ```
177
-
178
- Tipos de columna: `text` · `number` · `currency` · `date` · `boolean` · `badge` · `image` · `custom` · `actions`. Trae búsqueda con antirrebote, paginación, selección múltiple, panel lateral de filtros y menú de acciones por fila.
179
-
180
- ## Personalización
181
-
182
- Tres niveles, de más global a más concreto:
183
-
184
- ```ts
185
- // app.config.ts para toda la aplicación
186
- export default defineAppConfig({
187
- ui: { colors: { primary: 'indigo' } },
188
- vuno: {
189
- formSection: { slots: { root: 'border-dashed' } },
190
- icons: { slugEdit: 'lucide:wand-sparkles' },
191
- messages: { selectPlaceholder: 'Elige una opción' },
192
- },
193
- })
194
- ```
195
-
196
- ```vue
197
- <!-- prop `ui` — para una instancia -->
198
- <VForm :ui="{ sections: 'gap-8' }" ... />
199
- ```
200
-
201
- Los textos e iconos están todos en `vuno.messages` y `vuno.icons`: el paquete no impone idioma ni colección de iconos.
202
-
203
- ## Estado
204
-
205
- En desarrollo.
206
-
207
- ### Nota sobre pruebas automatizadas
208
-
209
- Al comprobar los diálogos con un navegador controlado por automatización, el nodo del modal puede seguir en el DOM después de cerrarlo. No es un fallo: Chrome congela las animaciones CSS en pestañas ocultas y Reka UI espera el evento `animationend` para desmontar el diálogo. El componente ya está cerrado —lo indica su `data-state="closed"`—, solo falta la limpieza.
210
-
211
- Comprueba la visibilidad real (`opacity`, `data-state`) en lugar de la existencia del nodo, o haz la prueba con la pestaña en primer plano.
212
-
213
- ## Licencia
214
-
215
- [MIT](./LICENSE) © Diego Otayza
1
+ # @groupteknology/vuno
2
+
3
+ Formularios, tablas y diálogos declarativos para Nuxt 4, construidos sobre [Nuxt UI](https://ui.nuxt.com).
4
+
5
+ El módulo **no aporta estética**. Todo lo que pinta sale del tema del proyecto que lo instala: usa las utilidades semánticas de Nuxt UI (`bg-default`, `text-muted`, `border-default`), su escala de radios y sus colores. Si cambias `primary` o `--ui-radius`, los componentes de vuno cambian contigo.
6
+
7
+ ## Instalación
8
+
9
+ ```bash
10
+ pnpm add @groupteknology/vuno
11
+ ```
12
+
13
+ ```ts
14
+ // nuxt.config.ts
15
+ export default defineNuxtConfig({
16
+ modules: ['@nuxt/ui', '@groupteknology/vuno'],
17
+ })
18
+ ```
19
+
20
+ ## Formularios
21
+
22
+ Un formulario se declara como datos y se renderiza solo. El estado vive en un único objeto y cada campo lo lee y lo escribe por ruta, incluidas las anidadas.
23
+
24
+ ```vue
25
+ <script setup lang="ts">
26
+ import type { VunoFormDefinition } from '@groupteknology/vuno'
27
+ import * as z from 'zod'
28
+
29
+ const schema = z.object({
30
+ sku: z.string().min(1, 'El SKU es requerido'),
31
+ name: z.string().min(1, 'El nombre es requerido'),
32
+ })
33
+
34
+ const state = ref({ sku: '', name: '', slug: '' })
35
+
36
+ const definition = {
37
+ layout: 'tabs', // 'sections' (por defecto) | 'tabs'
38
+ sections: [
39
+ {
40
+ id: 'general',
41
+ title: 'Datos generales',
42
+ icon: 'lucide:info',
43
+ fields: [
44
+ { name: 'sku', type: 'text', label: 'SKU', columns: { xs: 12, lg: 6 }, required: true },
45
+ { name: 'name', type: 'text', label: 'Nombre', columns: { xs: 12, lg: 6 }, required: true },
46
+ { name: 'slug', type: 'slug', label: 'Slug', source: 'name' },
47
+ ],
48
+ },
49
+ ],
50
+ } satisfies VunoFormDefinition
51
+ </script>
52
+
53
+ <template>
54
+ <VForm v-model:state="state" :definition="definition" :schema="schema" @submit="guardar">
55
+ <template #footer="{ dirty, reset }">
56
+ <UButton :disabled="!dirty" label="Descartar" variant="ghost" @click="reset" />
57
+ <UButton label="Guardar" type="submit" />
58
+ </template>
59
+ </VForm>
60
+ </template>
61
+ ```
62
+
63
+ ### Tipos de campo
64
+
65
+ `text` · `email` · `password` · `textarea` · `number` · `select` · `radio` · `checkbox` · `switch` · `color` · `slug` · `file` · `array`
66
+
67
+ `source` del campo `slug` es el **nombre** del campo del que deriva, no su valor.
68
+
69
+ Cada uno declara sus propios props mediante una unión discriminada por `type`, así que el editor te avisa si a un `slug` le falta el `source` o si le pasas un `placeholder` a un `file`.
70
+
71
+ ### Campos repetibles
72
+
73
+ `type: 'array'` repite un grupo de campos y genera rutas anidadas (`variants.0.sku`) que coinciden con las que produce la validación, de modo que cada campo muestra su propio error. Admite anidamiento.
74
+
75
+ ```ts
76
+ {
77
+ name: 'variants',
78
+ type: 'array',
79
+ label: 'Variantes',
80
+ sortable: true,
81
+ fields: [
82
+ { name: 'sku', type: 'text', label: 'SKU', columns: { xs: 12, md: 6 } },
83
+ { name: 'stock', type: 'number', label: 'Stock', columns: { xs: 12, md: 6 } },
84
+ ],
85
+ }
86
+ ```
87
+
88
+ Con `layout: 'tabs'`, cada pestaña muestra un contador de los campos con error que contiene, incluidos los anidados.
89
+
90
+ ### En un modal
91
+
92
+ El slot `footer` recibe `formId`, necesario para enviar desde un botón propio: el pie vive fuera del `<form>`, así que la asociación se hace con el atributo nativo.
93
+
94
+ ```vue
95
+ <VFormModal v-model:open="open" v-model:state="state" :definition="definition" :title="title">
96
+ <template #footer="{ close, formId, loading }">
97
+ <UButton label="Cancelar" variant="ghost" @click="close" />
98
+ <UButton :form="formId" :label="editando ? 'Guardar cambios' : 'Crear'" :loading="loading" type="submit" />
99
+ </template>
100
+ </VFormModal>
101
+ ```
102
+
103
+ `VFormModal` añade el diálogo, los botones y el ancho adecuado. Los botones van en el pie del modal —fijo mientras el cuerpo hace scroll— asociados al formulario por `id`.
104
+
105
+ ```vue
106
+ <VFormModal v-model:open="open" v-model:state="state" :definition="definition" :schema="schema" title="Nuevo producto" close-on-submit @submit="guardar" />
107
+ ```
108
+
109
+ ## Una pantalla de mantenimiento entera
110
+
111
+ `useVunoCrud` resuelve el ciclo completo: listar, dar de alta o editar en un modal, borrar con confirmación, invalidar la caché y avisar. Está construido sobre [TanStack Query](https://tanstack.com/query).
112
+
113
+ ```vue
114
+ <script setup lang="ts">
115
+ // En SSR, `$fetch` no reenvía la cookie de la petición y una ruta con sesión
116
+ // responde 401 durante el render. `useRequestFetch()` sí la lleva.
117
+ const request = useRequestFetch()
118
+
119
+ const query = useVunoTableQuery({ sort: 'sku', url: true })
120
+
121
+ const { items, total, failure, isPending, isSaving, isDeleting,
122
+ formOpen, editing, state, submit, openCreate, openEdit,
123
+ confirmOpen, deleting, askDelete, confirmDelete } = useVunoCrud<Product, ProductForm>({
124
+ key: 'products',
125
+ query,
126
+ emptyForm: () => ({ name: '', sku: '' }),
127
+ list: (q) => request('/api/products', { query: q }),
128
+ create: (form) => $fetch('/api/products', { body: form, method: 'POST' }),
129
+ update: (id, form) => $fetch(`/api/products/${id}`, { body: form, method: 'PATCH' }),
130
+ remove: (row) => $fetch(`/api/products/${row.id}`, { method: 'DELETE' }),
131
+ })
132
+ </script>
133
+ ```
134
+
135
+ Solo `list` necesita `useRequestFetch()`: es la única que corre en servidor. El alta, la edición y el borrado salen siempre del navegador, donde `$fetch` lleva la cookie por su cuenta.
136
+
137
+ La consulta forma parte de la clave de caché, así que cada página, filtro y orden se guarda por separado. Usa `placeholderData: keepPreviousData`, que mantiene en pantalla los datos anteriores mientras llega la nueva página: sin eso, cada cambio de filtro deja `data` en `undefined` un instante y la tabla parpadea enseñando su estado vacío. Los textos de los avisos salen de `vuno.messages` y se pueden afinar por pantalla con la opción `messages`.
138
+
139
+ ### Claves que dependen de estado reactivo
140
+
141
+ `key` acepta un getter o un `computed`, no solo un valor fijo. Hace falta en cuanto la pantalla tiene un selector —empresa, tienda, almacén—: con una clave fija, cambiar de empresa seguiría enseñando los datos de la anterior sin dar ninguna señal de error.
142
+
143
+ ```ts
144
+ const crud = useVunoCrud({
145
+ key: () => ['products', selectedCompanyId.value],
146
+ // ...
147
+ })
148
+ ```
149
+
150
+ Un segmento `undefined` o `null` marca la clave como incompleta y **suspende la consulta**. En una clave de caché ese hueco nunca es un valor: es un dato que todavía no ha llegado, y consultar con él devolvería la lista de otro inquilino o una vacía que parecería legítima.
151
+
152
+ Por eso no hace falta ni `?? ''` ni un `enabled` por pantalla:
153
+
154
+ ```ts
155
+ // Sobra: el `?? ''` convierte «aún no sé la empresa» en una empresa válida.
156
+ key: () => ['products', selectedCompanyId.value ?? '']
157
+
158
+ // Basta: mientras la empresa no llegue, no se consulta.
159
+ key: () => ['products', selectedCompanyId.value]
160
+ ```
161
+
162
+ `0`, `''` y `false` son valores y no suspenden nada.
163
+
164
+ Mientras la clave está incompleta, `isPending` sigue en `true`: la pantalla está esperando, y esa es la diferencia con un listado vacío. `isEnabled` dice si la consulta puede dispararse, e `invalidate()` no toca la caché con una clave a medias, porque invalidar por un prefijo incompleto alcanzaría a otras pantallas.
165
+
166
+ Para lo que no se deduce de la clave —un permiso, una pestaña que no está a la vista— está la opción `enabled`, que se suma a la condición anterior.
167
+
168
+ ```ts
169
+ useVunoCrud({ key: () => ['products', companyId.value], enabled: () => canRead.value, /* ... */ })
170
+ ```
171
+
172
+ ### La clave de invalidación
173
+
174
+ `invalidate()` invalida **por prefijo**, usando la clave base que le des. Pásale el prefijo más ancho del dominio y deja que vuno añada el estado de consulta como último segmento:
175
+
176
+ ```ts
177
+ key: () => productKeys.all() // un alta refresca todo el dominio
178
+ key: () => productKeys.list(filtros) // solo refresca esa combinación de filtros
179
+ ```
180
+
181
+ ### Render en servidor
182
+
183
+ `useVunoCrud` resuelve el listado durante el render en servidor, así que el HTML sale con las filas puestas y no aparecen al hidratar. Nuxt renderiza en servidor por defecto, de modo que esto no hay que activarlo.
184
+
185
+ Para que además viaje en el payload y el cliente no vuelva a pedirlo, el plugin de vue-query del proyecto tiene que deshidratar. Es responsabilidad del consumidor, no del módulo:
186
+
187
+ ```ts
188
+ // app/plugins/vue-query.ts
189
+ export default defineNuxtPlugin((nuxtApp) => {
190
+ const state = useState<DehydratedState | null>('vue-query', () => null)
191
+ const queryClient = new QueryClient()
192
+
193
+ nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })
194
+
195
+ if (import.meta.server) nuxtApp.hooks.hook('app:rendered', () => { state.value = dehydrate(queryClient) })
196
+ if (import.meta.client) hydrate(queryClient, state.value)
197
+ })
198
+ ```
199
+
200
+ **Si tu API tiene sesión, `list` tiene que usar `useRequestFetch()`.** Un `$fetch` pelado no reenvía la cookie de la petición entrante, así que durante el render en servidor la llamada sale sin autenticar y la ruta responde 401. Es el tropiezo más probable al estrenar esto, porque en cliente el mismo código funciona.
201
+
202
+ Con la clave incompleta no se espera nada: la consulta está suspendida y esperarla colgaría el render entero, porque una consulta deshabilitada nunca se asienta.
203
+
204
+ `server: false` desactiva la espera para lo que no se ve en la primera pintada —una lista dentro de un modal, una pestaña oculta—, donde solo retrasaría el HTML.
205
+
206
+ Si compruebas esto con un test, **mira el HTML renderizado, no el payload**. El payload lleva la consulta resuelta aunque el prefetch esté apagado, porque la consulta se asienta después del render y el plugin deshidrata lo que encuentra: un assert sobre el payload pasa en las dos direcciones y no mide nada.
207
+
208
+ `@tanstack/vue-query` es una dependencia opcional: solo hace falta si usas este composable.
209
+
210
+ ## Páginas
211
+
212
+ `VPage` pone la estructura que repite cualquier pantalla de mantenimiento:
213
+
214
+ ```vue
215
+ <VPage title="Productos" description="Catálogo completo." :breadcrumb="[{ label: 'Inicio', to: '/' }, { label: 'Productos' }]">
216
+ <template #actions>
217
+ <UButton icon="lucide:plus" label="Nuevo producto" @click="openCreate" />
218
+ </template>
219
+
220
+ <VTable ... />
221
+ </VPage>
222
+ ```
223
+
224
+ ## Tablas
225
+
226
+ `VTable` funciona en modo servidor: emite el estado de consulta y tú devuelves la página.
227
+
228
+ ```vue
229
+ <script setup lang="ts">
230
+ const query = useVunoTableQuery({ sort: 'sku', url: true, history: 'push', filterKeys: ['status'] })
231
+
232
+ const { data, status } = await useFetch('/api/products', {
233
+ query: computed(() => ({ page: query.value.page, sort: query.value.sort?.column, ...query.value.filters })),
234
+ })
235
+ </script>
236
+
237
+ <template>
238
+ <VTable v-model:query="query" :definition="definition" :data="data?.data ?? []" :total="data?.total ?? 0" :loading="status === 'pending'" />
239
+ </template>
240
+ ```
241
+
242
+ Con `url: true` los filtros, la página, la búsqueda y el orden viajan en la query string: el enlace es compartible y recargar conserva la vista.
243
+
244
+ ### Una tabla vacía no es una que falló
245
+
246
+ Son dos estados distintos y se pintan distinto. Sin separarlos, una consulta que falla se normaliza a lista vacía (`data ?? []`) y la tabla dice «No hay resultados»: afirma que no hay nada cuando lo cierto es que no se pudo saber.
247
+
248
+ ```ts
249
+ const failure = computed(() => isError.value
250
+ ? { title: 'No pudimos cargar los productos', hint: 'Puede ser algo temporal.', retry: () => void refetch() }
251
+ : undefined)
252
+ ```
253
+
254
+ El reintento va **dentro** del objeto para que no se pueda pintar un error sin ofrecer salida. Mientras hay fallo, la tabla no muestra filas ni paginación. El estado vacío se declara en la definición (`empty: { title, hint, icon }`) y tiene un slot `empty-actions` donde encaja el «crear el primero».
255
+
256
+ Los dos estados existen además como componentes sueltos, para las pantallas que no son una tabla:
257
+
258
+ ```vue
259
+ <VEmptyState title="Todavía no hay pedidos" hint="Aparecerán aquí en cuanto entre el primero." icon="lucide:inbox">
260
+ <template #actions><UButton label="Crear pedido" /></template>
261
+ </VEmptyState>
262
+
263
+ <VFailureState :failure="failure" />
264
+ ```
265
+
266
+ Tipos de columna: `text` · `number` · `currency` · `date` · `boolean` · `badge` · `image` · `custom` · `actions`. Trae búsqueda con antirrebote, paginación, selección múltiple, panel lateral de filtros y menú de acciones por fila.
267
+
268
+ ## Diálogos de confirmación
269
+
270
+ `VConfirm` acepta `description` como texto plano y un slot por defecto para cuando el aviso es información que cambia la decisión:
271
+
272
+ ```vue
273
+ <VConfirm v-model:open="open" :title="`¿Eliminar ${marca.nombre}?`" @confirm="borrar">
274
+ <p>Se eliminará <strong>{{ marca.nombre }}</strong>.</p>
275
+ <p class="text-warning">{{ marca.productos }} productos se quedarán sin marca.</p>
276
+ </VConfirm>
277
+ ```
278
+
279
+ No se cierra solo cuando recibe `loading`: así se espera a que la acción termine contra la API y se cierra desde fuera.
280
+
281
+ ## Composables
282
+
283
+ | Composable | Para qué |
284
+ |---|---|
285
+ | `useVunoCrud` | El ciclo de mantenimiento entero sobre TanStack Query. |
286
+ | `useVunoTableQuery` | Estado de consulta de una tabla, opcionalmente sincronizado con la query string. |
287
+ | `useVunoForm` | Contexto del `VForm` que envuelve al componente: estado, `dirty`, `reset`. |
288
+ | `useVunoField` | Lee y escribe un campo por su ruta; para construir campos propios. |
289
+ | `useVunoTheme` | Resuelve los slots de un componente cruzando tema, `app.config` y prop `ui`. |
290
+ | `useVunoIcons` | Los iconos configurados en `vuno.icons`. |
291
+ | `useVunoMessages` | Los textos configurados en `vuno.messages`. |
292
+
293
+ Los tres últimos existen para que un componente tuyo pueda usar los mismos iconos, textos y reglas de tema que los de vuno sin duplicar la configuración.
294
+
295
+ ## Personalización
296
+
297
+ Tres niveles, de más global a más concreto:
298
+
299
+ ```ts
300
+ // app.config.ts — para toda la aplicación
301
+ export default defineAppConfig({
302
+ ui: { colors: { primary: 'indigo' } },
303
+ vuno: {
304
+ formSection: { slots: { root: 'border-dashed' } },
305
+ icons: { slugEdit: 'lucide:wand-sparkles' },
306
+ messages: { selectPlaceholder: 'Elige una opción' },
307
+ },
308
+ })
309
+ ```
310
+
311
+ ```vue
312
+ <!-- prop `ui` — para una instancia -->
313
+ <VForm :ui="{ sections: 'gap-8' }" ... />
314
+ ```
315
+
316
+ Los textos e iconos están todos en `vuno.messages` y `vuno.icons`: el paquete no impone idioma ni colección de iconos.
317
+
318
+ ## Estado
319
+
320
+ En desarrollo.
321
+
322
+ ### Nota sobre pruebas automatizadas
323
+
324
+ Al comprobar los diálogos con un navegador controlado por automatización, el nodo del modal puede seguir en el DOM después de cerrarlo. No es un fallo: Chrome congela las animaciones CSS en pestañas ocultas y Reka UI espera el evento `animationend` para desmontar el diálogo. El componente ya está cerrado —lo indica su `data-state="closed"`—, solo falta la limpieza.
325
+
326
+ Comprueba la visibilidad real (`opacity`, `data-state`) en lugar de la existencia del nodo, o haz la prueba con la pestaña en primer plano.
327
+
328
+ ## Licencia
329
+
330
+ [MIT](./LICENSE) © Diego Otayza
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vuno",
3
3
  "configKey": "vuno",
4
- "version": "0.1.0",
4
+ "version": "0.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -4,11 +4,19 @@ type __VLS_Props = VunoConfirmOptions & {
4
4
  loading?: boolean;
5
5
  ui?: VunoFieldSlots;
6
6
  };
7
+ type __VLS_Slots = {
8
+ /**
9
+ * Sustituye a `description`. Para cuando el aviso es información que
10
+ * cambia la decisión —cuántos hijos quedan huérfanos, qué se pierde—
11
+ * y aplanarlo a una frase la escondería.
12
+ */
13
+ default?: () => unknown;
14
+ };
7
15
  type __VLS_ModelProps = {
8
16
  'open'?: boolean;
9
17
  };
10
18
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
- declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
19
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
20
  "update:open": (value: boolean) => any;
13
21
  confirm: () => any;
14
22
  cancel: () => any;
@@ -17,5 +25,11 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
17
25
  onConfirm?: (() => any) | undefined;
18
26
  onCancel?: (() => any) | undefined;
19
27
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
20
29
  declare const _default: typeof __VLS_export;
21
30
  export default _default;
31
+ type __VLS_WithSlots<T, S> = T & {
32
+ new (): {
33
+ $slots: S;
34
+ };
35
+ };
@@ -12,6 +12,7 @@ const props = defineProps({
12
12
  ui: { type: Object, required: false }
13
13
  });
14
14
  const emit = defineEmits(["cancel", "confirm"]);
15
+ defineSlots();
15
16
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
16
17
  const icons = useVunoIcons();
17
18
  const messages = useVunoMessages();
@@ -29,37 +30,39 @@ function onConfirm() {
29
30
  </script>
30
31
 
31
32
  <template>
32
- <UModal
33
- v-model:open="open"
34
- :dismissible="!props.loading"
35
- scrollable
36
- :title="props.title ?? messages.confirmTitle"
37
- :ui="modalUi"
38
- >
39
- <template #body>
40
- <div :class="ui.body">
41
- <UIcon
42
- :class="[ui.icon, `text-${color}`]"
43
- :name="props.icon ?? icons.confirm"
44
- />
45
- <p>{{ props.description ?? messages.confirmDescription }}</p>
46
- </div>
47
- </template>
48
-
49
- <template #footer>
50
- <UButton
51
- color="neutral"
52
- :disabled="props.loading"
53
- :label="props.cancelLabel ?? messages.cancel"
54
- variant="ghost"
55
- @click="onCancel"
56
- />
57
- <UButton
58
- :color="color"
59
- :label="props.confirmLabel ?? messages.confirm"
60
- :loading="props.loading"
61
- @click="onConfirm"
62
- />
63
- </template>
64
- </UModal>
33
+ <UModal
34
+ v-model:open="open"
35
+ :dismissible="!props.loading"
36
+ scrollable
37
+ :title="props.title ?? messages.confirmTitle"
38
+ :ui="modalUi"
39
+ >
40
+ <template #body>
41
+ <div :class="ui.body">
42
+ <UIcon
43
+ :class="[ui.icon, `text-${color}`]"
44
+ :name="props.icon ?? icons.confirm"
45
+ />
46
+ <div :class="ui.description">
47
+ <slot>{{ props.description ?? messages.confirmDescription }}</slot>
48
+ </div>
49
+ </div>
50
+ </template>
51
+
52
+ <template #footer>
53
+ <UButton
54
+ color="neutral"
55
+ :disabled="props.loading"
56
+ :label="props.cancelLabel ?? messages.cancel"
57
+ variant="ghost"
58
+ @click="onCancel"
59
+ />
60
+ <UButton
61
+ :color="color"
62
+ :label="props.confirmLabel ?? messages.confirm"
63
+ :loading="props.loading"
64
+ @click="onConfirm"
65
+ />
66
+ </template>
67
+ </UModal>
65
68
  </template>
@@ -4,11 +4,19 @@ type __VLS_Props = VunoConfirmOptions & {
4
4
  loading?: boolean;
5
5
  ui?: VunoFieldSlots;
6
6
  };
7
+ type __VLS_Slots = {
8
+ /**
9
+ * Sustituye a `description`. Para cuando el aviso es información que
10
+ * cambia la decisión —cuántos hijos quedan huérfanos, qué se pierde—
11
+ * y aplanarlo a una frase la escondería.
12
+ */
13
+ default?: () => unknown;
14
+ };
7
15
  type __VLS_ModelProps = {
8
16
  'open'?: boolean;
9
17
  };
10
18
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
- declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
19
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
20
  "update:open": (value: boolean) => any;
13
21
  confirm: () => any;
14
22
  cancel: () => any;
@@ -17,5 +25,11 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
17
25
  onConfirm?: (() => any) | undefined;
18
26
  onCancel?: (() => any) | undefined;
19
27
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
20
29
  declare const _default: typeof __VLS_export;
21
30
  export default _default;
31
+ type __VLS_WithSlots<T, S> = T & {
32
+ new (): {
33
+ $slots: S;
34
+ };
35
+ };
@@ -0,0 +1,5 @@
1
+ import type { VunoFieldSwitch } from '#vuno/types/field';
2
+ type __VLS_Props = Omit<VunoFieldSwitch, 'class' | 'columns'>;
3
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
4
+ declare const _default: typeof __VLS_export;
5
+ export default _default;
@@ -0,0 +1,27 @@
1
+ <script setup>
2
+ import { useVunoField } from "#vuno/composables/useVunoField";
3
+ import { useVunoTheme } from "#vuno/composables/useVunoTheme";
4
+ const props = defineProps({
5
+ name: { type: String, required: true },
6
+ description: { type: String, required: false },
7
+ disabled: { type: Boolean, required: false },
8
+ help: { type: String, required: false },
9
+ hint: { type: String, required: false },
10
+ label: { type: String, required: false },
11
+ required: { type: Boolean, required: false },
12
+ size: { type: String, required: false },
13
+ ui: { type: Object, required: false }
14
+ });
15
+ const { value } = useVunoField(() => props.name);
16
+ const ui = useVunoTheme("fieldSwitch", () => props.ui);
17
+ </script>
18
+
19
+ <template>
20
+ <USwitch
21
+ v-model="value"
22
+ :disabled="props.disabled"
23
+ :name="props.name"
24
+ :size="props.size"
25
+ :ui="ui"
26
+ />
27
+ </template>
@@ -0,0 +1,5 @@
1
+ import type { VunoFieldSwitch } from '#vuno/types/field';
2
+ type __VLS_Props = Omit<VunoFieldSwitch, 'class' | 'columns'>;
3
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
4
+ declare const _default: typeof __VLS_export;
5
+ export default _default;
@@ -101,6 +101,7 @@ defineExpose({ dirty, errors: errorNames, isDirty, reset, resetField, submit: ()
101
101
  :size="size"
102
102
  :ui="{ root: ui.tabs, content: ui.tabsContent }"
103
103
  :unmountOnHide="false"
104
+ variant="link"
104
105
  >
105
106
  <template #content="{ item }">
106
107
  <FormSection
@@ -12,6 +12,7 @@ import FieldPassword from "#vuno/components/Field/FieldPassword.vue";
12
12
  import FieldRadio from "#vuno/components/Field/FieldRadio.vue";
13
13
  import FieldSelect from "#vuno/components/Field/FieldSelect.vue";
14
14
  import FieldSlug from "#vuno/components/Field/FieldSlug.vue";
15
+ import FieldSwitch from "#vuno/components/Field/FieldSwitch.vue";
15
16
  import FieldText from "#vuno/components/Field/FieldText.vue";
16
17
  import FieldTextarea from "#vuno/components/Field/FieldTextarea.vue";
17
18
  const props = defineProps({
@@ -28,6 +29,7 @@ const components = {
28
29
  radio: FieldRadio,
29
30
  select: FieldSelect,
30
31
  slug: FieldSlug,
32
+ switch: FieldSwitch,
31
33
  text: FieldText,
32
34
  textarea: FieldTextarea
33
35
  };
@@ -42,20 +44,20 @@ const fieldProps = computed(() => {
42
44
  </script>
43
45
 
44
46
  <template>
45
- <UFormField
46
- :class="props.field.class"
47
- :description="props.field.description"
48
- :help="props.field.help"
49
- :hint="props.field.hint"
50
- :label="props.field.label"
51
- :name="props.field.name"
52
- :required="props.field.required"
53
- :size="size"
54
- :ui="ui"
55
- >
56
- <component
57
- :is="component"
58
- v-bind="fieldProps"
59
- />
60
- </UFormField>
47
+ <UFormField
48
+ :class="props.field.class"
49
+ :description="props.field.description"
50
+ :help="props.field.help"
51
+ :hint="props.field.hint"
52
+ :label="props.field.label"
53
+ :name="props.field.name"
54
+ :required="props.field.required"
55
+ :size="size"
56
+ :ui="ui"
57
+ >
58
+ <component
59
+ :is="component"
60
+ v-bind="fieldProps"
61
+ />
62
+ </UFormField>
61
63
  </template>
@@ -32,8 +32,14 @@ declare const __VLS_export: <TState extends VunoFormState>(__VLS_props: NonNulla
32
32
  expose: (exposed: {}) => void;
33
33
  attrs: any;
34
34
  slots: {
35
+ /**
36
+ * `formId` es imprescindible, no un extra: el pie del modal vive fuera
37
+ * del `<form>`, así que un botón propio solo puede enviarlo con
38
+ * `:form="formId"`. Sin exponerlo, cualquier pie a medida queda mudo.
39
+ */
35
40
  footer?: (props: {
36
41
  dirty: boolean;
42
+ formId: string;
37
43
  loading: boolean;
38
44
  close: () => void;
39
45
  reset: () => void;
@@ -33,50 +33,51 @@ function onSubmit(event) {
33
33
  </script>
34
34
 
35
35
  <template>
36
- <UModal
37
- v-model:open="open"
38
- :description="props.description"
39
- :dismissible="!props.loading"
40
- scrollable
41
- :title="props.title"
42
- :ui="modalUi"
43
- >
44
- <slot name="trigger" />
45
-
46
- <template #body>
47
- <Form
48
- :id="formId"
49
- ref="form"
50
- v-model:state="state"
51
- :definition="props.definition"
52
- :schema="props.schema"
53
- :ui="props.formUi"
54
- @submit="onSubmit"
55
- />
56
- </template>
57
-
58
- <template #footer>
59
- <slot
60
- :close="close"
61
- :dirty="form?.dirty ?? false"
62
- :loading="props.loading ?? false"
63
- name="footer"
64
- :reset="() => form?.reset?.()"
65
- >
66
- <UButton
67
- color="neutral"
68
- :disabled="props.loading"
69
- :label="messages.cancel"
70
- variant="ghost"
71
- @click="close"
72
- />
73
- <UButton
74
- :form="formId"
75
- :label="messages.save"
76
- :loading="props.loading"
77
- type="submit"
78
- />
79
- </slot>
80
- </template>
81
- </UModal>
36
+ <UModal
37
+ v-model:open="open"
38
+ :description="props.description"
39
+ :dismissible="!props.loading"
40
+ scrollable
41
+ :title="props.title"
42
+ :ui="modalUi"
43
+ >
44
+ <slot name="trigger" />
45
+
46
+ <template #body>
47
+ <Form
48
+ :id="formId"
49
+ ref="form"
50
+ v-model:state="state"
51
+ :definition="props.definition"
52
+ :schema="props.schema"
53
+ :ui="props.formUi"
54
+ @submit="onSubmit"
55
+ />
56
+ </template>
57
+
58
+ <template #footer>
59
+ <slot
60
+ :close="close"
61
+ :dirty="form?.dirty ?? false"
62
+ :formId="formId"
63
+ :loading="props.loading ?? false"
64
+ name="footer"
65
+ :reset="() => form?.reset?.()"
66
+ >
67
+ <UButton
68
+ color="neutral"
69
+ :disabled="props.loading"
70
+ :label="messages.cancel"
71
+ variant="ghost"
72
+ @click="close"
73
+ />
74
+ <UButton
75
+ :form="formId"
76
+ :label="messages.save"
77
+ :loading="props.loading"
78
+ type="submit"
79
+ />
80
+ </slot>
81
+ </template>
82
+ </UModal>
82
83
  </template>
@@ -32,8 +32,14 @@ declare const __VLS_export: <TState extends VunoFormState>(__VLS_props: NonNulla
32
32
  expose: (exposed: {}) => void;
33
33
  attrs: any;
34
34
  slots: {
35
+ /**
36
+ * `formId` es imprescindible, no un extra: el pie del modal vive fuera
37
+ * del `<form>`, así que un botón propio solo puede enviarlo con
38
+ * `:form="formId"`. Sin exponerlo, cualquier pie a medida queda mudo.
39
+ */
35
40
  footer?: (props: {
36
41
  dirty: boolean;
42
+ formId: string;
37
43
  loading: boolean;
38
44
  close: () => void;
39
45
  reset: () => void;
@@ -1,7 +1,8 @@
1
1
  import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
2
2
  import { useToast } from "@nuxt/ui/composables";
3
- import { computed, ref, shallowRef } from "vue";
3
+ import { computed, onServerPrefetch, ref, shallowRef, toValue } from "vue";
4
4
  import { getPath } from "#vuno/utils/path";
5
+ import { resolveQueryKey } from "#vuno/utils/query-key";
5
6
  import { useVunoMessages } from "./useVunoTheme.js";
6
7
  import { useVunoTableQuery } from "./useVunoTableQuery.js";
7
8
  export function useVunoCrud(options) {
@@ -9,7 +10,9 @@ export function useVunoCrud(options) {
9
10
  const queryClient = useQueryClient();
10
11
  const messages = useVunoMessages();
11
12
  const query = options.query ?? useVunoTableQuery();
12
- const baseKey = computed(() => Array.isArray(options.key) ? options.key : [options.key]);
13
+ const resolvedKey = computed(() => resolveQueryKey(toValue(options.key)));
14
+ const baseKey = computed(() => resolvedKey.value.segments);
15
+ const isEnabled = computed(() => resolvedKey.value.ready && (toValue(options.enabled) ?? true));
13
16
  const rowKey = options.rowKey ?? "id";
14
17
  const text = computed(() => ({
15
18
  created: options.messages?.created ?? messages.value.created,
@@ -19,20 +22,38 @@ export function useVunoCrud(options) {
19
22
  updated: options.messages?.updated ?? messages.value.updated,
20
23
  updateFailed: options.messages?.updateFailed ?? messages.value.saveFailed
21
24
  }));
22
- const { data, isError, isPending, refetch } = useQuery({
25
+ const { data, isError, isPending, refetch, suspense } = useQuery({
23
26
  // Sin esto, cambiar de filtro o de página deja `data` en undefined
24
27
  // mientras llega la respuesta y la tabla parpadea enseñando su estado
25
28
  // vacío. Con los datos anteriores en pantalla, el cambio no salta.
29
+ enabled: isEnabled,
26
30
  placeholderData: keepPreviousData,
27
31
  queryKey: computed(() => [...baseKey.value, query.value]),
28
32
  // La consulta forma parte de la clave: otra página u otro filtro es otra
29
33
  // consulta, y así cada una se cachea por separado.
30
34
  queryFn: () => options.list(query.value)
31
35
  });
36
+ onServerPrefetch(async () => {
37
+ if (options.server === false) return;
38
+ if (!isEnabled.value) return;
39
+ try {
40
+ await suspense();
41
+ } catch {
42
+ }
43
+ });
32
44
  const items = computed(() => data.value?.data ?? []);
33
45
  const total = computed(() => data.value?.total ?? 0);
34
- const failure = computed(() => isError.value ? { retry: () => void refetch() } : void 0);
46
+ const failure = computed(
47
+ () => isError.value ? {
48
+ // Sin valor propio los deja en `undefined` y el estado de
49
+ // fallo cae a los textos globales de `vuno.messages`.
50
+ hint: options.messages?.listFailedHint,
51
+ title: options.messages?.listFailed,
52
+ retry: () => void refetch()
53
+ } : void 0
54
+ );
35
55
  function invalidate() {
56
+ if (!resolvedKey.value.ready) return;
36
57
  void queryClient.invalidateQueries({ queryKey: baseKey.value });
37
58
  }
38
59
  const formOpen = ref(false);
@@ -100,6 +121,7 @@ export function useVunoCrud(options) {
100
121
  formOpen,
101
122
  invalidate,
102
123
  isDeleting: remove.isPending,
124
+ isEnabled,
103
125
  isPending,
104
126
  isSaving: save.isPending,
105
127
  items,
@@ -3,6 +3,7 @@ export const defaultTheme = {
3
3
  slots: {
4
4
  body: "flex items-start gap-3",
5
5
  content: "max-w-md",
6
+ description: "min-w-0 space-y-1",
6
7
  footer: "justify-end",
7
8
  icon: "shrink-0 size-5"
8
9
  }
@@ -49,6 +50,9 @@ export const defaultTheme = {
49
50
  fieldSlug: {
50
51
  slots: { root: "w-full", trailing: "pe-1" }
51
52
  },
53
+ fieldSwitch: {
54
+ slots: { root: "w-full" }
55
+ },
52
56
  fieldText: {
53
57
  slots: { root: "w-full" }
54
58
  },
@@ -1,4 +1,4 @@
1
- import type { ComputedRef, Ref } from 'vue';
1
+ import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
2
2
  import type { VunoTableFailure, VunoTableQuery, VunoTableRow } from './table.js';
3
3
  /** Respuesta de un listado paginado en servidor. */
4
4
  export type VunoCrudPage<TRow> = {
@@ -10,26 +10,68 @@ export type VunoCrudMessages = {
10
10
  createFailed: string;
11
11
  deleted: string;
12
12
  deleteFailed: string;
13
+ /** Título cuando falla el listado. Por defecto, el de `vuno.messages`. */
14
+ listFailed: string;
15
+ /**
16
+ * Pista bajo ese título. Es el sitio para lo que evita un error a quien
17
+ * mira una tabla que no cargó: «no des una de alta sin verlas, puede
18
+ * existir ya».
19
+ */
20
+ listFailedHint: string;
13
21
  updated: string;
14
22
  updateFailed: string;
15
23
  };
16
24
  export type VunoCrudOptions<TRow extends VunoTableRow, TForm> = {
25
+ /**
26
+ * Condición extra para consultar, además de que la clave esté completa.
27
+ * Para lo que no se deduce de la clave: un permiso, una pestaña oculta.
28
+ */
29
+ enabled?: MaybeRefOrGetter<boolean>;
17
30
  /**
18
31
  * Clave base de la caché. La consulta se añade automáticamente, así que
19
32
  * cada página, filtro y orden se cachean por separado.
33
+ *
34
+ * Admite un getter o un `computed` para las claves que dependen de estado
35
+ * reactivo —la empresa, la tienda o el almacén seleccionados—: sin eso la
36
+ * clave se congelaría en su valor inicial y la pantalla seguiría enseñando
37
+ * los datos del anterior sin dar señal de error.
38
+ *
39
+ * ```ts
40
+ * key: () => ['products', selectedCompanyId.value]
41
+ * ```
42
+ *
43
+ * Un segmento `undefined` o `null` significa que la clave aún no está
44
+ * lista y suspende la consulta, así que no hace falta `?? ''` ni un
45
+ * `enabled` por pantalla.
20
46
  */
21
- key: readonly unknown[] | string;
47
+ key: MaybeRefOrGetter<null | readonly unknown[] | string | undefined>;
22
48
  messages?: Partial<VunoCrudMessages>;
23
49
  /** Estado de consulta compartido con la tabla. */
24
50
  query?: Ref<VunoTableQuery>;
25
51
  /** Campo que identifica la fila. Por defecto `id`. */
26
52
  rowKey?: string;
53
+ /**
54
+ * Resolver el listado durante el render en servidor. Por defecto `true`.
55
+ *
56
+ * Ponlo en `false` para lo que no se ve en la primera pintada —una lista
57
+ * dentro de un modal, una pestaña oculta—: ahí esperar solo retrasa el
58
+ * HTML sin que nadie lo aproveche.
59
+ */
60
+ server?: boolean;
27
61
  create?: (form: TForm) => Promise<unknown>;
28
62
  /** Estado inicial del formulario al dar de alta. */
29
63
  emptyForm: () => TForm;
30
64
  list: (query: VunoTableQuery) => Promise<VunoCrudPage<TRow>>;
31
65
  remove?: (row: TRow) => Promise<unknown>;
32
- /** Convierte una fila en el estado del formulario al editar. */
66
+ /**
67
+ * Convierte una fila en el estado del formulario al editar.
68
+ *
69
+ * Es opcional solo mientras la fila y el formulario tengan la misma forma.
70
+ * En cuanto la fila traiga campos que no son del formulario —un contador,
71
+ * una fecha de alta— hace falta: sin él se copia la fila entera al estado
72
+ * y esos campos acaban viajando en el guardado, que es donde se nota, lejos
73
+ * de aquí.
74
+ */
33
75
  toForm?: (row: TRow) => TForm;
34
76
  update?: (id: number | string, form: TForm) => Promise<unknown>;
35
77
  };
@@ -46,6 +88,8 @@ export type VunoCrud<TRow extends VunoTableRow, TForm> = {
46
88
  failure: ComputedRef<undefined | VunoTableFailure>;
47
89
  formOpen: Ref<boolean>;
48
90
  isDeleting: Ref<boolean>;
91
+ /** La clave está completa y la consulta puede dispararse. */
92
+ isEnabled: ComputedRef<boolean>;
49
93
  isPending: Ref<boolean>;
50
94
  isSaving: Ref<boolean>;
51
95
  items: ComputedRef<TRow[]>;
@@ -52,6 +52,13 @@ export type VunoFieldArray = VunoFieldBase & {
52
52
  export type VunoFieldCheckbox = VunoFieldBase & {
53
53
  indeterminate?: boolean;
54
54
  };
55
+ /**
56
+ * Interruptor para un booleano que es un estado encendido o apagado, no una
57
+ * opción que se marca. La diferencia con `checkbox` es de lectura, no de dato:
58
+ * en una pantalla de ajustes el interruptor dice «esto está activo» y la
59
+ * casilla dice «esto queda seleccionado».
60
+ */
61
+ export type VunoFieldSwitch = VunoFieldBase;
55
62
  export type VunoFieldColor = VunoFieldBase & {
56
63
  placeholder?: string;
57
64
  };
@@ -112,7 +119,7 @@ export type VunoFieldTextarea = VunoFieldBase & {
112
119
  * Unión discriminada por `type`. Es lo que consume `VunoFormDefinition`, de modo
113
120
  * que cada campo de la definición queda tipado con sus propios props.
114
121
  */
115
- export type VunoField = WithType<VunoFieldArray, 'array'> | WithType<VunoFieldCheckbox, 'checkbox'> | WithType<VunoFieldColor, 'color'> | WithType<VunoFieldEmail, 'email'> | WithType<VunoFieldFile, 'file'> | WithType<VunoFieldNumber, 'number'> | WithType<VunoFieldPassword, 'password'> | WithType<VunoFieldRadio, 'radio'> | WithType<VunoFieldSelect, 'select'> | WithType<VunoFieldSlug, 'slug'> | WithType<VunoFieldText, 'text'> | WithType<VunoFieldTextarea, 'textarea'>;
122
+ export type VunoField = WithType<VunoFieldArray, 'array'> | WithType<VunoFieldCheckbox, 'checkbox'> | WithType<VunoFieldColor, 'color'> | WithType<VunoFieldEmail, 'email'> | WithType<VunoFieldFile, 'file'> | WithType<VunoFieldNumber, 'number'> | WithType<VunoFieldPassword, 'password'> | WithType<VunoFieldRadio, 'radio'> | WithType<VunoFieldSelect, 'select'> | WithType<VunoFieldSlug, 'slug'> | WithType<VunoFieldSwitch, 'switch'> | WithType<VunoFieldText, 'text'> | WithType<VunoFieldTextarea, 'textarea'>;
116
123
  export type VunoFieldType = VunoField['type'];
117
124
  /** Extrae los props de un tipo concreto de campo: `VunoFieldOf<'slug'>`. */
118
125
  export type VunoFieldOf<TType extends VunoFieldType> = Extract<VunoField, {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Clave base de la caché ya resuelta.
3
+ *
4
+ * `ready` en `false` significa que la clave todavía no se puede construir
5
+ * —falta la empresa, la tienda o el almacén que la pantalla está resolviendo—
6
+ * y que la consulta no debe dispararse.
7
+ */
8
+ export type VunoResolvedKey = {
9
+ ready: boolean;
10
+ segments: readonly unknown[];
11
+ };
12
+ /**
13
+ * Normaliza la clave base a segmentos y dice si está completa.
14
+ *
15
+ * Un segmento `undefined` o `null` marca la clave como no lista. En una clave
16
+ * de caché ese hueco nunca es un valor: es un dato que aún no ha llegado, y
17
+ * consultar con él devolvería la lista de otro inquilino o una vacía. Tratarlo
18
+ * como «todavía no» evita que cada pantalla repita su propio `enabled`.
19
+ */
20
+ export declare function resolveQueryKey(key: null | readonly unknown[] | string | undefined): VunoResolvedKey;
@@ -0,0 +1,7 @@
1
+ export function resolveQueryKey(key) {
2
+ if (key === null || key === void 0) return { ready: false, segments: [] };
3
+ const segments = Array.isArray(key) ? key : [key];
4
+ if (segments.length === 0) return { ready: false, segments };
5
+ const complete = segments.every((segment) => segment !== null && segment !== void 0);
6
+ return { ready: complete, segments };
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupteknology/vuno",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {