@groupteknology/vuno 0.1.0 → 0.3.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,311 @@
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 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` · `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
+ `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`.
93
+
94
+ ```vue
95
+ <VFormModal v-model:open="open" v-model:state="state" :definition="definition" :schema="schema" title="Nuevo producto" close-on-submit @submit="guardar" />
96
+ ```
97
+
98
+ ## Una pantalla de mantenimiento entera
99
+
100
+ `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).
101
+
102
+ ```vue
103
+ <script setup lang="ts">
104
+ const query = useVunoTableQuery({ sort: 'sku', url: true })
105
+
106
+ const { items, total, failure, isPending, isSaving, isDeleting,
107
+ formOpen, editing, state, submit, openCreate, openEdit,
108
+ confirmOpen, deleting, askDelete, confirmDelete } = useVunoCrud<Product, ProductForm>({
109
+ key: 'products',
110
+ query,
111
+ emptyForm: () => ({ name: '', sku: '' }),
112
+ list: (q) => $fetch('/api/products', { query: q }),
113
+ create: (form) => $fetch('/api/products', { body: form, method: 'POST' }),
114
+ update: (id, form) => $fetch(`/api/products/${id}`, { body: form, method: 'PATCH' }),
115
+ remove: (row) => $fetch(`/api/products/${row.id}`, { method: 'DELETE' }),
116
+ })
117
+ </script>
118
+ ```
119
+
120
+ 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`.
121
+
122
+ ### Claves que dependen de estado reactivo
123
+
124
+ `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.
125
+
126
+ ```ts
127
+ const crud = useVunoCrud({
128
+ key: () => ['products', selectedCompanyId.value],
129
+ // ...
130
+ })
131
+ ```
132
+
133
+ 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.
134
+
135
+ Por eso no hace falta ni `?? ''` ni un `enabled` por pantalla:
136
+
137
+ ```ts
138
+ // Sobra: el `?? ''` convierte «aún no la empresa» en una empresa válida.
139
+ key: () => ['products', selectedCompanyId.value ?? '']
140
+
141
+ // Basta: mientras la empresa no llegue, no se consulta.
142
+ key: () => ['products', selectedCompanyId.value]
143
+ ```
144
+
145
+ `0`, `''` y `false` son valores y no suspenden nada.
146
+
147
+ 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.
148
+
149
+ 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.
150
+
151
+ ```ts
152
+ useVunoCrud({ key: () => ['products', companyId.value], enabled: () => canRead.value, /* ... */ })
153
+ ```
154
+
155
+ ### La clave de invalidación
156
+
157
+ `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:
158
+
159
+ ```ts
160
+ key: () => productKeys.all() // un alta refresca todo el dominio
161
+ key: () => productKeys.list(filtros) // solo refresca esa combinación de filtros
162
+ ```
163
+
164
+ ### Render en servidor
165
+
166
+ `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.
167
+
168
+ 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:
169
+
170
+ ```ts
171
+ // app/plugins/vue-query.ts
172
+ export default defineNuxtPlugin((nuxtApp) => {
173
+ const state = useState<DehydratedState | null>('vue-query', () => null)
174
+ const queryClient = new QueryClient()
175
+
176
+ nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })
177
+
178
+ if (import.meta.server) nuxtApp.hooks.hook('app:rendered', () => { state.value = dehydrate(queryClient) })
179
+ if (import.meta.client) hydrate(queryClient, state.value)
180
+ })
181
+ ```
182
+
183
+ 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.
184
+
185
+ `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.
186
+
187
+ 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.
188
+
189
+ `@tanstack/vue-query` es una dependencia opcional: solo hace falta si usas este composable.
190
+
191
+ ## Páginas
192
+
193
+ `VPage` pone la estructura que repite cualquier pantalla de mantenimiento:
194
+
195
+ ```vue
196
+ <VPage title="Productos" description="Catálogo completo." :breadcrumb="[{ label: 'Inicio', to: '/' }, { label: 'Productos' }]">
197
+ <template #actions>
198
+ <UButton icon="lucide:plus" label="Nuevo producto" @click="openCreate" />
199
+ </template>
200
+
201
+ <VTable ... />
202
+ </VPage>
203
+ ```
204
+
205
+ ## Tablas
206
+
207
+ `VTable` funciona en modo servidor: emite el estado de consulta y tú devuelves la página.
208
+
209
+ ```vue
210
+ <script setup lang="ts">
211
+ const query = useVunoTableQuery({ sort: 'sku', url: true, history: 'push', filterKeys: ['status'] })
212
+
213
+ const { data, status } = await useFetch('/api/products', {
214
+ query: computed(() => ({ page: query.value.page, sort: query.value.sort?.column, ...query.value.filters })),
215
+ })
216
+ </script>
217
+
218
+ <template>
219
+ <VTable v-model:query="query" :definition="definition" :data="data?.data ?? []" :total="data?.total ?? 0" :loading="status === 'pending'" />
220
+ </template>
221
+ ```
222
+
223
+ 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.
224
+
225
+ ### Una tabla vacía no es una que falló
226
+
227
+ 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.
228
+
229
+ ```ts
230
+ const failure = computed(() => isError.value
231
+ ? { title: 'No pudimos cargar los productos', hint: 'Puede ser algo temporal.', retry: () => void refetch() }
232
+ : undefined)
233
+ ```
234
+
235
+ 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».
236
+
237
+ Los dos estados existen además como componentes sueltos, para las pantallas que no son una tabla:
238
+
239
+ ```vue
240
+ <VEmptyState title="Todavía no hay pedidos" hint="Aparecerán aquí en cuanto entre el primero." icon="lucide:inbox">
241
+ <template #actions><UButton label="Crear pedido" /></template>
242
+ </VEmptyState>
243
+
244
+ <VFailureState :failure="failure" />
245
+ ```
246
+
247
+ 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.
248
+
249
+ ## Diálogos de confirmación
250
+
251
+ `VConfirm` acepta `description` como texto plano y un slot por defecto para cuando el aviso es información que cambia la decisión:
252
+
253
+ ```vue
254
+ <VConfirm v-model:open="open" :title="`¿Eliminar ${marca.nombre}?`" @confirm="borrar">
255
+ <p>Se eliminará <strong>{{ marca.nombre }}</strong>.</p>
256
+ <p class="text-warning">{{ marca.productos }} productos se quedarán sin marca.</p>
257
+ </VConfirm>
258
+ ```
259
+
260
+ No se cierra solo cuando recibe `loading`: así se espera a que la acción termine contra la API y se cierra desde fuera.
261
+
262
+ ## Composables
263
+
264
+ | Composable | Para qué |
265
+ |---|---|
266
+ | `useVunoCrud` | El ciclo de mantenimiento entero sobre TanStack Query. |
267
+ | `useVunoTableQuery` | Estado de consulta de una tabla, opcionalmente sincronizado con la query string. |
268
+ | `useVunoForm` | Contexto del `VForm` que envuelve al componente: estado, `dirty`, `reset`. |
269
+ | `useVunoField` | Lee y escribe un campo por su ruta; para construir campos propios. |
270
+ | `useVunoTheme` | Resuelve los slots de un componente cruzando tema, `app.config` y prop `ui`. |
271
+ | `useVunoIcons` | Los iconos configurados en `vuno.icons`. |
272
+ | `useVunoMessages` | Los textos configurados en `vuno.messages`. |
273
+
274
+ 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.
275
+
276
+ ## Personalización
277
+
278
+ Tres niveles, de más global a más concreto:
279
+
280
+ ```ts
281
+ // app.config.ts — para toda la aplicación
282
+ export default defineAppConfig({
283
+ ui: { colors: { primary: 'indigo' } },
284
+ vuno: {
285
+ formSection: { slots: { root: 'border-dashed' } },
286
+ icons: { slugEdit: 'lucide:wand-sparkles' },
287
+ messages: { selectPlaceholder: 'Elige una opción' },
288
+ },
289
+ })
290
+ ```
291
+
292
+ ```vue
293
+ <!-- prop `ui` — para una instancia -->
294
+ <VForm :ui="{ sections: 'gap-8' }" ... />
295
+ ```
296
+
297
+ Los textos e iconos están todos en `vuno.messages` y `vuno.icons`: el paquete no impone idioma ni colección de iconos.
298
+
299
+ ## Estado
300
+
301
+ En desarrollo.
302
+
303
+ ### Nota sobre pruebas automatizadas
304
+
305
+ 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.
306
+
307
+ 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.
308
+
309
+ ## Licencia
310
+
311
+ [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.3.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
+ };
@@ -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
@@ -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,30 @@ 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
46
  const failure = computed(() => isError.value ? { retry: () => void refetch() } : void 0);
