flysoft-react-ui 1.4.0 → 1.4.3
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 +4 -4
- package/claude/install-skill.mjs +86 -0
- package/claude/skills/flysoft-ui/SKILL.md +100 -0
- package/claude/skills/flysoft-ui/patterns.md +352 -0
- package/claude/skills/flysoft-ui/references/display.md +163 -0
- package/claude/skills/flysoft-ui/references/forms.md +203 -0
- package/claude/skills/flysoft-ui/references/hooks.md +374 -0
- package/claude/skills/flysoft-ui/references/layout.md +260 -0
- package/claude/skills/flysoft-ui/references/services.md +194 -0
- package/claude/skills/flysoft-ui/references/templates.md +155 -0
- package/claude/skills/flysoft-ui/references/theming.md +269 -0
- package/dist/index.css +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -6
package/README.md
CHANGED
|
@@ -528,11 +528,11 @@ Las contribuciones son bienvenidas. Por favor, abre un issue o un pull request.
|
|
|
528
528
|
## 🔧 Scripts de Mantenimiento
|
|
529
529
|
|
|
530
530
|
```bash
|
|
531
|
-
#
|
|
532
|
-
npm run
|
|
531
|
+
# Regenerar la referencia de API de la skill desde los tipos
|
|
532
|
+
npm run docs:skill
|
|
533
533
|
|
|
534
|
-
# Validar que
|
|
535
|
-
npm run
|
|
534
|
+
# Validar que la referencia commiteada esté al día
|
|
535
|
+
npm run docs:skill:check
|
|
536
536
|
|
|
537
537
|
# Ver ejemplos completos
|
|
538
538
|
npm run dev
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copia la skill `flysoft-ui` desde node_modules hacia el .claude/skills/ de la
|
|
5
|
+
* app consumidora.
|
|
6
|
+
*
|
|
7
|
+
* Claude Code no levanta skills desde node_modules, así que hace falta este paso
|
|
8
|
+
* de copia. La copia es DESTRUCTIVA Y COMPLETA: se borra el destino y se vuelve
|
|
9
|
+
* a copiar entero. Nunca hay merge. Si alguien editó la copia, esa edición se
|
|
10
|
+
* pierde — la fuente de verdad es el repo de flysoft-react-ui.
|
|
11
|
+
*
|
|
12
|
+
* Si el paquete instalado no trae `claude/` (versión anterior a la 1.4.0), avisa
|
|
13
|
+
* y sale con 0: no rompe el `update-libraries` de la app.
|
|
14
|
+
*
|
|
15
|
+
* Este archivo viaja dentro del paquete. Se puede correr de dos formas:
|
|
16
|
+
*
|
|
17
|
+
* node node_modules/flysoft-react-ui/claude/install-skill.mjs
|
|
18
|
+
*
|
|
19
|
+
* o copiándolo al repo de la app (recomendado, ver AGENT_INSTRUCTIONS del repo
|
|
20
|
+
* de la librería) para que el script exista aunque el paquete no lo traiga:
|
|
21
|
+
*
|
|
22
|
+
* cp node_modules/flysoft-react-ui/claude/install-skill.mjs scripts/
|
|
23
|
+
* node scripts/install-skill.mjs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
const SKILL_NAME = "flysoft-ui";
|
|
30
|
+
const PACKAGE_NAME = "flysoft-react-ui";
|
|
31
|
+
|
|
32
|
+
// Los scripts de npm corren con el cwd en la raíz del paquete de la app.
|
|
33
|
+
const appRoot = process.cwd();
|
|
34
|
+
const packageRoot = path.join(appRoot, "node_modules", PACKAGE_NAME);
|
|
35
|
+
const source = path.join(packageRoot, "claude", "skills", SKILL_NAME);
|
|
36
|
+
const target = path.join(appRoot, ".claude", "skills", SKILL_NAME);
|
|
37
|
+
|
|
38
|
+
const label = `${PACKAGE_NAME} → .claude/skills/${SKILL_NAME}`;
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(packageRoot)) {
|
|
41
|
+
console.warn(
|
|
42
|
+
`[skill] ${PACKAGE_NAME} no está instalado. Se omite la sincronización de la skill.`
|
|
43
|
+
);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(source)) {
|
|
48
|
+
let version = "desconocida";
|
|
49
|
+
try {
|
|
50
|
+
version = JSON.parse(
|
|
51
|
+
fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")
|
|
52
|
+
).version;
|
|
53
|
+
} catch {
|
|
54
|
+
// Sin package.json legible no vale la pena insistir: el aviso alcanza.
|
|
55
|
+
}
|
|
56
|
+
console.warn(
|
|
57
|
+
`[skill] ${PACKAGE_NAME}@${version} no incluye claude/skills/${SKILL_NAME}. ` +
|
|
58
|
+
`Se omite la sincronización (hace falta 1.4.0 o superior).`
|
|
59
|
+
);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let version = "desconocida";
|
|
64
|
+
try {
|
|
65
|
+
version = JSON.parse(
|
|
66
|
+
fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")
|
|
67
|
+
).version;
|
|
68
|
+
} catch {
|
|
69
|
+
// Se sigue igual: la versión es informativa.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Destructivo a propósito: cualquier edición local del destino se descarta.
|
|
74
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
75
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
76
|
+
fs.cpSync(source, target, { recursive: true });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`[skill] Falló la copia ${label}: ${error.message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const count = fs
|
|
83
|
+
.readdirSync(target, { recursive: true, withFileTypes: true })
|
|
84
|
+
.filter((entry) => entry.isFile()).length;
|
|
85
|
+
|
|
86
|
+
console.log(`[skill] ${label} — ${count} archivos desde v${version}.`);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flysoft-ui
|
|
3
|
+
description: Catálogo y referencia de API de flysoft-react-ui — componentes React themeados, hooks, providers, apiClient y templates. Usar al construir o modificar cualquier UI (formularios, listados, tablas, diálogos, filtros) en una app que tenga flysoft-react-ui como dependencia, y antes de escribir a mano un control que la librería probablemente ya tenga.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flysoft-react-ui
|
|
7
|
+
|
|
8
|
+
Librería de componentes React con tema y densidad centralizados. Todo se importa
|
|
9
|
+
desde el paquete raíz — nunca desde rutas internas:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { Card, DataTable, Input, Button, useAsyncRequest } from "flysoft-react-ui";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Cómo entran los estilos
|
|
16
|
+
|
|
17
|
+
Los estilos entran una sola vez. **Importalos desde el CSS de la app, dentro de
|
|
18
|
+
una capa**, no con un `import "flysoft-react-ui/styles"` en el entry de JS:
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
/* index.css de la app — el orden de capas va antes que cualquier @import */
|
|
22
|
+
@layer flysoft, base, components, utilities;
|
|
23
|
+
|
|
24
|
+
@import "flysoft-react-ui/styles" layer(flysoft);
|
|
25
|
+
@import "tailwindcss";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
El motivo importa, porque el modo de fallar es silencioso. La librería importa
|
|
29
|
+
`tailwindcss/theme` y `tailwindcss/utilities` por separado para saltear
|
|
30
|
+
Preflight, y al hacerlo pierde la asignación de capa que Tailwind hace en su
|
|
31
|
+
`index.css`: **sus utilities se publican sin capa**. En CSS, lo que no está en
|
|
32
|
+
ninguna capa le gana a todo lo que sí — así que sin este `layer(flysoft)` los
|
|
33
|
+
estilos de la librería pisan los de la app aunque la app tenga más
|
|
34
|
+
especificidad, y no hay forma de ganarles salvo con `!important`.
|
|
35
|
+
|
|
36
|
+
Declarando `flysoft` como la primera capa, la librería queda con la prioridad
|
|
37
|
+
más baja y la app la puede sobreescribir normalmente.
|
|
38
|
+
|
|
39
|
+
Si la app no usa capas para nada, `import "flysoft-react-ui/styles"` en el entry
|
|
40
|
+
alcanza. En cuanto la app pone algo en una capa —lo hace cualquier proyecto con
|
|
41
|
+
Tailwind v4— necesita la forma de arriba.
|
|
42
|
+
|
|
43
|
+
Los componentes se pintan con variables CSS del tema (`--color-*`, `--radius-*`)
|
|
44
|
+
y escalan con la densidad activa. No lleva colores hardcodeados y no hace falta
|
|
45
|
+
configurarle nada por componente: el tema y la densidad se setean una vez en el
|
|
46
|
+
provider.
|
|
47
|
+
|
|
48
|
+
## Antes de escribir un componente, fijate si ya existe
|
|
49
|
+
|
|
50
|
+
Cuando te pidan "un formulario con un buscador de clientes y una fecha", la
|
|
51
|
+
respuesta no es escribir un `<select>` con búsqueda ni un calendario. Casi
|
|
52
|
+
siempre ya hay un componente para eso, y uno hecho a mano queda fuera del tema,
|
|
53
|
+
fuera de la densidad y sin el comportamiento de teclado y accesibilidad que el
|
|
54
|
+
de la librería ya tiene.
|
|
55
|
+
|
|
56
|
+
| Si necesitás | Usá | No escribas |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Un desplegable con búsqueda sobre una lista que ya tenés en memoria | `AutocompleteInput` | `<select>`, combobox propio |
|
|
59
|
+
| Lo mismo pero el dataset es grande y se busca contra el backend | `SearchSelectInput` | fetch + lista propia |
|
|
60
|
+
| Un campo de fecha | `DateInput` | `<input type="date">`, calendario propio |
|
|
61
|
+
| Un calendario suelto, sin input | `DatePicker` | — |
|
|
62
|
+
| Un importe en pesos | `CurrencyInput` | `<input>` + formateo a mano |
|
|
63
|
+
| Una tabla con datos, orden y acciones por fila | `DataTable` | `<table>` |
|
|
64
|
+
| Filtros que viven en la URL | `Filter` | `useSearchParams` + inputs sueltos |
|
|
65
|
+
| Paginación | `Pagination` | controles propios |
|
|
66
|
+
| Un modal | `Dialog` | portal propio |
|
|
67
|
+
| Un toast / notificación | `useSnackbar()` | librería de toasts |
|
|
68
|
+
| Mostrar un par etiqueta-valor | `DataField` | `<div><label>` |
|
|
69
|
+
| Apilar o alinear elementos con separación | `Collection` | `<div className="flex gap-4">` |
|
|
70
|
+
| Un contenedor con título, acciones y footer | `Card` | `<div>` con bordes |
|
|
71
|
+
| Estado de carga | `Loader` o `Skeleton` | spinner propio |
|
|
72
|
+
|
|
73
|
+
Antes de dar por hecho que algo no existe, buscalo en la referencia de abajo:
|
|
74
|
+
son 33 componentes más 7 templates, y hay bastante más de lo que parece.
|
|
75
|
+
|
|
76
|
+
Si de verdad no existe, componelo con los que sí (`Card` + `Collection` +
|
|
77
|
+
`Input`) antes de escribir CSS nuevo, y usá siempre las variables del tema
|
|
78
|
+
(`references/theming.md`), nunca colores literales.
|
|
79
|
+
|
|
80
|
+
## Dónde está cada cosa
|
|
81
|
+
|
|
82
|
+
Las referencias se cargan sueltas: leé sólo la que necesites.
|
|
83
|
+
|
|
84
|
+
| Necesitás | Leé |
|
|
85
|
+
|---|---|
|
|
86
|
+
| Inputs, botones, selects, fechas, importes, paginación | `references/forms.md` |
|
|
87
|
+
| Tablas, campos de datos, badges, avatares, loaders, skeletons | `references/display.md` |
|
|
88
|
+
| Cards, layout de página, tabs, acordeones, diálogos, menús, filtros | `references/layout.md` |
|
|
89
|
+
| Providers, hooks y lo que devuelve cada uno | `references/hooks.md` |
|
|
90
|
+
| `apiClient`, helpers, interfaces compartidas | `references/services.md` |
|
|
91
|
+
| Formularios y páginas pre-armadas | `references/templates.md` |
|
|
92
|
+
| Variables CSS de tema y densidad | `references/theming.md` |
|
|
93
|
+
| **Cuándo usar qué, patrones de página, densidad, contratos de los genéricos** | **`patterns.md`** |
|
|
94
|
+
|
|
95
|
+
Los archivos bajo `references/` se generan desde los tipos de la librería en
|
|
96
|
+
cada build. Si una tabla no coincide con el código, el que está mal es el tipo,
|
|
97
|
+
no la tabla.
|
|
98
|
+
|
|
99
|
+
`patterns.md` es lo escrito a mano: empezá por ahí si la pregunta es "cómo se
|
|
100
|
+
arma una página de listado" y no "qué props tiene `DataTable`".
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# Cómo se usa flysoft-react-ui
|
|
2
|
+
|
|
3
|
+
Esto es lo que los tipos no dicen: cuándo elegir un componente sobre otro, qué
|
|
4
|
+
forma tiene que tener un genérico, y cómo se arma una página completa. Para
|
|
5
|
+
firmas y props, `references/`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## El sistema de densidad
|
|
10
|
+
|
|
11
|
+
Es lo que más se malinterpreta, porque no se ve en las props de ningún
|
|
12
|
+
componente.
|
|
13
|
+
|
|
14
|
+
La densidad es **global y se setea una sola vez**, en el provider:
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
<AppLayoutProvider density="compact">
|
|
18
|
+
<App />
|
|
19
|
+
</AppLayoutProvider>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Los valores son `"comfortable" | "compact" | "dense"`. El provider escribe 26
|
|
23
|
+
variables CSS (`--flysoft-density-*`) en el `<html>`, y **todos** los componentes
|
|
24
|
+
de la librería leen de ahí: padding, tipografía, alto de los controles, alto de
|
|
25
|
+
las filas de `DataTable`, radio de los inputs, separación de `Collection`.
|
|
26
|
+
|
|
27
|
+
La consecuencia práctica es la que importa:
|
|
28
|
+
|
|
29
|
+
> **No pases `compact` ni `size="sm"` componente por componente para achicar la
|
|
30
|
+
> UI.** Esas props existen y funcionan, pero son overrides puntuales. Si las usás
|
|
31
|
+
> para lograr una UI compacta, terminás con una densidad distinta en cada
|
|
32
|
+
> pantalla, y el día que cambie la densidad global esos componentes no la siguen.
|
|
33
|
+
|
|
34
|
+
Si toda la app tiene que verse más compacta, se cambia una palabra en el
|
|
35
|
+
provider. Si necesitás densidad distinta sólo en una sección, hay un override
|
|
36
|
+
por subárbol:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
<Collection density="dense">
|
|
40
|
+
{/* estos hijos y sus descendientes usan la escala "dense" */}
|
|
41
|
+
</Collection>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
La densidad se persiste en `localStorage` bajo `"flysoft-density"` y se puede
|
|
45
|
+
cambiar en runtime:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
const { density, setDensity } = useAppLayout();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Para forzar un valor e ignorar lo guardado, `forceInitialDensity`. Los valores
|
|
52
|
+
concretos de cada token están en `references/theming.md`.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Qué forma tiene que tener `T`
|
|
57
|
+
|
|
58
|
+
Varios componentes son genéricos sin restricción: el tipo acepta cualquier `T`,
|
|
59
|
+
pero el componente en runtime espera algo. Este es el contrato real.
|
|
60
|
+
|
|
61
|
+
### `AutocompleteInput<T, K>` y `SearchSelectInput<T, K>`
|
|
62
|
+
|
|
63
|
+
Sin `getOptionLabel` / `getOptionValue`, el componente lee `item.label` e
|
|
64
|
+
`item.value`. O sea que `T` tiene que ser `{ label: string; value: string }`
|
|
65
|
+
—las interfaces `AutocompleteOption` y `SearchSelectOption` son exactamente eso.
|
|
66
|
+
|
|
67
|
+
Con tus propios objetos, pasás los dos getters y `T` puede ser lo que quieras:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
<AutocompleteInput<Cliente, number>
|
|
71
|
+
label="Cliente"
|
|
72
|
+
options={clientes}
|
|
73
|
+
getOptionLabel={(c) => c.razonSocial}
|
|
74
|
+
getOptionValue={(c) => c.id}
|
|
75
|
+
/>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Si omitís los getters con un `T` que no tiene `label`, no hay error de
|
|
79
|
+
compilación: la lista se renderiza con todas las etiquetas vacías. Es el modo de
|
|
80
|
+
fallar más común de estos dos componentes.
|
|
81
|
+
|
|
82
|
+
### `DataTable<T>`
|
|
83
|
+
|
|
84
|
+
`column.value` acepta un string, que se usa como nombre de propiedad de la fila.
|
|
85
|
+
**Si la propiedad no existe, se renderiza el string tal cual**, como texto
|
|
86
|
+
literal. Un typo en el nombre del campo no rompe: te muestra `"nomrbe"` en la
|
|
87
|
+
celda. El tipo no puede detectarlo porque `value` es `string`, no `keyof T`.
|
|
88
|
+
|
|
89
|
+
Cuando el dato no sale de una propiedad directa, usá la forma de función, que sí
|
|
90
|
+
está tipada:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
{ header: "Total", value: (row) => currencyFormat(row.importe * row.cantidad) }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `Menu<T>` y `DropdownMenu<T>`
|
|
97
|
+
|
|
98
|
+
Mismo contrato que `AutocompleteInput`: sin `getOptionLabel`, leen `item.label`.
|
|
99
|
+
|
|
100
|
+
### `CrudProvider<T>` / `useCrud<T>`
|
|
101
|
+
|
|
102
|
+
`T` es la entidad. Ojo con la forma de lo que devuelve `useCrud`: las
|
|
103
|
+
operaciones **no son funciones**, son objetos con `execute` e `isLoading`
|
|
104
|
+
propios.
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
const { list, fetchItems, createItem } = useCrud<Usuario>();
|
|
108
|
+
|
|
109
|
+
await fetchItems.execute({ pagina: 2 }); // correcto
|
|
110
|
+
await fetchItems(); // no compila
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Cuándo usar qué
|
|
116
|
+
|
|
117
|
+
**`Card` vs `Collection` vs `DataField`.** `Card` es una superficie con título,
|
|
118
|
+
acciones y footer — un bloque de contenido con identidad. `Collection` no dibuja
|
|
119
|
+
nada: sólo apila o alinea con la separación de la densidad activa; es el
|
|
120
|
+
reemplazo de `<div className="flex gap-4">`. `DataField` es un par
|
|
121
|
+
etiqueta-valor para pantallas de detalle, no un contenedor.
|
|
122
|
+
|
|
123
|
+
**`AutocompleteInput` vs `SearchSelectInput`.** Si las opciones ya están en
|
|
124
|
+
memoria y son unas pocas decenas, `AutocompleteInput`. Si hay que ir al backend
|
|
125
|
+
a buscarlas, `SearchSelectInput`, que abre un diálogo y recibe dos promesas: una
|
|
126
|
+
para buscar por texto y otra para resolver un valor ya seleccionado
|
|
127
|
+
(`onSingleSearchPromiseFn`, necesaria para poder mostrar el label cuando la
|
|
128
|
+
pantalla carga con un valor preexistente).
|
|
129
|
+
|
|
130
|
+
**`DataTable` vs `Collection` de `Card`s.** Tabla cuando las filas comparten
|
|
131
|
+
columnas y se comparan entre sí. Cards cuando cada ítem tiene forma propia.
|
|
132
|
+
|
|
133
|
+
**`Dialog` vs `DropdownPanel`.** `Dialog` bloquea e interrumpe: confirmaciones y
|
|
134
|
+
formularios. `DropdownPanel` es un popover anclado a un disparador, para filtros
|
|
135
|
+
y opciones.
|
|
136
|
+
|
|
137
|
+
**Template vs composición.** Los templates (`ListPattern`, `FormPattern`,
|
|
138
|
+
`LoginForm`) resuelven un caso entero. Sirven mientras tu caso sea el suyo; en
|
|
139
|
+
cuanto necesitás desviarte, componé con `Card` + `DataTable` + `Filter` en vez
|
|
140
|
+
de pelearle las props. `ListPattern` es el que más rinde porque el listado
|
|
141
|
+
paginado con filtros es siempre igual.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Patrones
|
|
146
|
+
|
|
147
|
+
### Formulario con guardado async
|
|
148
|
+
|
|
149
|
+
`useAsyncRequest` envuelve la promesa, maneja el `isLoading` y dispara el
|
|
150
|
+
snackbar de éxito o error. Necesita un `SnackbarProvider` arriba —
|
|
151
|
+
`AppLayoutProvider` ya lo incluye.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
const { execute, isLoading } = useAsyncRequest({
|
|
155
|
+
successMessage: "Guardado exitosamente",
|
|
156
|
+
errorMessage: (err) => getErrorMessage(err),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const guardar = () =>
|
|
160
|
+
execute(() => apiClient.post({ url: "/api/usuarios", body: { nombre, email } }));
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Card title="Nuevo usuario">
|
|
164
|
+
<Collection>
|
|
165
|
+
<Input label="Nombre" value={nombre} onChange={(e) => setNombre(e.target.value)} icon="fa-user" />
|
|
166
|
+
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} icon="fa-envelope" />
|
|
167
|
+
<Button icon="fa-save" loading={isLoading} onClick={guardar}>Guardar</Button>
|
|
168
|
+
</Collection>
|
|
169
|
+
</Card>
|
|
170
|
+
);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`execute` devuelve el resultado de la promesa, o `undefined` si falló — así que
|
|
174
|
+
podés encadenar sin try/catch:
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
const creado = await execute(() => apiClient.post<Usuario>({ url: "/api/usuarios", body }));
|
|
178
|
+
if (creado) navigate(`/usuarios/${creado.id}`);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Confirmación con Dialog
|
|
182
|
+
|
|
183
|
+
`isOpen`, `title` y `children` son requeridas. Las acciones van en `footer`.
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
<Dialog
|
|
187
|
+
isOpen={mostrarConfirmacion}
|
|
188
|
+
title="Confirmar eliminación"
|
|
189
|
+
onClose={() => setMostrarConfirmacion(false)}
|
|
190
|
+
footer={
|
|
191
|
+
<>
|
|
192
|
+
<Button variant="ghost" onClick={() => setMostrarConfirmacion(false)}>Cancelar</Button>
|
|
193
|
+
<Button color="danger" loading={isLoading} onClick={eliminar}>Eliminar</Button>
|
|
194
|
+
</>
|
|
195
|
+
}
|
|
196
|
+
>
|
|
197
|
+
<p>¿Está seguro? Esta acción no se puede deshacer.</p>
|
|
198
|
+
</Dialog>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Por defecto el click en el overlay **no** cierra el diálogo (`closeOnOverlayClick`
|
|
202
|
+
es `false`), que es lo que querés en una confirmación destructiva.
|
|
203
|
+
|
|
204
|
+
### Página de listado
|
|
205
|
+
|
|
206
|
+
Los `Filter` se sincronizan solos con el query string a través de `paramName`, y
|
|
207
|
+
`Pagination` hace lo mismo con `fieldName` (por defecto `"pagina"`). No hace
|
|
208
|
+
falta cablear nada entre ellos: el efecto que dispara la búsqueda escucha los
|
|
209
|
+
search params.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
function ListadoUsuarios() {
|
|
213
|
+
const [searchParams] = useSearchParams();
|
|
214
|
+
const [usuarios, setUsuarios] = useState<Usuario[]>([]);
|
|
215
|
+
const [paginacion, setPaginacion] = useState({ page: 1, pages: 1, total: 0 });
|
|
216
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
217
|
+
const { showSnackbar } = useSnackbar();
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const buscar = async () => {
|
|
221
|
+
setIsLoading(true);
|
|
222
|
+
try {
|
|
223
|
+
const params = Object.fromEntries(searchParams);
|
|
224
|
+
const data = await apiClient.get<PaginationInterface<Usuario>>({
|
|
225
|
+
url: "/api/usuarios",
|
|
226
|
+
params,
|
|
227
|
+
});
|
|
228
|
+
setUsuarios(data.list);
|
|
229
|
+
setPaginacion({ page: data.page, pages: data.pages, total: data.total });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
showSnackbar(getErrorMessage(error), "danger");
|
|
232
|
+
} finally {
|
|
233
|
+
setIsLoading(false);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
buscar();
|
|
237
|
+
}, [searchParams]);
|
|
238
|
+
|
|
239
|
+
const columns: DataTableColumn<Usuario>[] = [
|
|
240
|
+
{ header: "Nombre", value: "nombre" },
|
|
241
|
+
{ header: "Email", value: "email" },
|
|
242
|
+
{ header: "Alta", value: "fechaAlta", type: "date" },
|
|
243
|
+
{
|
|
244
|
+
actions: (row) => [
|
|
245
|
+
<LinkButton key="ver" to={`/usuarios/${row.id}`} variant="ghost" icon="fa-eye" />,
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<Collection>
|
|
252
|
+
<Collection direction="row">
|
|
253
|
+
<Filter filterType="search" paramName="nombre" label="Nombre" />
|
|
254
|
+
<Filter
|
|
255
|
+
filterType="autocomplete"
|
|
256
|
+
paramName="rol"
|
|
257
|
+
label="Rol"
|
|
258
|
+
options={[
|
|
259
|
+
{ label: "Administrador", value: "admin" },
|
|
260
|
+
{ label: "Operador", value: "operador" },
|
|
261
|
+
]}
|
|
262
|
+
/>
|
|
263
|
+
</Collection>
|
|
264
|
+
|
|
265
|
+
<Card
|
|
266
|
+
title="Usuarios"
|
|
267
|
+
headerActions={<LinkButton to="/usuarios/nuevo" icon="fa-plus">Nuevo</LinkButton>}
|
|
268
|
+
>
|
|
269
|
+
<DataTable columns={columns} rows={usuarios} isLoading={isLoading} />
|
|
270
|
+
<Pagination {...paginacion} isLoading={isLoading} />
|
|
271
|
+
</Card>
|
|
272
|
+
</Collection>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`DataTable` formatea solo según `type`: `"currency"` a miles con dos decimales y
|
|
278
|
+
sin símbolo, `"numeric"` según el `locale` (por defecto `"es-AR"`), `"date"` a
|
|
279
|
+
`DD/MM/YYYY`. Las columnas `currency` y `numeric` además se alinean solas a la
|
|
280
|
+
derecha, no hace falta pasar `align`.
|
|
281
|
+
|
|
282
|
+
### El mismo listado con ListPattern
|
|
283
|
+
|
|
284
|
+
Cuando el caso es el estándar, `ListPattern` reemplaza todo el JSX de arriba:
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
<ListPattern<Usuario>
|
|
288
|
+
title="Usuarios"
|
|
289
|
+
columns={columns}
|
|
290
|
+
rows={usuarios}
|
|
291
|
+
searchParamName="nombre"
|
|
292
|
+
addButtonText="Nuevo"
|
|
293
|
+
onAdd={() => navigate("/usuarios/nuevo")}
|
|
294
|
+
filtersNode={<Filter filterType="autocomplete" paramName="rol" label="Rol" options={roles} />}
|
|
295
|
+
page={paginacion.page}
|
|
296
|
+
pages={paginacion.pages}
|
|
297
|
+
total={paginacion.total}
|
|
298
|
+
isLoading={isLoading}
|
|
299
|
+
/>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Token de autenticación en el apiClient
|
|
303
|
+
|
|
304
|
+
Se registra una vez, y todas las llamadas lo mandan:
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
setApiClientTokenProvider(() => user?.token?.accessToken);
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Es un *provider*, no un valor: se evalúa en cada request, así que un refresh de
|
|
311
|
+
token se refleja solo sin volver a registrar nada.
|
|
312
|
+
|
|
313
|
+
### Controlar el drawer desde el contenido
|
|
314
|
+
|
|
315
|
+
En desktop el drawer está siempre visible; en móvil es un panel con overlay que
|
|
316
|
+
queda abierto después de navegar. Por eso los links del drawer lo cierran:
|
|
317
|
+
|
|
318
|
+
```tsx
|
|
319
|
+
const { closeLeftDrawer } = useLeftDrawer();
|
|
320
|
+
|
|
321
|
+
<LinkButton to="/inicio" onClick={closeLeftDrawer}>Inicio</LinkButton>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`useLeftDrawer()` lanza error fuera de un `AppLayout`. Para componentes que se
|
|
325
|
+
usan dentro y fuera, `useOptionalLeftDrawer()` devuelve `undefined`.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Íconos
|
|
330
|
+
|
|
331
|
+
FontAwesome 5, estilo light. Se pasa sólo el nombre y la librería lo normaliza a
|
|
332
|
+
`fal fa-*`:
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
<Button icon="fa-save">Guardar</Button>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
No mezclar con otras librerías de íconos: no van a heredar el color ni el tamaño
|
|
339
|
+
del tema.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Trampas verificadas
|
|
344
|
+
|
|
345
|
+
- **`CurrencyInput.onChange` está tipado `(value: any) => void`.** El valor real
|
|
346
|
+
que recibís es `number`. Tipá tu handler como `number` igual.
|
|
347
|
+
- **`DataTable` con un `value` string que no existe en la fila** renderiza el
|
|
348
|
+
string como texto, no falla. Revisá los nombres de campo.
|
|
349
|
+
- **`useCrud()` devuelve objetos, no funciones** (`fetchItems.execute()`).
|
|
350
|
+
- **`NavbarInterface.fullWidthNavbar` es requerida**, no opcional.
|
|
351
|
+
- **`Collection` es `direction="column"` por defecto**, no `row`.
|
|
352
|
+
- **`SnackbarContainer` es `position="top-right"` por defecto.**
|