ascua-scaffold 0.1.0 → 0.2.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
@@ -37,6 +37,8 @@ src/resources/products.ts el esquema del formulario, sus campos y las conv
37
37
  src/routes/products/index.ts /products, de 50 en 50
38
38
  src/routes/products/new.ts /products/new
39
39
  src/routes/products/[id].ts /products/:id, editar y borrar (?/delete)
40
+ src/routes/products/grid.ts /products/grid, todas las filas editables a la vez
41
+ src/resources/grid.ts leer lo que manda una grilla (uno para todo el sitio)
40
42
  src/pool.ts el pool, con las migraciones aplicadas una vez (si no existe)
41
43
  ```
42
44
 
@@ -66,6 +68,21 @@ obligatoria y no tiene `DEFAULT`, el comando lo dice y no genera nada.
66
68
  Lo generado es tuyo: no se vuelve a generar. Con `--force` se reescribe
67
69
  encima.
68
70
 
71
+ ## La grilla
72
+
73
+ `/products/grid` es la página entera editable, como una DataWindow de
74
+ PowerBuilder: una fila por registro y una celda por campo, de 50 en 50. Es
75
+ un `<form>` de HTML, así que también funciona sin JavaScript.
76
+
77
+ - Cada fila lleva los valores con que se mostró: al guardar, solo se
78
+ escriben las filas que cambiaron.
79
+ - Cada fila cambiada se valida con el mismo esquema del formulario
80
+ (`ProductForm`) y se guardan todas en una sola transacción
81
+ (`transaction()` de brasa): si una falla, no se escribe ninguna, y el
82
+ error aparece en su celda.
83
+ - Un `UNIQUE` repetido vuelve con un 409 y el error bajo la celda de esa
84
+ fila.
85
+
69
86
  ## Requisitos
70
87
 
71
88
  - Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
package/generate.js CHANGED
@@ -426,7 +426,7 @@ export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof
426
426
  return view\`
427
427
  <section>
428
428
  <h1>${titulo}</h1>
429
- <p><a href="${base}/new">New ${uno}</a></p>
429
+ <p><a href="${base}/new">New ${uno}</a> · <a href="${base}/grid">Edit as a grid</a></p>
430
430
  <table>
431
431
  <thead><tr>${cabeceras}</tr></thead>
432
432
  <tbody>
@@ -554,6 +554,217 @@ export default function Edit${n.uno}({ data, form }: { data: Awaited<ReturnType<
554
554
  return { index, nuevo, editar };
555
555
  }
556
556
 
557
+ /** `src/resources/grid.ts`: leer lo que manda una grilla. Uno para todo el sitio. */
558
+ export function ayudaRejilla() {
559
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
560
+ //
561
+ // A grid is a plain <form>: one row per record, one field per cell. Each row
562
+ // carries its key (k0, k1…) and the values it was shown with (w0, w1…), so
563
+ // the server only writes the rows that changed. Cell names are c<row>.<column>.
564
+
565
+ export type FormValues = Partial<Record<string, string | string[]>>;
566
+ export type FormErrors = Partial<Record<string, string>>;
567
+
568
+ /** A row of the grid: its key, as text, and the values of its fields. */
569
+ export interface GridRow {
570
+ key: string;
571
+ values: FormValues;
572
+ }
573
+
574
+ /** Every row the grid sent, and the ones that changed. */
575
+ export function readGrid(formData: FormData, columns: readonly string[]): { rows: GridRow[]; changed: GridRow[] } {
576
+ const rows: GridRow[] = [];
577
+ const changed: GridRow[] = [];
578
+ for (let i = 0; formData.has(\`k\${i}\`); i++) {
579
+ const key = String(formData.get(\`k\${i}\`));
580
+ const values: FormValues = {};
581
+ // An unchecked checkbox sends nothing, and stays missing: field.checkbox()
582
+ // takes any value that arrived, even "", as checked.
583
+ for (const column of columns) {
584
+ const value = formData.get(\`c\${i}.\${column}\`);
585
+ if (typeof value === "string") values[column] = value;
586
+ }
587
+ let was: FormValues = {};
588
+ try {
589
+ was = JSON.parse(String(formData.get(\`w\${i}\`) ?? "{}"));
590
+ } catch {
591
+ // Without what it was shown with, the row counts as changed.
592
+ }
593
+ rows.push({ key, values });
594
+ if (columns.some((column) => (was[column] ?? "") !== (values[column] ?? ""))) changed.push({ key, values });
595
+ }
596
+ return { rows, changed };
597
+ }
598
+ `;
599
+ }
600
+
601
+ /** Una celda editable de la grilla, con su error debajo. */
602
+ function celda(c, valoresEnum) {
603
+ const nombre = `\${\`c\${r.index}.${c.name}\`}`;
604
+ const v = `r.values${acceso(c.name)}`;
605
+ const texto = `String(${v} ?? "")`;
606
+ let html;
607
+ switch (c.kind) {
608
+ case "boolean":
609
+ html = `<input type="checkbox" name=${nombre} prop:checked=\${${v} === "on"}>`;
610
+ break;
611
+ case "enum": {
612
+ const vacia = c.nullable ? `<option value=""></option>` : "";
613
+ html =
614
+ `<select name=${nombre}>${vacia}<For each=\${() => ${valoresEnum}} key=\${(v: string) => v} ` +
615
+ `render=\${(v: string) => view\`<option prop:selected=\${${v} === v}>\${v}</option>\`}/></select>`;
616
+ break;
617
+ }
618
+ case "integer":
619
+ html = `<input type="number" step="1" name=${nombre} prop:value=\${${texto}}>`;
620
+ break;
621
+ case "decimal":
622
+ case "number":
623
+ html = `<input type="number" step="any" name=${nombre} prop:value=\${${texto}}>`;
624
+ break;
625
+ case "date":
626
+ html = `<input type="date" name=${nombre} prop:value=\${${texto}}>`;
627
+ break;
628
+ case "datetime":
629
+ html = `<input type="datetime-local" name=${nombre} prop:value=\${${texto}}>`;
630
+ break;
631
+ case "time":
632
+ html = `<input type="time" name=${nombre} prop:value=\${${texto}}>`;
633
+ break;
634
+ default:
635
+ html = `<input name=${nombre} prop:value=\${${texto}}>`;
636
+ }
637
+ return `<td>${html}<small class="error">\${r.errors${acceso(c.name)} ?? ""}</small></td>`;
638
+ }
639
+
640
+ /** Los valores de un enum en el código generado: `<x>Values` o escritos. */
641
+ export function valoresEnumDe(c, enums) {
642
+ return c.values ? (enums.has(c.sqlType) ? `${camel(c.sqlType)}Values` : JSON.stringify(c.values)) : "[]";
643
+ }
644
+
645
+ /** `src/routes/<tabla>/grid.ts`: la página entera editable, como una DataWindow. */
646
+ export function rejilla(tabla, a, n, { db, pool, resource, grid, base, enums }) {
647
+ const { guarda, valor } = claveDeUrl(a);
648
+ const pk = a.clave.name;
649
+ const titulo = etiqueta(tabla.name);
650
+ const forma = `${n.uno}Form`;
651
+ const params = `${camel(n.uno)}Params`;
652
+ const values = `${camel(n.uno)}Values`;
653
+ const importsEnum = [...new Set(a.alEditar.filter((c) => c.kind === "enum" && enums.has(c.sqlType)).map((c) => `${camel(c.sqlType)}Values`))];
654
+ const columnas = a.alEditar.map((c) => JSON.stringify(c.name)).join(", ");
655
+ const cabeceras = [pk, ...a.alEditar.map((c) => c.name)].map((c) => `<th>${etiqueta(c)}</th>`).join("");
656
+ const celdas = a.alEditar.map((c) => celda(c, valoresEnumDe(c, enums))).join("\n ");
657
+
658
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
659
+ //
660
+ // Every ${singular(tabla.name)} of the page, editable at once. Saving validates each changed
661
+ // row with ${forma} and writes them all in one transaction: if one fails,
662
+ // none is written, and the error shows in its cell.
663
+ import { fail, notFound, redirect, validate, type ActionContext, type LoadContext } from "vite-plugin-ascua/site";
664
+
665
+ import { ${[...importsEnum, camel(n.list), "transaction", camel(n.update)].join(", ")} } from "${db}";
666
+ import { db } from "${pool}";
667
+ import { readGrid, type FormErrors, type FormValues } from "${grid}";
668
+ import { ${forma}, ${params}, ${values}, duplicate, type ${n.uno}Data } from "${resource}";
669
+
670
+ const PAGE = 50;
671
+ const COLUMNS = [${columnas}] as const;
672
+
673
+ export const title = "${titulo}";
674
+
675
+ /** The key from the form, or a 404 if it cannot be one. */
676
+ function key(id: string) {
677
+ if (!(${guarda})) throw notFound();
678
+ return ${valor};
679
+ }
680
+
681
+ export async function load(_params: Record<string, string>, { url }: LoadContext) {
682
+ const page = Math.max(1, Math.floor(Number(url.searchParams.get("page"))) || 1);
683
+ const rows = await ${camel(n.list)}(await db(), { limit: PAGE + 1, offset: (page - 1) * PAGE });
684
+ return {
685
+ page,
686
+ more: rows.length > PAGE,
687
+ rows: rows.slice(0, PAGE).map((row) => ({ key: String(row${acceso(pk)}), values: ${values}(row) })),
688
+ };
689
+ }
690
+
691
+ /** What a failed save sends back: the errors and what was written, by row key. */
692
+ interface Failed {
693
+ errors: Record<string, FormErrors>;
694
+ values: Record<string, FormValues>;
695
+ }
696
+
697
+ export const actions = {
698
+ async default({ formData, url }: ActionContext) {
699
+ const { rows, changed } = readGrid(formData, COLUMNS);
700
+ const values = Object.fromEntries(rows.map((row) => [row.key, row.values]));
701
+ const errors: Record<string, FormErrors> = {};
702
+ const valid: { key: string; data: ${n.uno}Data }[] = [];
703
+ for (const row of changed) {
704
+ const result = await validate(${forma}, row.values);
705
+ if (result.ok) valid.push({ key: row.key, data: result.data });
706
+ else errors[row.key] = result.errors;
707
+ }
708
+ if (Object.keys(errors).length) return fail(400, { errors, values } satisfies Failed);
709
+
710
+ let current = "";
711
+ try {
712
+ await transaction(await db(), async (tx) => {
713
+ for (const row of valid) {
714
+ current = row.key;
715
+ await ${camel(n.update)}(tx, { ...${params}(row.data), ${propiedad(pk)}: key(row.key) });
716
+ }
717
+ });
718
+ } catch (error) {
719
+ // A repeated UNIQUE value: the error goes under that row's cell.
720
+ const taken = duplicate(error, new FormData());
721
+ return fail(409, { errors: { [current]: taken.data.errors }, values } satisfies Failed);
722
+ }
723
+ throw redirect(url.pathname + url.search);
724
+ },
725
+ };
726
+
727
+ export default function ${n.plural}Grid({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: Failed }) {
728
+ const rows = data.rows.map((row, index) => ({
729
+ index,
730
+ key: row.key,
731
+ was: JSON.stringify(row.values),
732
+ values: form?.values[row.key] ?? row.values,
733
+ errors: form?.errors[row.key] ?? {},
734
+ }));
735
+ type Row = (typeof rows)[number];
736
+ return view\`
737
+ <section>
738
+ <h1>${titulo}</h1>
739
+ <p><a href="${base}">List</a> · <a href="${base}/new">New ${etiqueta(singular(tabla.name)).toLowerCase()}</a></p>
740
+ <form method="post">
741
+ <table class="grid">
742
+ <thead><tr>${cabeceras}</tr></thead>
743
+ <tbody>
744
+ <For each=\${() => rows} key=\${(r: Row) => r.key}
745
+ render=\${(r: Row) => view\`<tr>
746
+ <td><a href=\${\`${base}/\${r.key}\`}>\${r.key}</a><input type="hidden" name=\${\`k\${r.index}\`} value=\${r.key}><input type="hidden" name=\${\`w\${r.index}\`} value=\${r.was}><small class="error">\${r.errors[""] ?? ""}</small></td>
747
+ ${celdas}
748
+ </tr>\`}/>
749
+ </tbody>
750
+ </table>
751
+ <button>Save changes</button>
752
+ </form>
753
+ <nav>
754
+ <Show when=\${() => data.page > 1}><a href=\${\`?page=\${data.page - 1}\`}>Previous</a></Show>
755
+ <Show when=\${() => data.more}><a href=\${\`?page=\${data.page + 1}\`}>Next</a></Show>
756
+ </nav>
757
+ <style>
758
+ .grid input, .grid select { width: 100%; box-sizing: border-box; }
759
+ .grid input[type="checkbox"] { width: auto; }
760
+ .grid td { vertical-align: top; }
761
+ .error { display: block; color: crimson; }
762
+ </style>
763
+ </section>\`;
764
+ }
765
+ `;
766
+ }
767
+
557
768
  /** `src/pool.ts`: un pool para todo el servidor, con las migraciones aplicadas una vez. */
558
769
  export function piscina({ mysql, migrate, db }) {
559
770
  if (mysql) {
package/index.js CHANGED
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
16
16
  import { dirname, join, relative, resolve } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
 
19
- import { analizar, consultasSql, nombres, piscina, recurso, rutas, ScaffoldError, t } from "./generate.js";
19
+ import { analizar, ayudaRejilla, consultasSql, nombres, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
20
20
 
21
21
  const AYUDA = t(
22
22
  `Uso: ascua-scaffold <tabla> [opciones]
@@ -27,6 +27,8 @@ Genera, a partir de una tabla de las migraciones de brasa:
27
27
  src/routes/<tabla>/index.ts la lista, paginada
28
28
  src/routes/<tabla>/new.ts el alta
29
29
  src/routes/<tabla>/[id].ts la edición y la baja
30
+ src/routes/<tabla>/grid.ts todas las filas editables a la vez
31
+ src/resources/grid.ts leer lo que manda una grilla, si no existe
30
32
  src/pool.ts el pool de la base, si no existe
31
33
 
32
34
  Opciones:
@@ -41,6 +43,8 @@ Generates, from a table in the brasa migrations:
41
43
  src/routes/<table>/index.ts the list, paginated
42
44
  src/routes/<table>/new.ts creating
43
45
  src/routes/<table>/[id].ts editing and deleting
46
+ src/routes/<table>/grid.ts every row editable at once
47
+ src/resources/grid.ts reading what a grid sends, if missing
44
48
  src/pool.ts the database pool, if missing
45
49
 
46
50
  Options:
@@ -138,6 +142,14 @@ export async function main(argv = process.argv.slice(2)) {
138
142
  const propio = join(dirConsultas, `${tabla.name}.sql`);
139
143
  const ajenas = salida.queries.filter((x) => !(args.force && resolve(x.file) === propio));
140
144
  const repetidas = [n.list, n.get, n.create, n.update, n.delete].filter((q) => ajenas.some((x) => x.name === q));
145
+ const enPropio = repetidas.filter((q) => salida.queries.some((x) => x.name === q && resolve(x.file) === propio));
146
+ if (repetidas.length && enPropio.length === repetidas.length) {
147
+ // Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
148
+ const archivo = relative(process.cwd(), propio);
149
+ throw new ScaffoldError(
150
+ t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`),
151
+ );
152
+ }
141
153
  if (repetidas.length) {
142
154
  throw new ScaffoldError(
143
155
  t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`),
@@ -145,6 +157,9 @@ export async function main(argv = process.argv.slice(2)) {
145
157
  }
146
158
 
147
159
  const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
160
+ if (carpeta === "grid") {
161
+ throw new ScaffoldError(t("una tabla `grid` chocaría con src/resources/grid.ts, que comparten las grillas", "a `grid` table would clash with src/resources/grid.ts, which the grids share"));
162
+ }
148
163
  // Los enums de Postgres tienen su `<x>Values` en el módulo; los de MySQL
149
164
  // (`ENUM('a','b')` en la columna), no: sus valores van escritos.
150
165
  const enums = new Set(mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
@@ -155,9 +170,11 @@ export async function main(argv = process.argv.slice(2)) {
155
170
  index: join(raiz, "src", "routes", carpeta, "index.ts"),
156
171
  nuevo: join(raiz, "src", "routes", carpeta, "new.ts"),
157
172
  editar: join(raiz, "src", "routes", carpeta, "[id].ts"),
173
+ rejilla: join(raiz, "src", "routes", carpeta, "grid.ts"),
158
174
  pool: join(raiz, "src", "pool.ts"),
175
+ ayuda: join(raiz, "src", "resources", "grid.ts"),
159
176
  };
160
- const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && existsSync(f));
177
+ const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && k !== "ayuda" && existsSync(f));
161
178
  if (existentes.length && !args.force) {
162
179
  throw new ScaffoldError(
163
180
  t(`ya existen: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Usa --force para reescribirlos`, `already exist: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Use --force to overwrite them`),
@@ -176,6 +193,14 @@ export async function main(argv = process.argv.slice(2)) {
176
193
  index: r.index,
177
194
  nuevo: r.nuevo,
178
195
  editar: r.editar,
196
+ rejilla: rejilla(tabla, a, n, {
197
+ db: importar(archivos.rejilla, out),
198
+ pool: importar(archivos.rejilla, archivos.pool),
199
+ resource: importar(archivos.rejilla, archivos.recurso),
200
+ grid: importar(archivos.rejilla, archivos.ayuda),
201
+ base: `/${carpeta}`,
202
+ enums,
203
+ }),
179
204
  };
180
205
  const escritos = [];
181
206
  for (const [k, contenido] of Object.entries(contenidos)) {
@@ -183,6 +208,10 @@ export async function main(argv = process.argv.slice(2)) {
183
208
  writeFileSync(archivos[k], contenido);
184
209
  escritos.push(archivos[k]);
185
210
  }
211
+ if (!existsSync(archivos.ayuda)) {
212
+ writeFileSync(archivos.ayuda, ayudaRejilla());
213
+ escritos.push(archivos.ayuda);
214
+ }
186
215
  if (!existsSync(archivos.pool)) {
187
216
  writeFileSync(archivos.pool, piscina({ mysql, migrate: config.migrate, db: importar(archivos.pool, out) }));
188
217
  escritos.push(archivos.pool);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ascua-scaffold",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "De una tabla de brasa a un recurso de Ascua: consultas, formulario validado y rutas para listar, crear, editar y borrar.",
5
5
  "keywords": ["ascua", "scaffold", "crud", "brasa", "sql", "forms"],
6
6
  "homepage": "https://ascua.gitweave.run",