@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 +330 -215
- package/dist/module.json +1 -1
- package/dist/runtime/app/components/Confirm/Confirm.d.vue.ts +15 -1
- package/dist/runtime/app/components/Confirm/Confirm.vue +36 -33
- package/dist/runtime/app/components/Confirm/Confirm.vue.d.ts +15 -1
- package/dist/runtime/app/components/Field/FieldSwitch.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldSwitch.vue +27 -0
- package/dist/runtime/app/components/Field/FieldSwitch.vue.d.ts +5 -0
- package/dist/runtime/app/components/Form/Form.vue +1 -0
- package/dist/runtime/app/components/Form/FormField.vue +18 -16
- package/dist/runtime/app/components/Form/FormModal.d.vue.ts +6 -0
- package/dist/runtime/app/components/Form/FormModal.vue +47 -46
- package/dist/runtime/app/components/Form/FormModal.vue.d.ts +6 -0
- package/dist/runtime/app/composables/useVunoCrud.js +26 -4
- package/dist/runtime/app/theme/index.js +4 -0
- package/dist/runtime/app/types/crud.d.ts +47 -3
- package/dist/runtime/app/types/field.d.ts +8 -1
- package/dist/runtime/app/utils/query-key.d.ts +20 -0
- package/dist/runtime/app/utils/query-key.js +7 -0
- package/package.json +1 -1
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
[
|
|
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` sí 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
|
@@ -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
|
|
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
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
:
|
|
76
|
-
:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
+
}
|