35
47
  function invalidate() {
48
+ if (!resolvedKey.value.ready) return;
36
49
  void queryClient.invalidateQueries({ queryKey: baseKey.value });
37
50
  }
38
51
  const formOpen = ref(false);
@@ -100,6 +113,7 @@ export function useVunoCrud(options) {
100
113
  formOpen,
101
114
  invalidate,
102
115
  isDeleting: remove.isPending,
116
+ isEnabled,
103
117
  isPending,
104
118
  isSaving: save.isPending,
105
119
  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
  }
@@ -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> = {
@@ -14,16 +14,42 @@ export type VunoCrudMessages = {
14
14
  updateFailed: string;
15
15
  };
16
16
  export type VunoCrudOptions<TRow extends VunoTableRow, TForm> = {
17
+ /**
18
+ * Condición extra para consultar, además de que la clave esté completa.
19
+ * Para lo que no se deduce de la clave: un permiso, una pestaña oculta.
20
+ */
21
+ enabled?: MaybeRefOrGetter<boolean>;
17
22
  /**
18
23
  * Clave base de la caché. La consulta se añade automáticamente, así que
19
24
  * cada página, filtro y orden se cachean por separado.
25
+ *
26
+ * Admite un getter o un `computed` para las claves que dependen de estado
27
+ * reactivo —la empresa, la tienda o el almacén seleccionados—: sin eso la
28
+ * clave se congelaría en su valor inicial y la pantalla seguiría enseñando
29
+ * los datos del anterior sin dar señal de error.
30
+ *
31
+ * ```ts
32
+ * key: () => ['products', selectedCompanyId.value]
33
+ * ```
34
+ *
35
+ * Un segmento `undefined` o `null` significa que la clave aún no está
36
+ * lista y suspende la consulta, así que no hace falta `?? ''` ni un
37
+ * `enabled` por pantalla.
20
38
  */
21
- key: readonly unknown[] | string;
39
+ key: MaybeRefOrGetter<null | readonly unknown[] | string | undefined>;
22
40
  messages?: Partial<VunoCrudMessages>;
23
41
  /** Estado de consulta compartido con la tabla. */
24
42
  query?: Ref<VunoTableQuery>;
25
43
  /** Campo que identifica la fila. Por defecto `id`. */
26
44
  rowKey?: string;
45
+ /**
46
+ * Resolver el listado durante el render en servidor. Por defecto `true`.
47
+ *
48
+ * Ponlo en `false` para lo que no se ve en la primera pintada —una lista
49
+ * dentro de un modal, una pestaña oculta—: ahí esperar solo retrasa el
50
+ * HTML sin que nadie lo aproveche.
51
+ */
52
+ server?: boolean;
27
53
  create?: (form: TForm) => Promise<unknown>;
28
54
  /** Estado inicial del formulario al dar de alta. */
29
55
  emptyForm: () => TForm;
@@ -46,6 +72,8 @@ export type VunoCrud<TRow extends VunoTableRow, TForm> = {
46
72
  failure: ComputedRef<undefined | VunoTableFailure>;
47
73
  formOpen: Ref<boolean>;
48
74
  isDeleting: Ref<boolean>;
75
+ /** La clave está completa y la consulta puede dispararse. */
76
+ isEnabled: ComputedRef<boolean>;
49
77
  isPending: Ref<boolean>;
50
78
  isSaving: Ref<boolean>;
51
79
  items: ComputedRef<TRow[]>;
@@ -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.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {